Compare commits

...

28 Commits

61 changed files with 1243 additions and 829 deletions

86
.github/workflows/dev.yml vendored Normal file
View File

@@ -0,0 +1,86 @@
name: Development Test
on:
workflow_dispatch:
permissions: write-all
env:
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: short
concurrency:
# only allow per workflow per commit (and not pr) to run at a time
group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
dev:
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
bundle: nsis
- os: macos-latest
target: aarch64-apple-darwin
bundle: dmg
- os: macos-latest
target: x86_64-apple-darwin
bundle: dmg
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@1.77.0
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
cache-all-crates: true
cache-on-failure: true
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: "20"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Pnpm install and check
run: |
pnpm i
pnpm check ${{ matrix.target }}
- name: Tauri build
uses: tauri-apps/tauri-action@v0
env:
NODE_OPTIONS: "--max_old_space_size=4096"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tauriScript: pnpm
args: --target ${{ matrix.target }} -b ${{ matrix.bundle }}
- name: Upload Artifacts
if: matrix.os == 'macos-latest'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}
path: src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
if-no-files-found: error
- name: Upload Artifacts
if: matrix.os == 'windows-latest'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}
path: src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*.exe
if-no-files-found: error

View File

@@ -256,3 +256,14 @@ jobs:
run: pnpm updater-fixed-webview2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
submit-to-winget:
runs-on: windows-latest
needs: [release-update]
steps:
- name: Submit to Winget
uses: vedantmgoyal9/winget-releaser@main
with:
identifier: ClashVergeRev.ClashVergeRev
installers-regex: '_(arm64|x64|x86)-setup\.exe$'
token: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@@ -7,4 +7,5 @@ dist-ssr
update.json
scripts/_env.sh
.vscode
.tool-versions
.tool-versions
.idea

View File

@@ -1,3 +1,28 @@
## v1.7.4
### Features
- 展示局域网 IP 地址信息
- 在设置页面直接复制环境变量
- 优化服务模式安装逻辑
### Performance
- 优化切换订阅速度
- 优化更改端口速度
### Bugs Fixes
- 调整 MacOS 托盘图标大小
- Trojan URI 解析错误
- 卡片拖动显示层级错误
- 代理绕过格式检查错误
- MacOS 下编辑器最大化失败
- MacOS 服务安装失败
- 更改窗口大小导致闪退的问题
---
## v1.7.3
### Features

View File

@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "1.7.3",
"version": "1.7.4",
"license": "GPL-3.0-only",
"scripts": {
"dev": "tauri dev",

16
src-tauri/Cargo.lock generated
View File

@@ -790,7 +790,7 @@ dependencies = [
[[package]]
name = "clash-verge"
version = "1.7.3"
version = "1.7.4"
dependencies = [
"anyhow",
"auto-launch",
@@ -803,6 +803,7 @@ dependencies = [
"log 0.4.22",
"log4rs",
"nanoid",
"network-interface",
"once_cell",
"open 5.2.0",
"parking_lot",
@@ -3243,6 +3244,19 @@ dependencies = [
"jni-sys",
]
[[package]]
name = "network-interface"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "433419f898328beca4f2c6c73a1b52540658d92b0a99f0269330457e0fd998d5"
dependencies = [
"cc",
"libc",
"serde",
"thiserror",
"winapi",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"

View File

@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "1.7.3"
version = "1.7.4"
description = "clash verge"
authors = ["zzzgydi", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -38,6 +38,7 @@ serde = { version = "1.0", features = ["derive"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
sysproxy = { git="https://github.com/zzzgydi/sysproxy-rs", branch = "main" }
tauri = { version="1", features = [ "fs-read-file", "fs-exists", "path-all", "protocol-asset", "dialog-open", "notification-all", "icon-png", "icon-ico", "clipboard-all", "global-shortcut-all", "process-all", "shell-all", "system-tray", "updater", "window-all", "devtools"] }
network-interface = { version = "2.0.0", features = ["serde"] }
[target.'cfg(windows)'.dependencies]
runas = "=1.2.0"
deelevate = "0.2.0"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -6,12 +6,19 @@ use crate::{
};
use crate::{ret_err, wrap_err};
use anyhow::{Context, Result};
use network_interface::NetworkInterface;
use serde_yaml::Mapping;
use std::collections::{HashMap, VecDeque};
use sysproxy::{Autoproxy, Sysproxy};
use tauri::{api, Manager};
type CmdResult<T = ()> = Result<T, String>;
#[tauri::command]
pub fn copy_clash_env(app_handle: tauri::AppHandle) -> CmdResult {
feat::copy_clash_env(&app_handle);
Ok(())
}
#[tauri::command]
pub fn get_profiles() -> CmdResult<IProfiles> {
Ok(Config::profiles().data().clone())
@@ -322,6 +329,36 @@ pub fn copy_icon_file(path: String, name: String) -> CmdResult<String> {
}
}
#[tauri::command]
pub fn get_network_interfaces() -> Vec<String> {
use sysinfo::Networks;
let mut result = Vec::new();
let networks = Networks::new_with_refreshed_list();
for (interface_name, _) in &networks {
result.push(interface_name.clone());
}
return result;
}
#[tauri::command]
pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
use network_interface::NetworkInterface;
use network_interface::NetworkInterfaceConfig;
let names = get_network_interfaces();
let interfaces = wrap_err!(NetworkInterface::show())?;
let mut result = Vec::new();
for interface in interfaces {
if names.contains(&interface.name) {
result.push(interface);
}
}
Ok(result)
}
#[tauri::command]
pub fn open_devtools(app_handle: tauri::AppHandle) {
if let Some(window) = app_handle.get_window("main") {

View File

@@ -7,7 +7,7 @@ use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use serde_yaml::Mapping;
use std::{sync::Arc, time::Duration};
use sysinfo::System;
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
use tauri::api::process::{Command, CommandChild, CommandEvent};
use tokio::time::sleep;
@@ -44,13 +44,27 @@ impl CoreManager {
let config_path = dirs::path_to_str(&config_path)?;
let clash_core = { Config::verge().latest().clash_core.clone() };
let clash_core = clash_core.unwrap_or("clash".into());
let mut clash_core = clash_core.unwrap_or("verge-mihomo".into());
let app_dir = dirs::app_home_dir()?;
let app_dir = dirs::path_to_str(&app_dir)?;
// compatibility
if clash_core.contains("clash") {
clash_core = "verge-mihomo".to_string();
Config::verge().draft().patch_config(IVerge {
clash_core: Some("verge-mihomo".to_string()),
..IVerge::default()
});
Config::verge().apply();
match Config::verge().data().save_file() {
Ok(_) => handle::Handle::refresh_verge(),
Err(err) => log::error!(target: "app", "{err}"),
}
}
let test_dir = dirs::app_home_dir()?.join("test");
let test_dir = dirs::path_to_str(&test_dir)?;
let output = Command::new_sidecar(clash_core)?
.args(["-t", "-d", app_dir, "-f", config_path])
.args(["-t", "-d", test_dir, "-f", config_path])
.output()?;
if !output.status.success() {
@@ -70,12 +84,6 @@ impl CoreManager {
pub async fn run_core(&self) -> Result<()> {
let config_path = Config::generate_file(ConfigType::Run)?;
#[allow(unused_mut)]
let mut should_kill = match self.sidecar.lock().take() {
Some(_) => true,
None => false,
};
// 关闭tun模式
let mut disable = Mapping::new();
let mut tun = Mapping::new();
@@ -84,23 +92,19 @@ impl CoreManager {
log::debug!(target: "app", "disable tun mode");
let _ = clash_api::patch_configs(&disable).await;
let mut system = System::new();
system.refresh_all();
let procs = system.processes_by_name("verge-mihomo");
for proc in procs {
log::debug!(target: "app", "kill all clash process");
proc.kill();
}
if *self.use_service_mode.lock() {
log::debug!(target: "app", "stop the core by service");
log_err!(service::stop_core_by_service().await);
should_kill = true;
}
} else {
let system = System::new_with_specifics(
RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
);
let procs = system.processes_by_name("verge-mihomo");
// 这里得等一会儿
if should_kill {
sleep(Duration::from_millis(500)).await;
for proc in procs {
log::debug!(target: "app", "kill all clash process");
proc.kill();
}
}
// 服务模式
@@ -237,8 +241,9 @@ impl CoreManager {
let mut sidecar = self.sidecar.lock();
let _ = sidecar.take();
let mut system = System::new();
system.refresh_all();
let system = System::new_with_specifics(
RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
);
let procs = system.processes_by_name("verge-mihomo");
for proc in procs {
log::debug!(target: "app", "kill all clash process");
@@ -287,7 +292,6 @@ impl CoreManager {
/// 如果涉及端口和外部控制则需要重启
pub async fn update_config(&self) -> Result<()> {
log::debug!(target: "app", "try to update clash config");
// 更新订阅
Config::generate().await?;
@@ -299,20 +303,19 @@ impl CoreManager {
let path = dirs::path_to_str(&path)?;
// 发送请求 发送5次
for i in 0..5 {
for i in 0..10 {
match clash_api::put_configs(path).await {
Ok(_) => break,
Err(err) => {
if i < 4 {
if i < 9 {
log::info!(target: "app", "{err}");
} else {
bail!(err);
}
}
}
sleep(Duration::from_millis(250)).await;
sleep(Duration::from_millis(100)).await;
}
Ok(())
}
}

View File

@@ -103,6 +103,12 @@ pub async fn install_service() -> Result<()> {
if !installer_path.exists() {
bail!("installer not found");
}
let _ = StdCommand::new("chmod")
.arg("+x")
.arg(installer_path.to_string_lossy().replace(" ", "\\ "))
.output();
let shell = installer_path.to_string_lossy().replace(" ", "\\\\ ");
let command = format!(r#"do shell script "{shell}" with administrator privileges"#);

View File

@@ -107,48 +107,13 @@ pub async fn patch_clash(patch: Mapping) -> Result<()> {
Config::clash().draft().patch_config(patch.clone());
let res = {
let redir_port = patch.get("redir-port");
let tproxy_port = patch.get("tproxy-port");
let mixed_port = patch.get("mixed-port");
let socks_port = patch.get("socks-port");
let port = patch.get("port");
let enable_random_port = Config::verge().latest().enable_random_port.unwrap_or(false);
if mixed_port.is_some() && !enable_random_port {
let changed = mixed_port.unwrap()
!= Config::verge()
.latest()
.verge_mixed_port
.unwrap_or(Config::clash().data().get_mixed_port());
// 检查端口占用
if changed {
if let Some(port) = mixed_port.unwrap().as_u64() {
if !port_scanner::local_port_available(port as u16) {
Config::clash().discard();
bail!("port already in use");
}
}
}
};
// 激活订阅
if redir_port.is_some()
|| tproxy_port.is_some()
|| mixed_port.is_some()
|| socks_port.is_some()
|| port.is_some()
|| patch.get("secret").is_some()
|| patch.get("external-controller").is_some()
{
if patch.get("secret").is_some() || patch.get("external-controller").is_some() {
Config::generate().await?;
CoreManager::global().run_core().await?;
handle::Handle::refresh_clash();
}
// 更新系统代理
if mixed_port.is_some() {
log_err!(sysopt::Sysopt::global().init_sysproxy());
}
if patch.get("mode").is_some() {
log_err!(handle::Handle::update_systray_part());
}
@@ -174,7 +139,6 @@ pub async fn patch_clash(patch: Mapping) -> Result<()> {
/// 一般都是一个个的修改
pub async fn patch_verge(patch: IVerge) -> Result<()> {
Config::verge().draft().patch_config(patch.clone());
let tun_mode = patch.enable_tun_mode;
let auto_launch = patch.enable_auto_launch;
let system_proxy = patch.enable_system_proxy;
@@ -182,7 +146,7 @@ pub async fn patch_verge(patch: IVerge) -> Result<()> {
let pac_content = patch.pac_file_content;
let proxy_bypass = patch.system_proxy_bypass;
let language = patch.language;
let port = patch.verge_mixed_port;
let mixed_port = patch.verge_mixed_port;
#[cfg(target_os = "macos")]
let tray_icon = patch.tray_icon;
let common_tray_icon = patch.common_tray_icon;
@@ -190,41 +154,62 @@ pub async fn patch_verge(patch: IVerge) -> Result<()> {
let tun_tray_icon = patch.tun_tray_icon;
#[cfg(not(target_os = "windows"))]
let redir_enabled = patch.verge_redir_enabled;
#[cfg(not(target_os = "windows"))]
let redir_port = patch.verge_redir_port;
#[cfg(target_os = "linux")]
let tproxy_enabled = patch.verge_tproxy_enabled;
#[cfg(target_os = "linux")]
let tproxy_port = patch.verge_tproxy_port;
let socks_enabled = patch.verge_socks_enabled;
let socks_port = patch.verge_socks_port;
let http_enabled = patch.verge_http_enabled;
let http_port = patch.verge_port;
let res = {
let service_mode = patch.enable_service_mode;
let mut generated = false;
if service_mode.is_some() {
log::debug!(target: "app", "change service mode to {}", service_mode.unwrap());
Config::generate().await?;
CoreManager::global().run_core().await?;
if !generated {
Config::generate().await?;
CoreManager::global().run_core().await?;
generated = true;
}
} else if tun_mode.is_some() {
update_core_config().await?;
}
#[cfg(not(target_os = "windows"))]
if redir_enabled.is_some() {
Config::generate().await?;
CoreManager::global().run_core().await?;
if redir_enabled.is_some() || redir_port.is_some() {
if !generated {
Config::generate().await?;
CoreManager::global().run_core().await?;
generated = true;
}
}
#[cfg(target_os = "linux")]
if tproxy_enabled.is_some() {
Config::generate().await?;
CoreManager::global().run_core().await?;
if tproxy_enabled.is_some() || tproxy_port.is_some() {
if !generated {
Config::generate().await?;
CoreManager::global().run_core().await?;
generated = true;
}
}
if socks_enabled.is_some() || http_enabled.is_some() {
Config::generate().await?;
CoreManager::global().run_core().await?;
if socks_enabled.is_some()
|| http_enabled.is_some()
|| socks_port.is_some()
|| http_port.is_some()
|| mixed_port.is_some()
{
if !generated {
Config::generate().await?;
CoreManager::global().run_core().await?;
}
}
if auto_launch.is_some() {
sysopt::Sysopt::global().update_launch()?;
}
if system_proxy.is_some()
|| proxy_bypass.is_some()
|| port.is_some()
|| mixed_port.is_some()
|| pac.is_some()
|| pac_content.is_some()
{

View File

@@ -51,6 +51,7 @@ fn main() -> std::io::Result<()> {
cmds::open_web_url,
cmds::open_core_dir,
cmds::get_portable_flag,
cmds::get_network_interfaces,
// cmds::kill_sidecar,
cmds::restart_sidecar,
// clash
@@ -63,6 +64,7 @@ fn main() -> std::io::Result<()> {
cmds::get_runtime_exists,
cmds::get_runtime_logs,
cmds::uwp::invoke_uwp_tool,
cmds::copy_clash_env,
// verge
cmds::get_verge_config,
cmds::patch_verge_config,
@@ -72,6 +74,7 @@ fn main() -> std::io::Result<()> {
cmds::download_icon_cache,
cmds::open_devtools,
cmds::exit_app,
cmds::get_network_interfaces_info,
// cmds::update_hotkeys,
// profile
cmds::get_profiles,

View File

@@ -2,7 +2,7 @@
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"package": {
"productName": "Clash Verge",
"version": "1.7.3"
"version": "1.7.4"
},
"build": {
"distDir": "../dist",

View File

@@ -1,7 +1,11 @@
import { createRoot } from "react-dom/client";
import { ReactNode, useState, useEffect } from "react";
import { Box, IconButton, Slide, Snackbar, Typography } from "@mui/material";
import { Close, CheckCircleRounded, ErrorRounded } from "@mui/icons-material";
import {
CloseRounded,
CheckCircleRounded,
ErrorRounded,
} from "@mui/icons-material";
import { useVerge } from "@/hooks/use-verge";
import { appWindow } from "@tauri-apps/api/window";
interface InnerProps {
@@ -81,7 +85,7 @@ const NoticeInner = (props: InnerProps) => {
transitionDuration={200}
action={
<IconButton size="small" color="inherit" onClick={onBtnClose}>
<Close fontSize="inherit" />
<CloseRounded fontSize="inherit" />
</IconButton>
}
/>

View File

@@ -26,7 +26,7 @@ type SearchProps = {
export const BaseSearchBox = styled((props: SearchProps) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [matchCase, setMatchCase] = useState(props.matchCase ?? true);
const [matchCase, setMatchCase] = useState(props.matchCase ?? false);
const [matchWholeWord, setMatchWholeWord] = useState(
props.matchWholeWord ?? false
);
@@ -96,7 +96,7 @@ export const BaseSearchBox = styled((props: SearchProps) => {
return (
<Tooltip title={errorMessage} placement="bottom-start">
<TextField
autoComplete="off"
autoComplete="new-password"
inputRef={inputRef}
hiddenLabel
fullWidth

View File

@@ -4,7 +4,7 @@ export const BaseStyledSelect = styled((props: SelectProps<string>) => {
return (
<Select
size="small"
autoComplete="off"
autoComplete="new-password"
sx={{
width: 120,
height: 33.375,

View File

@@ -6,7 +6,7 @@ export const BaseStyledTextField = styled((props: TextFieldProps) => {
return (
<TextField
autoComplete="off"
autoComplete="new-password"
hiddenLabel
fullWidth
size="small"

View File

@@ -11,6 +11,7 @@ export const Switch = styled((props: SwitchProps) => (
width: 42,
height: 26,
padding: 0,
marginRight: 1,
"& .MuiSwitch-switchBase": {
padding: 0,
margin: 2,

View File

@@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { Box, Typography } from "@mui/material";
import {
ArrowDownward,
ArrowUpward,
MemoryOutlined,
ArrowDownwardRounded,
ArrowUpwardRounded,
MemoryRounded,
} from "@mui/icons-material";
import { useClashInfo } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge";
@@ -153,7 +153,7 @@ export const LayoutTraffic = () => {
<Box display="flex" flexDirection="column" gap={0.75}>
<Box title={t("Upload Speed")} {...boxStyle}>
<ArrowUpward
<ArrowUpwardRounded
{...iconStyle}
color={+up > 0 ? "secondary" : "disabled"}
/>
@@ -164,7 +164,7 @@ export const LayoutTraffic = () => {
</Box>
<Box title={t("Download Speed")} {...boxStyle}>
<ArrowDownward
<ArrowDownwardRounded
{...iconStyle}
color={+down > 0 ? "primary" : "disabled"}
/>
@@ -184,7 +184,7 @@ export const LayoutTraffic = () => {
isDebug && (await gc());
}}
>
<MemoryOutlined {...iconStyle} />
<MemoryRounded {...iconStyle} />
<Typography {...valStyle}>{inuse}</Typography>
<Typography {...unitStyle}>{inuseUnit}</Typography>
</Box>

View File

@@ -10,14 +10,17 @@ import {
DialogTitle,
IconButton,
} from "@mui/material";
import FormatPaintIcon from "@mui/icons-material/FormatPaint";
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
import CloseFullscreenIcon from "@mui/icons-material/CloseFullscreen";
import {
FormatPaintRounded,
OpenInFullRounded,
CloseFullscreenRounded,
} from "@mui/icons-material";
import { useThemeMode } from "@/services/states";
import { Notice } from "@/components/base";
import { nanoid } from "nanoid";
import { appWindow } from "@tauri-apps/api/window";
import getSystem from "@/utils/get-system";
import debounce from "@/utils/debounce";
import * as monaco from "monaco-editor";
import MonacoEditor from "react-monaco-editor";
@@ -144,12 +147,19 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
}
});
const editorResize = debounce(() => {
editorRef.current?.layout();
setTimeout(() => editorRef.current?.layout(), 500);
}, 100);
useEffect(() => {
const unlistenResized = appWindow.onResized(() => {
const onResized = debounce(() => {
editorResize();
appWindow.isMaximized().then((maximized) => {
setIsMaximized(() => maximized);
});
});
}, 100);
const unlistenResized = appWindow.onResized(onResized);
return () => {
unlistenResized.then((fn) => fn());
@@ -162,7 +172,13 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
<Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogContent sx={{ width: "auto", height: "calc(100vh - 185px)" }}>
<DialogContent
sx={{
width: "auto",
height: "calc(100vh - 185px)",
overflow: "hidden",
}}
>
<MonacoEditor
language={language}
theme={themeMode === "light" ? "vs" : "vs-dark"}
@@ -209,17 +225,15 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
?.run()
}
>
<FormatPaintIcon fontSize="inherit" />
<FormatPaintRounded fontSize="inherit" />
</IconButton>
<IconButton
size="medium"
color="inherit"
title={t(isMaximized ? "Minimize" : "Maximize")}
onClick={() =>
appWindow.toggleMaximize().then(() => editorRef.current?.layout())
}
onClick={() => appWindow.toggleMaximize().then(editorResize)}
>
{isMaximized ? <CloseFullscreenIcon /> : <OpenInFullIcon />}
{isMaximized ? <CloseFullscreenRounded /> : <OpenInFullRounded />}
</IconButton>
</ButtonGroup>
</DialogContent>

View File

@@ -22,7 +22,14 @@ export const GroupItem = (props: Props) => {
let { type, group, onDelete } = props;
const sortable = type === "prepend" || type === "append";
const { attributes, listeners, setNodeRef, transform, transition } = sortable
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = sortable
? useSortable({ id: group.name })
: {
attributes: {},
@@ -30,6 +37,7 @@ export const GroupItem = (props: Props) => {
setNodeRef: null,
transform: null,
transition: null,
isDragging: false,
};
const [iconCachePath, setIconCachePath] = useState("");
@@ -55,6 +63,7 @@ export const GroupItem = (props: Props) => {
<ListItem
dense
sx={({ palette }) => ({
position: "relative",
background:
type === "original"
? palette.mode === "dark"
@@ -68,6 +77,7 @@ export const GroupItem = (props: Props) => {
borderRadius: "8px",
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? "calc(infinity)" : undefined,
})}
>
{group.icon && group.icon?.trim().startsWith("http") && (

View File

@@ -1,4 +1,4 @@
import { ReactNode, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useLockFn } from "ahooks";
import yaml from "js-yaml";
import { useTranslation } from "react-i18next";
@@ -23,14 +23,23 @@ import {
DialogActions,
DialogContent,
DialogTitle,
InputAdornment,
List,
ListItem,
ListItemText,
TextField,
styled,
} from "@mui/material";
import {
VerticalAlignTopRounded,
VerticalAlignBottomRounded,
} from "@mui/icons-material";
import { GroupItem } from "@/components/profile/group-item";
import { readProfileFile, saveProfileFile } from "@/services/cmds";
import {
getNetworkInterfaces,
readProfileFile,
saveProfileFile,
} from "@/services/cmds";
import { Notice, Switch } from "@/components/base";
import getSystem from "@/utils/get-system";
import { BaseSearchBox } from "../base/base-search-box";
@@ -60,7 +69,7 @@ export const GroupsEditorViewer = (props: Props) => {
const [currData, setCurrData] = useState("");
const [visualization, setVisualization] = useState(true);
const [match, setMatch] = useState(() => (_: string) => true);
const [interfaceNameList, setInterfaceNameList] = useState<string[]>([]);
const { control, watch, register, ...formIns } = useForm<IProxyGroupConfig>({
defaultValues: {
type: "select",
@@ -251,6 +260,10 @@ export const GroupsEditorViewer = (props: Props) => {
setProxyProviderList(Object.keys(provider));
setGroupList(originGroupsObj?.["proxy-groups"] || []);
};
const getInterfaceNameList = async () => {
let list = await getNetworkInterfaces();
setInterfaceNameList(list);
};
useEffect(() => {
fetchProxyPolicy();
}, [prependSeq, appendSeq, deleteSeq]);
@@ -259,12 +272,13 @@ export const GroupsEditorViewer = (props: Props) => {
fetchContent();
fetchProxyPolicy();
fetchProfile();
getInterfaceNameList();
}, [open]);
const validateGroup = () => {
let group = formIns.getValues();
if (group.name === "") {
throw new Error(t("Group Name Cannot Be Empty"));
throw new Error(t("Group Name Required"));
}
};
@@ -333,6 +347,11 @@ export const GroupsEditorViewer = (props: Props) => {
"relay",
]}
value={field.value}
renderOption={(props, option) => (
<li {...props} title={t(option)}>
{option}
</li>
)}
onChange={(_, value) => value && field.onChange(value)}
renderInput={(params) => <TextField {...params} />}
/>
@@ -346,7 +365,7 @@ export const GroupsEditorViewer = (props: Props) => {
<Item>
<ListItemText primary={t("Group Name")} />
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
sx={{ width: "calc(100% - 150px)" }}
{...field}
@@ -361,9 +380,9 @@ export const GroupsEditorViewer = (props: Props) => {
control={control}
render={({ field }) => (
<Item>
<ListItemText primary={t("Icon")} />
<ListItemText primary={t("Proxy Group Icon")} />
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
sx={{ width: "calc(100% - 150px)" }}
{...field}
@@ -387,6 +406,11 @@ export const GroupsEditorViewer = (props: Props) => {
disableCloseOnSelect
onChange={(_, value) => value && field.onChange(value)}
renderInput={(params) => <TextField {...params} />}
renderOption={(props, option) => (
<li {...props} title={t(option)}>
{option}
</li>
)}
/>
</Item>
)}
@@ -409,7 +433,6 @@ export const GroupsEditorViewer = (props: Props) => {
</Item>
)}
/>
<Controller
name="url"
control={control}
@@ -417,7 +440,8 @@ export const GroupsEditorViewer = (props: Props) => {
<Item>
<ListItemText primary={t("Health Check Url")} />
<TextField
autoComplete="off"
autoComplete="new-password"
placeholder="https://www.gstatic.com/generate_204"
size="small"
sx={{ width: "calc(100% - 150px)" }}
{...field}
@@ -425,6 +449,24 @@ export const GroupsEditorViewer = (props: Props) => {
</Item>
)}
/>
<Controller
name="expected-status"
control={control}
render={({ field }) => (
<Item>
<ListItemText primary={t("Expected Status")} />
<TextField
autoComplete="new-password"
placeholder="*"
size="small"
sx={{ width: "calc(100% - 150px)" }}
onChange={(e) => {
field.onChange(parseInt(e.target.value));
}}
/>
</Item>
)}
/>
<Controller
name="interval"
control={control}
@@ -432,13 +474,21 @@ export const GroupsEditorViewer = (props: Props) => {
<Item>
<ListItemText primary={t("Interval")} />
<TextField
autoComplete="off"
autoComplete="new-password"
placeholder="300"
type="number"
size="small"
sx={{ width: "calc(100% - 150px)" }}
onChange={(e) => {
field.onChange(parseInt(e.target.value));
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
{t("seconds")}
</InputAdornment>
),
}}
/>
</Item>
)}
@@ -450,13 +500,21 @@ export const GroupsEditorViewer = (props: Props) => {
<Item>
<ListItemText primary={t("Timeout")} />
<TextField
autoComplete="off"
autoComplete="new-password"
placeholder="5000"
type="number"
size="small"
sx={{ width: "calc(100% - 150px)" }}
onChange={(e) => {
field.onChange(parseInt(e.target.value));
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
{t("millis")}
</InputAdornment>
),
}}
/>
</Item>
)}
@@ -468,7 +526,8 @@ export const GroupsEditorViewer = (props: Props) => {
<Item>
<ListItemText primary={t("Max Failed Times")} />
<TextField
autoComplete="off"
autoComplete="new-password"
placeholder="5"
type="number"
size="small"
sx={{ width: "calc(100% - 150px)" }}
@@ -485,11 +544,13 @@ export const GroupsEditorViewer = (props: Props) => {
render={({ field }) => (
<Item>
<ListItemText primary={t("Interface Name")} />
<TextField
autoComplete="off"
<Autocomplete
size="small"
sx={{ width: "calc(100% - 150px)" }}
{...field}
options={interfaceNameList}
value={field.value}
onChange={(_, value) => value && field.onChange(value)}
renderInput={(params) => <TextField {...params} />}
/>
</Item>
)}
@@ -501,7 +562,7 @@ export const GroupsEditorViewer = (props: Props) => {
<Item>
<ListItemText primary={t("Routing Mark")} />
<TextField
autoComplete="off"
autoComplete="new-password"
type="number"
size="small"
sx={{ width: "calc(100% - 150px)" }}
@@ -519,7 +580,7 @@ export const GroupsEditorViewer = (props: Props) => {
<Item>
<ListItemText primary={t("Filter")} />
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
sx={{ width: "calc(100% - 150px)" }}
{...field}
@@ -534,7 +595,7 @@ export const GroupsEditorViewer = (props: Props) => {
<Item>
<ListItemText primary={t("Exclude Filter")} />
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
sx={{ width: "calc(100% - 150px)" }}
{...field}
@@ -588,23 +649,6 @@ export const GroupsEditorViewer = (props: Props) => {
</Item>
)}
/>
<Controller
name="expected-status"
control={control}
render={({ field }) => (
<Item>
<ListItemText primary={t("Expected Status")} />
<TextField
autoComplete="off"
size="small"
sx={{ width: "calc(100% - 150px)" }}
onChange={(e) => {
field.onChange(parseInt(e.target.value));
}}
/>
</Item>
)}
/>
<Controller
name="include-all"
control={control}
@@ -670,10 +714,11 @@ export const GroupsEditorViewer = (props: Props) => {
<Button
fullWidth
variant="contained"
startIcon={<VerticalAlignTopRounded />}
onClick={() => {
try {
validateGroup();
for (const item of prependSeq) {
for (const item of [...prependSeq, ...groupList]) {
if (item.name === formIns.getValues().name) {
throw new Error(t("Group Name Already Exists"));
}
@@ -691,10 +736,11 @@ export const GroupsEditorViewer = (props: Props) => {
<Button
fullWidth
variant="contained"
startIcon={<VerticalAlignBottomRounded />}
onClick={() => {
try {
validateGroup();
for (const item of appendSeq) {
for (const item of [...appendSeq, ...groupList]) {
if (item.name === formIns.getValues().name) {
throw new Error(t("Group Name Already Exists"));
}
@@ -716,10 +762,7 @@ export const GroupsEditorViewer = (props: Props) => {
padding: "0 10px",
}}
>
<BaseSearchBox
matchCase={false}
onSearch={(match) => setMatch(() => match)}
/>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<Virtuoso
style={{ height: "calc(100% - 24px)", marginTop: "8px" }}
totalCount={

View File

@@ -15,7 +15,7 @@ import {
Menu,
CircularProgress,
} from "@mui/material";
import { RefreshRounded, DragIndicator } from "@mui/icons-material";
import { RefreshRounded, DragIndicatorRounded } from "@mui/icons-material";
import { useLoadingCache, useSetLoadingCache } from "@/services/states";
import {
viewProfile,
@@ -51,8 +51,14 @@ interface Props {
export const ProfileItem = (props: Props) => {
const { selected, activating, itemData, onSelect, onEdit, onSave, onDelete } =
props;
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: props.id });
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: props.id });
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null);
@@ -73,7 +79,10 @@ export const ProfileItem = (props: Props) => {
const from = parseUrl(itemData.url);
const description = itemData.desc;
const expire = parseExpire(extra?.expire);
const progress = Math.round(((download + upload) * 100) / (total + 0.01) + 1);
const progress = Math.min(
Math.round(((download + upload) * 100) / (total + 0.01)) + 1,
100
);
const loading = loadingCache[itemData.uid] ?? false;
@@ -297,8 +306,10 @@ export const ProfileItem = (props: Props) => {
return (
<Box
sx={{
position: "relative",
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? "calc(infinity)" : undefined,
}}
>
<ProfileBox
@@ -337,7 +348,7 @@ export const ProfileItem = (props: Props) => {
{...attributes}
{...listeners}
>
<DragIndicator
<DragIndicatorRounded
sx={[
{ cursor: "move", marginLeft: "-6px" },
({ palette: { text } }) => {
@@ -472,64 +483,78 @@ export const ProfileItem = (props: Props) => {
</MenuItem>
))}
</Menu>
{fileOpen && (
<EditorViewer
open={true}
initialData={readProfileFile(uid)}
language="yaml"
schema="clash"
onSave={async (prev, curr) => {
await saveProfileFile(uid, curr ?? "");
onSave && onSave(prev, curr);
}}
onClose={() => setFileOpen(false)}
/>
)}
{rulesOpen && (
<RulesEditorViewer
groupsUid={option?.groups ?? ""}
mergeUid={option?.merge ?? ""}
profileUid={uid}
property={option?.rules ?? ""}
open={true}
onSave={onSave}
onClose={() => setRulesOpen(false)}
/>
)}
{proxiesOpen && (
<ProxiesEditorViewer
profileUid={uid}
property={option?.proxies ?? ""}
open={true}
onSave={onSave}
onClose={() => setProxiesOpen(false)}
/>
)}
{groupsOpen && (
<GroupsEditorViewer
mergeUid={option?.merge ?? ""}
proxiesUid={option?.proxies ?? ""}
profileUid={uid}
property={option?.groups ?? ""}
open={true}
onSave={onSave}
onClose={() => {
setGroupsOpen(false);
}}
/>
)}
{mergeOpen && (
<EditorViewer
open={true}
initialData={readProfileFile(option?.merge ?? "")}
language="yaml"
schema="clash"
onSave={async (prev, curr) => {
await saveProfileFile(option?.merge ?? "", curr ?? "");
onSave && onSave(prev, curr);
}}
onClose={() => setMergeOpen(false)}
/>
)}
{scriptOpen && (
<EditorViewer
open={true}
initialData={readProfileFile(option?.script ?? "")}
language="javascript"
onSave={async (prev, curr) => {
await saveProfileFile(option?.script ?? "", curr ?? "");
onSave && onSave(prev, curr);
}}
onClose={() => setScriptOpen(false)}
/>
)}
<EditorViewer
open={fileOpen}
initialData={readProfileFile(uid)}
language="yaml"
schema="clash"
onSave={async (prev, curr) => {
await saveProfileFile(uid, curr ?? "");
onSave && onSave(prev, curr);
}}
onClose={() => setFileOpen(false)}
/>
<RulesEditorViewer
groupsUid={option?.groups ?? ""}
mergeUid={option?.merge ?? ""}
profileUid={uid}
property={option?.rules ?? ""}
open={rulesOpen}
onSave={onSave}
onClose={() => setRulesOpen(false)}
/>
<ProxiesEditorViewer
profileUid={uid}
property={option?.proxies ?? ""}
open={proxiesOpen}
onSave={onSave}
onClose={() => setProxiesOpen(false)}
/>
<GroupsEditorViewer
mergeUid={option?.merge ?? ""}
proxiesUid={option?.proxies ?? ""}
profileUid={uid}
property={option?.groups ?? ""}
open={groupsOpen}
onSave={onSave}
onClose={() => setGroupsOpen(false)}
/>
<EditorViewer
open={mergeOpen}
initialData={readProfileFile(option?.merge ?? "")}
language="yaml"
schema="clash"
onSave={async (prev, curr) => {
await saveProfileFile(option?.merge ?? "", curr ?? "");
onSave && onSave(prev, curr);
}}
onClose={() => setMergeOpen(false)}
/>
<EditorViewer
open={scriptOpen}
initialData={readProfileFile(option?.script ?? "")}
language="javascript"
onSave={async (prev, curr) => {
await saveProfileFile(option?.script ?? "", curr ?? "");
onSave && onSave(prev, curr);
}}
onClose={() => setScriptOpen(false)}
/>
<ConfirmViewer
title={t("Confirm deletion")}
message={t("This operation is not reversible")}

View File

@@ -167,25 +167,27 @@ export const ProfileMore = (props: Props) => {
</MenuItem>
))}
</Menu>
<EditorViewer
open={fileOpen}
title={`${t("Global " + id)}`}
initialData={readProfileFile(id)}
language={id === "Merge" ? "yaml" : "javascript"}
schema={id === "Merge" ? "clash" : undefined}
onSave={async (prev, curr) => {
await saveProfileFile(id, curr ?? "");
onSave && onSave(prev, curr);
}}
onClose={() => setFileOpen(false)}
/>
<LogViewer
open={logOpen}
logInfo={logInfo}
onClose={() => setLogOpen(false)}
/>
{fileOpen && (
<EditorViewer
open={true}
title={`${t("Global " + id)}`}
initialData={readProfileFile(id)}
language={id === "Merge" ? "yaml" : "javascript"}
schema={id === "Merge" ? "clash" : undefined}
onSave={async (prev, curr) => {
await saveProfileFile(id, curr ?? "");
onSave && onSave(prev, curr);
}}
onClose={() => setFileOpen(false)}
/>
)}
{logOpen && (
<LogViewer
open={logOpen}
logInfo={logInfo}
onClose={() => setLogOpen(false)}
/>
)}
</>
);
};

View File

@@ -24,10 +24,13 @@ import {
DialogTitle,
List,
ListItem,
ListItemText,
TextField,
styled,
} from "@mui/material";
import {
VerticalAlignTopRounded,
VerticalAlignBottomRounded,
} from "@mui/icons-material";
import { ProxyItem } from "@/components/profile/proxy-item";
import { readProfileFile, saveProfileFile } from "@/services/cmds";
import { Notice } from "@/components/base";
@@ -256,7 +259,7 @@ export const ProxiesEditorViewer = (props: Props) => {
>
<Item>
<TextField
autoComplete="off"
autoComplete="new-password"
placeholder={t("Use newlines for multiple uri")}
fullWidth
rows={9}
@@ -270,6 +273,7 @@ export const ProxiesEditorViewer = (props: Props) => {
<Button
fullWidth
variant="contained"
startIcon={<VerticalAlignTopRounded />}
onClick={() => {
let proxies = handleParse();
setPrependSeq([...prependSeq, ...proxies]);
@@ -282,6 +286,7 @@ export const ProxiesEditorViewer = (props: Props) => {
<Button
fullWidth
variant="contained"
startIcon={<VerticalAlignBottomRounded />}
onClick={() => {
let proxies = handleParse();
setAppendSeq([...appendSeq, ...proxies]);
@@ -298,10 +303,7 @@ export const ProxiesEditorViewer = (props: Props) => {
padding: "0 10px",
}}
>
<BaseSearchBox
matchCase={false}
onSearch={(match) => setMatch(() => match)}
/>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<Virtuoso
style={{ height: "calc(100% - 24px)", marginTop: "8px" }}
totalCount={

View File

@@ -20,7 +20,14 @@ export const ProxyItem = (props: Props) => {
let { type, proxy, onDelete } = props;
const sortable = type === "prepend" || type === "append";
const { attributes, listeners, setNodeRef, transform, transition } = sortable
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = sortable
? useSortable({ id: proxy.name })
: {
attributes: {},
@@ -28,12 +35,14 @@ export const ProxyItem = (props: Props) => {
setNodeRef: null,
transform: null,
transition: null,
isDragging: false,
};
return (
<ListItem
dense
sx={({ palette }) => ({
position: "relative",
background:
type === "original"
? palette.mode === "dark"
@@ -47,6 +56,7 @@ export const ProxyItem = (props: Props) => {
borderRadius: "8px",
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? "calc(infinity)" : undefined,
})}
>
<ListItemText
@@ -56,6 +66,7 @@ export const ProxyItem = (props: Props) => {
sx={{ cursor: sortable ? "move" : "" }}
primary={
<StyledPrimary
title={proxy.name}
sx={{ textDecoration: type === "delete" ? "line-through" : "" }}
>
{proxy.name}

View File

@@ -24,7 +24,14 @@ export const RuleItem = (props: Props) => {
const proxyPolicy = rule.match(/[^,]+$/)?.[0] ?? "";
const ruleContent = rule.slice(ruleType.length + 1, -proxyPolicy.length - 1);
const { attributes, listeners, setNodeRef, transform, transition } = sortable
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = sortable
? useSortable({ id: ruleRaw })
: {
attributes: {},
@@ -32,11 +39,13 @@ export const RuleItem = (props: Props) => {
setNodeRef: null,
transform: null,
transition: null,
isDragging: false,
};
return (
<ListItem
dense
sx={({ palette }) => ({
position: "relative",
background:
type === "original"
? palette.mode === "dark"
@@ -50,6 +59,7 @@ export const RuleItem = (props: Props) => {
borderRadius: "8px",
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? "calc(infinity)" : undefined,
})}
>
<ListItemText
@@ -59,6 +69,7 @@ export const RuleItem = (props: Props) => {
sx={{ cursor: sortable ? "move" : "" }}
primary={
<StyledPrimary
title={ruleContent || "-"}
sx={{ textDecoration: type === "delete" ? "line-through" : "" }}
>
{ruleContent || "-"}

View File

@@ -29,7 +29,10 @@ import {
TextField,
styled,
} from "@mui/material";
import {
VerticalAlignTopRounded,
VerticalAlignBottomRounded,
} from "@mui/icons-material";
import { readProfileFile, saveProfileFile } from "@/services/cmds";
import { Notice, Switch } from "@/components/base";
import getSystem from "@/utils/get-system";
@@ -495,7 +498,7 @@ export const RulesEditorViewer = (props: Props) => {
{ruleType.name !== "RULE-SET" &&
ruleType.name !== "SUB-RULE" && (
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
sx={{ minWidth: "240px" }}
value={ruleContent}
@@ -535,6 +538,7 @@ export const RulesEditorViewer = (props: Props) => {
<Button
fullWidth
variant="contained"
startIcon={<VerticalAlignTopRounded />}
onClick={() => {
try {
let raw = validateRule();
@@ -552,6 +556,7 @@ export const RulesEditorViewer = (props: Props) => {
<Button
fullWidth
variant="contained"
startIcon={<VerticalAlignBottomRounded />}
onClick={() => {
try {
let raw = validateRule();
@@ -573,10 +578,7 @@ export const RulesEditorViewer = (props: Props) => {
padding: "0 10px",
}}
>
<BaseSearchBox
matchCase={false}
onSearch={(match) => setMatch(() => match)}
/>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<Virtuoso
style={{ height: "calc(100% - 24px)", marginTop: "8px" }}
totalCount={

View File

@@ -105,8 +105,9 @@ export const ProviderButton = () => {
const download = sub?.Download || 0;
const total = sub?.Total || 0;
const expire = sub?.Expire || 0;
const progress = Math.round(
((download + upload) * 100) / (total + 0.1)
const progress = Math.min(
Math.round(((download + upload) * 100) / (total + 0.01)) + 1,
100
);
return (
<>
@@ -159,6 +160,7 @@ export const ProviderButton = () => {
<LinearProgress
variant="determinate"
value={progress}
style={{ opacity: total > 0 ? 1 : 0 }}
/>
</>
)}

View File

@@ -132,7 +132,7 @@ export const ProxyHead = (props: Props) => {
{textState === "filter" && (
<TextField
autoComplete="off"
autoComplete="new-password"
autoFocus={autoFocus}
hiddenLabel
value={filterText}
@@ -146,7 +146,7 @@ export const ProxyHead = (props: Props) => {
{textState === "url" && (
<TextField
autoComplete="off"
autoComplete="new-password"
autoFocus={autoFocus}
hiddenLabel
autoSave="off"

View File

@@ -5,7 +5,10 @@ import { useTranslation } from "react-i18next";
import { useVerge } from "@/hooks/use-verge";
import { useLockFn } from "ahooks";
import { LoadingButton } from "@mui/lab";
import { SwitchAccessShortcut, RestartAlt } from "@mui/icons-material";
import {
SwitchAccessShortcutRounded,
RestartAltRounded,
} from "@mui/icons-material";
import {
Box,
Button,
@@ -85,7 +88,7 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
<LoadingButton
variant="contained"
size="small"
startIcon={<SwitchAccessShortcut />}
startIcon={<SwitchAccessShortcutRounded />}
loadingPosition="start"
loading={upgrading}
sx={{ marginRight: "8px" }}
@@ -97,7 +100,7 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
variant="contained"
size="small"
onClick={onRestart}
startIcon={<RestartAlt />}
startIcon={<RestartAltRounded />}
>
{t("Restart")}
</Button>

View File

@@ -13,7 +13,6 @@ export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
const { clashInfo, patchInfo } = useClashInfo();
const { verge, patchVerge } = useVerge();
const [open, setOpen] = useState(false);
const [redirPort, setRedirPort] = useState(
verge?.verge_redir_port ?? clashInfo?.redir_port ?? 7895
@@ -94,24 +93,57 @@ export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
return;
}
try {
if (OS !== "windows") {
await patchInfo({ "redir-port": redirPort });
await patchVerge({ verge_redir_port: redirPort });
await patchVerge({ verge_redir_enabled: redirEnabled });
if (OS === "windows") {
await patchInfo({
"mixed-port": mixedPort,
"socks-port": socksPort,
port,
});
await patchVerge({
verge_mixed_port: mixedPort,
verge_socks_port: socksPort,
verge_socks_enabled: socksEnabled,
verge_port: port,
verge_http_enabled: httpEnabled,
});
}
if (OS === "macos") {
await patchInfo({
"redir-port": redirPort,
"mixed-port": mixedPort,
"socks-port": socksPort,
port,
});
await patchVerge({
verge_redir_port: redirPort,
verge_redir_enabled: redirEnabled,
verge_mixed_port: mixedPort,
verge_socks_port: socksPort,
verge_socks_enabled: socksEnabled,
verge_port: port,
verge_http_enabled: httpEnabled,
});
}
if (OS === "linux") {
await patchInfo({ "tproxy-port": tproxyPort });
await patchVerge({ verge_tproxy_port: tproxyPort });
await patchVerge({ verge_tproxy_enabled: tproxyEnabled });
await patchInfo({
"redir-port": redirPort,
"tproxy-port": tproxyPort,
"mixed-port": mixedPort,
"socks-port": socksPort,
port,
});
await patchVerge({
verge_redir_port: redirPort,
verge_redir_enabled: redirEnabled,
verge_tproxy_port: tproxyPort,
verge_tproxy_enabled: tproxyEnabled,
verge_mixed_port: mixedPort,
verge_socks_port: socksPort,
verge_socks_enabled: socksEnabled,
verge_port: port,
verge_http_enabled: httpEnabled,
});
}
await patchInfo({ "mixed-port": mixedPort });
await patchInfo({ "socks-port": socksPort });
await patchInfo({ port });
await patchVerge({ verge_mixed_port: mixedPort });
await patchVerge({ verge_socks_port: socksPort });
await patchVerge({ verge_port: port });
await patchVerge({ verge_socks_enabled: socksEnabled });
await patchVerge({ verge_http_enabled: httpEnabled });
setOpen(false);
Notice.success(t("Clash Port Modified"), 1000);
} catch (err: any) {
@@ -134,7 +166,7 @@ export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Mixed Port")} />
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
sx={{ width: 135 }}
value={mixedPort}
@@ -146,7 +178,7 @@ export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Socks Port")} />
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
sx={{ width: 135 }}
value={socksPort}
@@ -169,7 +201,7 @@ export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Http Port")} />
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
sx={{ width: 135 }}
value={port}
@@ -193,7 +225,7 @@ export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Redir Port")} />
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
sx={{ width: 135 }}
value={redirPort}
@@ -218,7 +250,7 @@ export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Tproxy Port")} />
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
sx={{ width: 135 }}
value={tproxyPort}

View File

@@ -20,11 +20,12 @@ export const ConfigViewer = forwardRef<DialogRef>((_, ref) => {
close: () => setOpen(false),
}));
if (!open) return null;
return (
<EditorViewer
open={open}
open={true}
title={
<Box>
<Box display="flex" alignItems="center" gap={2}>
{t("Runtime Config")}
<Chip label={t("ReadOnly")} size="small" />
</Box>

View File

@@ -48,7 +48,7 @@ export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("External Controller")} />
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
sx={{ width: 175 }}
value={controller}
@@ -60,7 +60,7 @@ export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Core Secret")} />
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
sx={{ width: 175 }}
value={secret}

View File

@@ -198,7 +198,7 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
/>
<TooltipIcon title={t("Default Latency Test Info")} />
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
autoCorrect="off"
autoCapitalize="off"
@@ -215,7 +215,7 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Default Latency Timeout")} />
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
type="number"
autoCorrect="off"

View File

@@ -0,0 +1,141 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { BaseDialog, DialogRef, Notice } from "@/components/base";
import { getNetworkInterfacesInfo } from "@/services/cmds";
import { alpha, Box, Button, Chip, IconButton } from "@mui/material";
import { ContentCopyRounded } from "@mui/icons-material";
import { writeText } from "@tauri-apps/api/clipboard";
export const NetworkInterfaceViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [networkInterfaces, setNetworkInterfaces] = useState<
INetworkInterface[]
>([]);
const [isV4, setIsV4] = useState(true);
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
},
close: () => setOpen(false),
}));
useEffect(() => {
if (!open) return;
getNetworkInterfacesInfo().then((res) => {
console.log(res);
setNetworkInterfaces(res);
});
}, [open]);
return (
<BaseDialog
open={open}
title={
<Box display="flex" justifyContent="space-between">
{t("Network Interface")}
<Box>
<Button
variant="contained"
size="small"
onClick={() => {
setIsV4((prev) => !prev);
}}
>
{isV4 ? "Ipv6" : "Ipv4"}
</Button>
</Box>
</Box>
}
contentSx={{ width: 450, maxHeight: 330 }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
{networkInterfaces.map((item) => (
<Box key={item.name}>
<h4>{item.name}</h4>
<Box>
{isV4 && (
<>
{item.addr.map(
(address) =>
address.V4 && (
<AddressDisplay
key={address.V4.ip}
label={t("Ip Address")}
content={address.V4.ip}
/>
)
)}
<AddressDisplay
label={t("Mac Address")}
content={item.mac_addr ?? ""}
/>
</>
)}
{!isV4 && (
<>
{item.addr.map(
(address) =>
address.V6 && (
<AddressDisplay
key={address.V6.ip}
label={t("Ip Address")}
content={address.V6.ip}
/>
)
)}
<AddressDisplay
label={t("Mac Address")}
content={item.mac_addr ?? ""}
/>
</>
)}
</Box>
</Box>
))}
</BaseDialog>
);
});
const AddressDisplay = (props: { label: string; content: string }) => {
const { t } = useTranslation();
return (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
margin: "8px 0",
}}
>
<Box>{props.label}</Box>
<Box
sx={({ palette }) => ({
borderRadius: "8px",
padding: "2px",
background:
palette.mode === "dark"
? alpha(palette.background.paper, 0.3)
: alpha(palette.grey[400], 0.3),
})}
>
<Box sx={{ display: "inline", userSelect: "text" }}>
{props.content}
</Box>
<IconButton
size="small"
onClick={async () => {
await writeText(props.content);
Notice.success(t("Copy Success"));
}}
>
<ContentCopyRounded sx={{ fontSize: "18px" }} />
</IconButton>
</Box>
</Box>
);
};

View File

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

View File

@@ -1,129 +0,0 @@
import useSWR from "swr";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { Button, Stack, Typography } from "@mui/material";
import {
checkService,
installService,
uninstallService,
patchVergeConfig,
} from "@/services/cmds";
import { BaseDialog, DialogRef, Notice } from "@/components/base";
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();
await mutateCheck();
setOpen(false);
setTimeout(() => {
mutateCheck();
}, 2000);
Notice.success(t("Service Installed Successfully"));
} catch (err: any) {
mutateCheck();
Notice.error(err.message || err.toString());
}
});
const onUninstall = useLockFn(async () => {
try {
if (enable) {
await patchVergeConfig({ enable_service_mode: false });
}
await uninstallService();
mutateCheck();
setOpen(false);
Notice.success(t("Service Uninstalled Successfully"));
} catch (err: any) {
mutateCheck();
Notice.error(err.message || err.toString());
}
});
// 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();
Notice.error(err.message || err.toString());
}
});
return (
<BaseDialog
open={open}
title={t("Service Mode")}
contentSx={{ width: 360, userSelect: "text" }}
disableFooter
onClose={() => setOpen(false)}
>
<Typography>
{t("Current State")}: {t(state)}
</Typography>
{(state === "unknown" || state === "uninstall") && (
<Typography>
{t(
"Information: Please make sure that the Clash Verge 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}>
{t("Disable Service Mode")}
</Button>
)}
{state === "uninstall" && (
<Button variant="contained" onClick={onInstall}>
{t("Install")}
</Button>
)}
{(state === "active" || state === "installed") && (
<Button variant="outlined" onClick={onUninstall}>
{t("Uninstall")}
</Button>
)}
</Stack>
</BaseDialog>
);
});

View File

@@ -1,4 +1,4 @@
import { forwardRef, useImperativeHandle, useState } from "react";
import { forwardRef, useImperativeHandle, useMemo, useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import {
@@ -14,7 +14,7 @@ import {
import { useVerge } from "@/hooks/use-verge";
import { getSystemProxy, getAutotemProxy } from "@/services/cmds";
import { BaseDialog, DialogRef, Notice, Switch } from "@/components/base";
import { Edit } from "@mui/icons-material";
import { EditRounded } from "@mui/icons-material";
import { EditorViewer } from "@/components/profile/editor-viewer";
import { BaseFieldset } from "@/components/base/base-fieldset";
import getSystem from "@/utils/get-system";
@@ -23,16 +23,38 @@ const DEFAULT_PAC = `function FindProxyForURL(url, host) {
return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;";
}`;
/** NO_PROXY validation */
// *., cdn*., *, etc.
const domain_subdomain_part = String.raw`(?:[a-z0-9\-\*]+\.|\*)*`;
// .*, .cn, .moe, .co*, *
const domain_tld_part = String.raw`(?:\w{2,64}\*?|\*)`;
// *epicgames*, *skk.moe, *.skk.moe, skk.*, sponsor.cdn.skk.moe, *.*, etc.
// also matches 192.168.*, 10.*, 127.0.0.*, etc. (partial ipv4)
const rDomainSimple = domain_subdomain_part + domain_tld_part;
const ipv4_part = String.raw`\d{1,3}`;
// 127.0.0.1 (full ipv4)
const rIPv4 = String.raw`(?:${ipv4_part}\.){3}${ipv4_part}`;
// const rIPv4Partial = String.raw`${ipv4_part}\.(?:(?:${ipv4_part}|\*)\.){0,2}\.\*`;
const ipv6_part = "(?:[a-fA-F0-9:])+";
const rIPv6 = `(?:${ipv6_part}:+)+${ipv6_part}`;
const rLocal = `localhost|<local>|localdomain`;
const rValidPart = `${rDomainSimple}|${rIPv4}|${rIPv6}|${rLocal}`;
const getValidReg = (isWindows: boolean) => {
const separator = isWindows ? ";" : ",";
const rValid = String.raw`^(${rValidPart})(?:${separator}\s?(${rValidPart}))*${separator}?$`;
return new RegExp(rValid);
};
export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
let validReg;
if (getSystem() === "windows") {
validReg =
/^((\*\.)?([a-zA-Z0-9-]+\.?)+(local|test|example|invalid|localhost|onion|([a-zA-Z]{2,}))|(\d{1,3}\.){1,3}\d{1,3}|\d{1,3}\.\d{1,3}\.\d{1,3}\.\*|\d{1,3}\.\d{1,3}\.\*|\d{1,3}\.\*|([a-fA-F0-9:]+:+)+[a-fA-F0-9]+|localhost|<local>)(;((\*\.)?([a-zA-Z0-9-]+\.?)+(local|test|example|invalid|localhost|onion|([a-zA-Z]{2,}))|(\d{1,3}\.){1,3}\d{1,3}|\d{1,3}\.\d{1,3}\.\d{1,3}\.\*|\d{1,3}\.\d{1,3}\.\*|\d{1,3}\.\*|([a-fA-F0-9:]+:+)+[a-fA-F0-9]+|localhost|<local>))*;?$/;
} else {
validReg =
/^((\*\.)?([a-zA-Z0-9-]+\.?)+(local|test|example|invalid|localhost|onion|([a-zA-Z]{2,}))|(\d{1,3}\.){1,3}\d{1,3}(\/\d{1,2}|\/3[0-2])?|\d{1,3}\.\d{1,3}\.\d{1,3}\.\*(\/\d{1,2}|\/3[0-2])?|\d{1,3}\.\d{1,3}\.\*(\/\d{1,2}|\/3[0-2])?|\d{1,3}\.\*(\/\d{1,2}|\/3[0-2])?|([a-fA-F0-9:]+:+)+[a-fA-F0-9]+(\/\d{1,3})?|localhost|<local>)(,((\*\.)?([a-zA-Z0-9-]+\.?)+(local|test|example|invalid|localhost|onion|([a-zA-Z]{2,}))|(\d{1,3}\.){1,3}\d{1,3}(\/\d{1,2}|\/3[0-2])?|\d{1,3}\.\d{1,3}\.\d{1,3}\.\*(\/\d{1,2}|\/3[0-2])?|\d{1,3}\.\d{1,3}\.\*(\/\d{1,2}|\/3[0-2])?|\d{1,3}\.\*(\/3[0-2])?|([a-fA-F0-9:]+:+)+[a-fA-F0-9]+(\/\d{1,3})?|localhost|<local>))*,?$/;
}
const isWindows = getSystem() === "windows";
const validReg = useMemo(() => getValidReg(isWindows), [isWindows]);
const [open, setOpen] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
@@ -188,7 +210,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Guard Duration")} />
<TextField
autoComplete="off"
autoComplete="new-password"
disabled={!enabled}
size="small"
value={value.duration}
@@ -219,7 +241,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
<>
<ListItemText primary={t("Proxy Bypass")} />
<TextField
autoComplete="off"
autoComplete="new-password"
error={value.bypass ? !validReg.test(value.bypass) : false}
disabled={!enabled}
size="small"
@@ -234,7 +256,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
<ListItemText primary={t("Bypass")} />
<FlexBox>
<TextField
autoComplete="off"
autoComplete="new-password"
disabled={true}
size="small"
multiline
@@ -253,7 +275,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
sx={{ padding: "3px 0" }}
/>
<Button
startIcon={<Edit />}
startIcon={<EditRounded />}
variant="outlined"
onClick={() => {
setEditorOpen(true);
@@ -261,20 +283,22 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
>
{t("Edit")} PAC
</Button>
<EditorViewer
open={editorOpen}
title={`${t("Edit")} PAC`}
initialData={Promise.resolve(value.pac_content ?? "")}
language="javascript"
onSave={(_prev, curr) => {
let pac = DEFAULT_PAC;
if (curr && curr.trim().length > 0) {
pac = curr;
}
setValue((v) => ({ ...v, pac_content: pac }));
}}
onClose={() => setEditorOpen(false)}
/>
{editorOpen && (
<EditorViewer
open={true}
title={`${t("Edit")} PAC`}
initialData={Promise.resolve(value.pac_content ?? "")}
language="javascript"
onSave={(_prev, curr) => {
let pac = DEFAULT_PAC;
if (curr && curr.trim().length > 0) {
pac = curr;
}
setValue((v) => ({ ...v, pac_content: pac }));
}}
onClose={() => setEditorOpen(false)}
/>
)}
</ListItem>
</>
)}

View File

@@ -14,7 +14,7 @@ import { useVerge } from "@/hooks/use-verge";
import { defaultTheme, defaultDarkTheme } from "@/pages/_theme";
import { BaseDialog, DialogRef, Notice } from "@/components/base";
import { EditorViewer } from "@/components/profile/editor-viewer";
import { Edit } from "@mui/icons-material";
import { EditRounded } from "@mui/icons-material";
export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
@@ -115,7 +115,7 @@ export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
<Item>
<ListItemText primary={t("CSS Injection")} />
<Button
startIcon={<Edit />}
startIcon={<EditRounded />}
variant="outlined"
onClick={() => {
setEditorOpen(true);
@@ -123,19 +123,21 @@ export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
>
{t("Edit")} CSS
</Button>
<EditorViewer
open={editorOpen}
title={`${t("Edit")} CSS`}
initialData={Promise.resolve(theme.css_injection ?? "")}
language="css"
onSave={(_prev, curr) => {
theme.css_injection = curr;
handleChange("css_injection");
}}
onClose={() => {
setEditorOpen(false);
}}
/>
{editorOpen && (
<EditorViewer
open={true}
title={`${t("Edit")} CSS`}
initialData={Promise.resolve(theme.css_injection ?? "")}
language="css"
onSave={(_prev, curr) => {
theme.css_injection = curr;
handleChange("css_injection");
}}
onClose={() => {
setEditorOpen(false);
}}
/>
)}
</Item>
</List>
</BaseDialog>

View File

@@ -144,7 +144,7 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Device")} />
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
autoCorrect="off"
autoCapitalize="off"
@@ -190,7 +190,7 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("DNS Hijack")} />
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
autoCorrect="off"
autoCapitalize="off"
@@ -207,7 +207,7 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("MTU")} />
<TextField
autoComplete="off"
autoComplete="new-password"
size="small"
type="number"
autoCorrect="off"

View File

@@ -43,7 +43,7 @@ export const WebUIItem = (props: Props) => {
<>
<Stack spacing={0.75} direction="row" mt={1} mb={1} alignItems="center">
<TextField
autoComplete="off"
autoComplete="new-password"
fullWidth
size="small"
value={editValue}

View File

@@ -2,7 +2,11 @@ import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { TextField, Select, MenuItem, Typography } from "@mui/material";
import { Settings, Shuffle } from "@mui/icons-material";
import {
SettingsRounded,
ShuffleRounded,
LanRounded,
} from "@mui/icons-material";
import { DialogRef, Notice, Switch } from "@/components/base";
import { useClash } from "@/hooks/use-clash";
import { GuardState } from "./mods/guard-state";
@@ -16,6 +20,7 @@ import getSystem from "@/utils/get-system";
import { useVerge } from "@/hooks/use-verge";
import { updateGeoData } from "@/services/api";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { NetworkInterfaceViewer } from "./mods/network-interface-viewer";
const isWIN = getSystem() === "windows";
@@ -37,6 +42,7 @@ const SettingClash = ({ onError }: Props) => {
const portRef = useRef<DialogRef>(null);
const ctrlRef = useRef<DialogRef>(null);
const coreRef = useRef<DialogRef>(null);
const networkRef = useRef<DialogRef>(null);
const onSwitchFormat = (_e: any, value: boolean) => value;
const onChangeData = (patch: Partial<IConfigData>) => {
@@ -60,8 +66,21 @@ const SettingClash = ({ onError }: Props) => {
<ClashPortViewer ref={portRef} />
<ControllerViewer ref={ctrlRef} />
<ClashCoreViewer ref={coreRef} />
<NetworkInterfaceViewer ref={networkRef} />
<SettingItem label={t("Allow Lan")}>
<SettingItem
label={t("Allow Lan")}
extra={
<TooltipIcon
title={t("Network Interface")}
color={"inherit"}
icon={LanRounded}
onClick={() => {
networkRef.current?.open();
}}
/>
}
>
<GuardState
value={allowLan ?? false}
valueProps="checked"
@@ -112,7 +131,7 @@ const SettingClash = ({ onError }: Props) => {
<TooltipIcon
title={t("Random Port")}
color={enable_random_port ? "primary" : "inherit"}
icon={Shuffle}
icon={ShuffleRounded}
onClick={() => {
Notice.success(
t("Restart Application to Apply Modifications"),
@@ -125,7 +144,7 @@ const SettingClash = ({ onError }: Props) => {
}
>
<TextField
autoComplete="off"
autoComplete="new-password"
disabled={enable_random_port}
size="small"
value={verge_mixed_port ?? 7897}
@@ -148,7 +167,7 @@ const SettingClash = ({ onError }: Props) => {
label={t("Clash Core")}
extra={
<TooltipIcon
icon={Settings}
icon={SettingsRounded}
onClick={() => coreRef.current?.open()}
/>
}

View File

@@ -1,13 +1,13 @@
import useSWR from "swr";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { PrivacyTipRounded, Settings } from "@mui/icons-material";
import { SettingsRounded } from "@mui/icons-material";
import { checkService } from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge";
import { DialogRef, Switch } from "@/components/base";
import { DialogRef, Notice, Switch } from "@/components/base";
import { SettingList, SettingItem } from "./mods/setting-comp";
import { GuardState } from "./mods/guard-state";
import { ServiceViewer } from "./mods/service-viewer";
import { ServiceSwitcher } from "./mods/service-switcher";
import { SysproxyViewer } from "./mods/sysproxy-viewer";
import { TunViewer } from "./mods/tun-viewer";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
@@ -20,22 +20,23 @@ const SettingSystem = ({ onError }: Props) => {
const { t } = useTranslation();
const { verge, mutateVerge, patchVerge } = useVerge();
// service mode
const { data: serviceStatus } = useSWR("checkService", checkService, {
revalidateIfStale: false,
shouldRetryOnError: false,
focusThrottleInterval: 36e5, // 1 hour
});
const { data: serviceStatus, mutate: mutateServiceStatus } = useSWR(
"checkService",
checkService,
{
revalidateIfStale: false,
shouldRetryOnError: false,
focusThrottleInterval: 36e5, // 1 hour
}
);
const serviceRef = useRef<DialogRef>(null);
const sysproxyRef = useRef<DialogRef>(null);
const tunRef = useRef<DialogRef>(null);
const {
enable_tun_mode,
enable_auto_launch,
enable_service_mode,
enable_silent_start,
enable_system_proxy,
} = verge ?? {};
@@ -49,14 +50,13 @@ const SettingSystem = ({ onError }: Props) => {
<SettingList title={t("System Setting")}>
<SysproxyViewer ref={sysproxyRef} />
<TunViewer ref={tunRef} />
<ServiceViewer ref={serviceRef} enable={!!enable_service_mode} />
<SettingItem
label={t("Tun Mode")}
extra={
<TooltipIcon
title={t("Tun Mode Info")}
icon={Settings}
icon={SettingsRounded}
onClick={() => tunRef.current?.open()}
/>
}
@@ -66,38 +66,33 @@ const SettingSystem = ({ onError }: Props) => {
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_tun_mode: e })}
onGuard={(e) => patchVerge({ enable_tun_mode: e })}
onChange={(e) => {
if (serviceStatus !== "active") {
onChangeData({ enable_tun_mode: false });
} else {
onChangeData({ enable_tun_mode: e });
}
}}
onGuard={(e) => {
if (serviceStatus !== "active" && e) {
Notice.error(t("Please Enable Service Mode"));
return Promise.resolve();
} else {
return patchVerge({ enable_tun_mode: e });
}
}}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<SettingItem
label={t("Service Mode")}
extra={
<TooltipIcon
title={t("Service Mode Info")}
icon={PrivacyTipRounded}
onClick={() => serviceRef.current?.open()}
/>
}
>
<GuardState
value={enable_service_mode ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_service_mode: e })}
onGuard={(e) => patchVerge({ enable_service_mode: e })}
>
<Switch
edge="end"
disabled={
serviceStatus !== "active" && serviceStatus !== "installed"
}
/>
</GuardState>
<SettingItem label={t("Service Mode")}>
<ServiceSwitcher
status={serviceStatus ?? "unknown"}
mutate={mutateServiceStatus}
patchVerge={patchVerge}
onChangeData={onChangeData}
/>
</SettingItem>
<SettingItem
@@ -106,7 +101,7 @@ const SettingSystem = ({ onError }: Props) => {
<>
<TooltipIcon
title={t("System Proxy Info")}
icon={Settings}
icon={SettingsRounded}
onClick={() => sysproxyRef.current?.open()}
/>
</>

View File

@@ -1,13 +1,21 @@
import { useRef } from "react";
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { open } from "@tauri-apps/api/dialog";
import { Button, MenuItem, Select, Input, Typography } from "@mui/material";
import {
Button,
MenuItem,
Select,
Input,
Typography,
Box,
} from "@mui/material";
import {
exitApp,
openAppDir,
openCoreDir,
openLogsDir,
openDevTools,
copyClashEnv,
} from "@/services/cmds";
import { checkUpdate } from "@tauri-apps/api/updater";
import { useVerge } from "@/hooks/use-verge";
@@ -24,6 +32,8 @@ import { LayoutViewer } from "./mods/layout-viewer";
import { UpdateViewer } from "./mods/update-viewer";
import getSystem from "@/utils/get-system";
import { routers } from "@/pages/_routers";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { ContentCopyRounded } from "@mui/icons-material";
interface Props {
onError?: (err: Error) => void;
@@ -67,6 +77,11 @@ const SettingVerge = ({ onError }: Props) => {
}
};
const onCopyClashEnv = useCallback(async () => {
await copyClashEnv();
Notice.success(t("Copy Success"), 1000);
}, []);
return (
<SettingList title={t("Verge Setting")}>
<ThemeViewer ref={themeRef} />
@@ -123,7 +138,12 @@ const SettingVerge = ({ onError }: Props) => {
</SettingItem>
)}
<SettingItem label={t("Copy Env Type")}>
<SettingItem
label={t("Copy Env Type")}
extra={
<TooltipIcon icon={ContentCopyRounded} onClick={onCopyClashEnv} />
}
>
<GuardState
value={env_type ?? (OS === "windows" ? "powershell" : "bash")}
onCatch={onError}

View File

@@ -13,7 +13,7 @@ import {
alpha,
} from "@mui/material";
import { BaseLoading } from "@/components/base";
import { LanguageTwoTone } from "@mui/icons-material";
import { LanguageRounded } from "@mui/icons-material";
import { Notice } from "@/components/base";
import { TestBox } from "./test-box";
import delayManager from "@/services/delay";
@@ -32,8 +32,14 @@ let eventListener: UnlistenFn | null = null;
export const TestItem = (props: Props) => {
const { itemData, onEdit, onDelete: onDeleteItem } = props;
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: props.id });
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: props.id });
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null);
@@ -99,8 +105,10 @@ export const TestItem = (props: Props) => {
return (
<Box
sx={{
position: "relative",
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? "calc(infinity)" : undefined,
}}
>
<TestBox
@@ -138,7 +146,7 @@ export const TestItem = (props: Props) => {
</Box>
) : (
<Box sx={{ display: "flex", justifyContent: "center" }}>
<LanguageTwoTone sx={{ height: "40px" }} fontSize="large" />
<LanguageRounded sx={{ height: "40px" }} fontSize="large" />
</Box>
)}

View File

@@ -1,5 +1,6 @@
{
"millis": "millis",
"seconds": "seconds",
"mins": "mins",
"Back": "Back",
"Close": "Close",
@@ -54,7 +55,7 @@
"Create Profile": "Create Profile",
"Edit Profile": "Edit Profile",
"Edit Proxies": "Edit Proxies",
"Use newlines for multiple uri": "Use newlines for multiple uri",
"Use newlines for multiple uri": "Use newlines for multiple uri(Base64 encoding supported)",
"Edit Rules": "Edit Rules",
"Rule Type": "Rule Type",
"Rule Content": "Rule Content",
@@ -109,10 +110,16 @@
"PASS": "Skips this rule when matched",
"Edit Groups": "Edit Proxy Groups",
"Group Type": "Group Type",
"select": "Select proxy manually",
"url-test": "Select proxy based on URL test delay",
"fallback": "Switch to another proxy on error",
"load-balance": "Distribute proxy based on load balancing",
"relay": "Pass through the defined proxy chain",
"Group Name": "Group Name",
"Use Proxies": "Use Proxies",
"Use Provider": "Use Provider",
"Health Check Url": "Health Check Url",
"Expected Status": "Expected Status",
"Interval": "Interval",
"Lazy": "Lazy",
"Timeout": "Timeout",
@@ -124,9 +131,10 @@
"Include All Proxies": "Include All Proxies",
"Exclude Filter": "Exclude Filter",
"Exclude Type": "Exclude Type",
"Expected Status": "Expected Status",
"Disable UDP": "Disable UDP",
"Hidden": "Hidden",
"Group Name Required": "Group Name Required",
"Group Name Already Exists": "Group Name Already Exists",
"Extend Config": "Extend Config",
"Extend Script": "Extend Script",
"Global Merge": "Global Extend Config",
@@ -234,6 +242,9 @@
"Github Repo": "Github Repo",
"Clash Setting": "Clash Setting",
"Allow Lan": "Allow LAN",
"Network Interface": "Network Interface",
"Ip Address": "IP Address",
"Mac Address": "MAC Address",
"IPv6": "IPv6",
"Log Level": "Log Level",
"Port Config": "Port Config",
@@ -255,7 +266,7 @@
"Restart": "Restart",
"Release Version": "Release Version",
"Alpha Version": "Alpha Version",
"Tun mode requires": "Tun mode requires",
"Please Enable Service Mode": "Please Install and Enable Service Mode First",
"Grant": "Grant",
"Open UWP tool": "Open UWP tool",
"Open UWP tool Info": "Since Windows 8, UWP apps (such as Microsoft Store) are restricted from directly accessing local host network services, and this tool can be used to bypass this restriction",
@@ -269,6 +280,7 @@
"Tray Click Event": "Tray Click Event",
"Show Main Window": "Show Main Window",
"Copy Env Type": "Copy Env Type",
"Copy Success": "Copy Success",
"Start Page": "Start Page",
"Startup Script": "Startup Script",
"Browse": "Browse",

View File

@@ -1,5 +1,6 @@
{
"millis": "میلی‌ثانیه",
"seconds": "ثانیه‌ها",
"mins": "دقیقه",
"Back": "بازگشت",
"Close": "بستن",
@@ -54,7 +55,7 @@
"Create Profile": "ایجاد پروفایل",
"Edit Profile": "ویرایش پروفایل",
"Edit Proxies": "ویرایش پروکسی‌ها",
"Use newlines for multiple uri": "استفاده از خطوط جدید برای چندین آدرس",
"Use newlines for multiple uri": "استفاده از خطوط جدید برای چندین آدرس (پشتیبانی از رمزگذاری Base64)",
"Edit Rules": "ویرایش قوانین",
"Rule Type": "نوع قانون",
"Rule Content": "محتوای قانون",
@@ -107,10 +108,16 @@
"PASS": "این قانون را در صورت تطابق نادیده می‌گیرد",
"Edit Groups": "ویرایش گروه‌های پروکسی",
"Group Type": "نوع گروه",
"select": "انتخاب پروکسی به صورت دستی",
"url-test": "انتخاب پروکسی بر اساس تأخیر آزمایش URL",
"fallback": "تعویض به پروکسی دیگر در صورت بروز خطا",
"load-balance": "توزیع پروکسی بر اساس توازن بار",
"relay": "عبور از زنجیره پروکسی تعریف شده",
"Group Name": "نام گروه",
"Use Proxies": "استفاده از پروکسی‌ها",
"Use Provider": "استفاده از ارائه‌دهنده",
"Health Check Url": "آدرس بررسی سلامت",
"Expected Status": "وضعیت مورد انتظار",
"Interval": "فاصله زمانی",
"Lazy": "تنبل",
"Timeout": "زمان قطع",
@@ -122,9 +129,10 @@
"Include All Proxies": "شامل همه پروکسی‌ها",
"Exclude Filter": "فیلتر استثناء",
"Exclude Type": "نوع استثناء",
"Expected Status": "وضعیت مورد انتظار",
"Disable UDP": "غیرفعال کردن UDP",
"Hidden": "مخفی",
"Group Name Required": "نام گروه مورد نیاز است",
"Group Name Already Exists": "نام گروه قبلا وجود دارد",
"Extend Config": "توسعه پیکربندی",
"Extend Script": "ادغام اسکریپت",
"Global Merge": "تنظیمات گسترده‌ی سراسری",
@@ -229,6 +237,9 @@
"Silent Start Info": "برنامه را در حالت پس‌زمینه بدون نمایش پانل اجرا کنید",
"Clash Setting": "تنظیمات Clash",
"Allow Lan": "اجازه LAN",
"Network Interface": "رابط شبکه",
"Ip Address": "آدرس IP",
"Mac Address": "آدرس MAC",
"IPv6": "IPv6",
"Log Level": "سطح لاگ",
"Port Config": "پیکربندی پورت",
@@ -250,7 +261,7 @@
"Restart": "راه‌اندازی مجدد",
"Release Version": "نسخه نهایی",
"Alpha Version": "نسخه آلفا",
"Tun mode requires": "Tun mode نیاز دارد",
"Please Install and Enable Service Mode First": "لطفاً ابتدا حالت سرویس را نصب و فعال کنید",
"Grant": "اعطا",
"Open UWP tool": "باز کردن ابزار UWP",
"Open UWP tool Info": "از ویندوز 8 به بعد، برنامه‌های UWP (مانند Microsoft Store) از دسترسی مستقیم به خدمات شبکه محلی محدود شده‌اند و این ابزار می‌تواند برای دور زدن این محدودیت استفاده شود",
@@ -267,6 +278,7 @@
"Tray Click Event": "رویداد کلیک در سینی سیستم",
"Show Main Window": "نمایش پنجره اصلی",
"Copy Env Type": "کپی نوع محیط",
"Copy Success": "کپی با موفقیت انجام شد",
"Start Page": "صفحه شروع",
"Startup Script": "اسکریپت راه‌اندازی",
"Browse": "مرور کردن",

View File

@@ -1,5 +1,6 @@
{
"millis": "миллисекунды",
"seconds": "секунды",
"mins": "минуты",
"Back": "Назад",
"Close": "Закрыть",
@@ -54,7 +55,7 @@
"Create Profile": "Создать профиль",
"Edit Profile": "Изменить профиль",
"Edit Proxies": "Редактировать прокси",
"Use newlines for multiple uri": "Используйте новые строки для нескольких URI",
"Use newlines for multiple uri": "Используйте символы новой строки для нескольких URI (поддерживается кодировка Base64)",
"Edit Rules": "Редактировать правила",
"Rule Type": "Тип правила",
"Rule Content": "Содержимое правила",
@@ -107,10 +108,16 @@
"PASS": "Пропускает это правило при совпадении",
"Edit Groups": "Редактировать группы прокси",
"Group Type": "Тип группы",
"select": "Выбор прокси вручную",
"url-test": "Выбор прокси на основе задержки теста URL",
"fallback": "Переключение на другой прокси при ошибке",
"load-balance": "Распределение прокси на основе балансировки нагрузки",
"relay": "Передача через определенную цепочку прокси",
"Group Name": "Имя группы",
"Use Proxies": "Использовать прокси",
"Use Provider": "Использовать провайдера",
"Health Check Url": "URL проверки здоровья",
"Expected Status": "Ожидаемый статус",
"Interval": "Интервал",
"Lazy": "Ленивый",
"Timeout": "Таймаут",
@@ -122,9 +129,10 @@
"Include All Proxies": "Включить все прокси",
"Exclude Filter": "Исключить фильтр",
"Exclude Type": "Тип исключения",
"Expected Status": "Ожидаемый статус",
"Disable UDP": "Отключить UDP",
"Hidden": "Скрытый",
"Group Name Required": "Требуется имя группы",
"Group Name Already Exists": "Имя группы уже существует",
"Extend Config": "Изменить Merge.",
"Extend Script": "Изменить Script",
"Global Merge": "Глобальный расширенный Настройки",
@@ -232,6 +240,9 @@
"Github Repo": "GitHub репозиторий",
"Clash Setting": "Настройки Clash",
"Allow Lan": "Разрешить локальную сеть",
"Network Interface": "Сетевой интерфейс",
"Ip Address": "IP адрес",
"Mac Address": "MAC адрес",
"IPv6": "IPv6",
"Log Level": "Уровень логов",
"Port Config": "Настройка порта",
@@ -253,7 +264,7 @@
"Restart": "Перезапуск",
"Release Version": "Официальная версия",
"Alpha Version": "Альфа-версия",
"Tun mode requires": "Требуется Режим туннеля",
"Please Enable Service Mode": "Пожалуйста, сначала установите и включите режим обслуживания",
"Grant": "Предоставить",
"Open UWP tool": "Открыть UWP инструмент",
"Open UWP tool Info": "С Windows 8 приложения UWP (такие как Microsoft Store) ограничены в прямом доступе к сетевым службам локального хоста, и этот инструмент позволяет обойти это ограничение",
@@ -267,6 +278,7 @@
"Tray Click Event": "Событие щелчка в лотке",
"Show Main Window": "Показать главное окно",
"Copy Env Type": "Скопировать тип Env",
"Copy Success": "Скопировано",
"Start Page": "Главная страница",
"Startup Script": "Скрипт запуска",
"Browse": "Просмотреть",

View File

@@ -1,6 +1,7 @@
{
"millis": "毫秒",
"mins": "分钟",
"seconds": "秒",
"Back": "返回",
"Close": "关闭",
"Cancel": "取消",
@@ -54,7 +55,7 @@
"Create Profile": "新建配置",
"Edit Profile": "编辑配置",
"Edit Proxies": "编辑节点",
"Use newlines for multiple uri": "多条URI请使用换行分隔",
"Use newlines for multiple uri": "多条URI请使用换行分隔支持Base64编码",
"Edit Rules": "编辑规则",
"Rule Type": "规则类型",
"Rule Content": "规则内容",
@@ -109,24 +110,31 @@
"PASS": "跳过此规则",
"Edit Groups": "编辑代理组",
"Group Type": "代理组类型",
"select": "手动选择代理",
"url-test": "根据URL测试延迟选择代理",
"fallback": "不可用时切换到另一个代理",
"load-balance": "根据负载均衡分配代理",
"relay": "根据定义的代理链传递",
"Group Name": "代理组组名",
"Use Proxies": "引入代理",
"Use Provider": "引入代理集合",
"Health Check Url": "健康检查测试地址",
"Expected Status": "期望状态码",
"Interval": "检查间隔",
"Lazy": "懒惰状态",
"Timeout": "超时时间",
"Max Failed Times": "最大失败次数",
"Interface Name": "出站接口",
"Routing Mark": "路由标记",
"Include All": "引入所有出站代理以及代理集合",
"Include All": "引入所有出站代理代理集合",
"Include All Providers": "引入所有代理集合",
"Include All Proxies": "引入所有出站代理",
"Exclude Filter": "排除节点",
"Exclude Type": "排除节点类型",
"Expected Status": "期望状态码",
"Disable UDP": "禁用UDP",
"Hidden": "隐藏组",
"Hidden": "隐藏代理组",
"Group Name Required": "代理组名称不能为空",
"Group Name Already Exists": "代理组名称已存在",
"Extend Config": "扩展配置",
"Extend Script": "扩展脚本",
"Global Merge": "全局扩展配置",
@@ -234,6 +242,9 @@
"Github Repo": "GitHub 项目地址",
"Clash Setting": "Clash 设置",
"Allow Lan": "局域网连接",
"Network Interface": "网络接口",
"Ip Address": "IP 地址",
"Mac Address": "MAC 地址",
"IPv6": "IPv6",
"Log Level": "日志等级",
"Port Config": "端口设置",
@@ -255,7 +266,7 @@
"Restart": "重启内核",
"Release Version": "正式版",
"Alpha Version": "预览版",
"Tun mode requires": "如需启用 Tun 模式需要授权",
"Please Enable Service Mode": "请先安装并启用服务模式",
"Grant": "授权",
"Open UWP tool": "UWP 工具",
"Open UWP tool Info": "Windows 8开始限制 UWP 应用(如微软商店)直接访问本地主机的网络服务,使用此工具可绕过该限制",
@@ -269,6 +280,7 @@
"Tray Click Event": "托盘点击事件",
"Show Main Window": "显示主窗口",
"Copy Env Type": "复制环境变量类型",
"Copy Success": "复制成功",
"Start Page": "启动页面",
"Startup Script": "启动脚本",
"Browse": "浏览",

View File

@@ -171,10 +171,11 @@ const ProfilePage = () => {
}, 100);
try {
await patchProfiles({ current });
mutateLogs();
await mutateLogs();
closeAllConnections();
setTimeout(() => activateSelected(), 2000);
Notice.success(t("Profile Switched"), 1000);
activateSelected().then(() => {
Notice.success(t("Profile Switched"), 1000);
});
} catch (err: any) {
Notice.error(err?.message || err.toString(), 4000);
} finally {

View File

@@ -2,7 +2,7 @@ import { Box, ButtonGroup, Grid, IconButton } from "@mui/material";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { BasePage, Notice } from "@/components/base";
import { GitHub, HelpOutlineSharp, Telegram } from "@mui/icons-material";
import { GitHub, HelpOutlineRounded, Telegram } from "@mui/icons-material";
import { openWebUrl } from "@/services/cmds";
import SettingVerge from "@/components/setting/setting-verge";
import SettingClash from "@/components/setting/setting-clash";
@@ -42,7 +42,7 @@ const SettingPage = () => {
title={t("Manual")}
onClick={toGithubDoc}
>
<HelpOutlineSharp fontSize="inherit" />
<HelpOutlineRounded fontSize="inherit" />
</IconButton>
<IconButton
size="medium"

View File

@@ -2,6 +2,10 @@ import dayjs from "dayjs";
import { invoke } from "@tauri-apps/api/tauri";
import { Notice } from "@/components/base";
export async function copyClashEnv() {
return invoke<void>("copy_clash_env");
}
export async function getClashLogs() {
const regex = /time="(.+?)"\s+level=(.+?)\s+msg="(.+?)"/;
const newRegex = /(.+?)\s+(.+?)\s+(.+)/;
@@ -233,3 +237,11 @@ export async function copyIconFile(
export async function downloadIconCache(url: string, name: string) {
return invoke<string>("download_icon_cache", { url, name });
}
export async function getNetworkInterfaces() {
return invoke<string[]>("get_network_interfaces");
}
export async function getNetworkInterfacesInfo() {
return invoke<INetworkInterface[]>("get_network_interfaces_info");
}

View File

@@ -197,6 +197,24 @@ interface IVergeTestItem {
icon?: string;
url: string;
}
interface IAddress {
V4?: {
ip: string;
broadcast?: string;
netmask?: string;
};
V6?: {
ip: string;
broadcast?: string;
netmask?: string;
};
}
interface INetworkInterface {
name: string;
addr: IAddress[];
mac_addr?: string;
index: number;
}
interface ISeqProfileConfig {
prepend: [];
@@ -260,8 +278,17 @@ interface RealityOptions {
"public-key"?: string;
"short-id"?: string;
}
type NetworkType = "ws" | "http" | "h2" | "grpc";
type ClientFingerprint =
| "chrome"
| "firefox"
| "safari"
| "iOS"
| "android"
| "edge"
| "360"
| "qq"
| "random";
type NetworkType = "ws" | "http" | "h2" | "grpc" | "tcp";
type CipherType =
| "none"
| "auto"
@@ -376,7 +403,7 @@ interface IProxyTrojanConfig extends IProxyBaseConfig {
method?: string;
password?: string;
};
"client-fingerprint"?: string;
"client-fingerprint"?: ClientFingerprint;
}
// tuic
interface IProxyTuicConfig extends IProxyBaseConfig {
@@ -438,7 +465,7 @@ interface IProxyVlessConfig extends IProxyBaseConfig {
"skip-cert-verify"?: boolean;
fingerprint?: string;
servername?: string;
"client-fingerprint"?: string;
"client-fingerprint"?: ClientFingerprint;
}
// vmess
interface IProxyVmessConfig extends IProxyBaseConfig {
@@ -466,7 +493,7 @@ interface IProxyVmessConfig extends IProxyBaseConfig {
"packet-encoding"?: string;
"global-padding"?: boolean;
"authenticated-length"?: boolean;
"client-fingerprint"?: string;
"client-fingerprint"?: ClientFingerprint;
}
interface WireGuardPeerOptions {
server?: string;
@@ -574,7 +601,7 @@ interface IProxyShadowsocksConfig extends IProxyBaseConfig {
};
"udp-over-tcp"?: boolean;
"udp-over-tcp-version"?: number;
"client-fingerprint"?: string;
"client-fingerprint"?: ClientFingerprint;
}
// shadowsocksR
interface IProxyshadowsocksRConfig extends IProxyBaseConfig {

12
src/utils/debounce.ts Normal file
View File

@@ -0,0 +1,12 @@
export default function debounce<T extends (...args: any[]) => void>(
func: T,
wait: number
): T {
let timeout: ReturnType<typeof setTimeout> | null = null;
return function (this: any, ...args: Parameters<T>) {
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(() => func.apply(this, args), wait);
} as T;
}

View File

@@ -1,131 +0,0 @@
// global initializer
{{
function $set(obj, path, value) {
if (Object(obj) !== obj) return obj;
if (!Array.isArray(path)) path = path.toString().match(/[^.[\]]+/g) || [];
path
.slice(0, -1)
.reduce((a, c, i) => (Object(a[c]) === a[c] ? a[c] : (a[c] = Math.abs(path[i + 1]) >> 0 === +path[i + 1] ? [] : {})), obj)[
path[path.length - 1]
] = value;
return obj;
}
function toBool(str) {
if (typeof str === 'undefined' || str === null) return undefined;
return /(TRUE)|1/i.test(str);
}
}}
{
const proxy = {};
const obfs = {};
const $ = {};
const params = {};
}
start = (trojan) {
return proxy
}
trojan = "trojan://" password:password "@" server:server ":" port:port "/"? params? name:name?{
proxy.type = "trojan";
proxy.password = password;
proxy.server = server;
proxy.port = port;
proxy.name = name;
// name may be empty
if (!proxy.name) {
proxy.name = server + ":" + port;
}
};
password = match:$[^@]+ {
return decodeURIComponent(match);
};
server = ip/domain;
domain = match:[0-9a-zA-z-_.]+ {
const domain = match.join("");
if (/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/.test(domain)) {
return domain;
}
}
ip = & {
const start = peg$currPos;
let end;
let j = start;
while (j < input.length) {
if (input[j] === ",") break;
if (input[j] === ":") end = j;
j++;
}
peg$currPos = end || j;
$.ip = input.substring(start, end).trim();
return true;
} { return $.ip; }
port = digits:[0-9]+ {
const port = parseInt(digits.join(""), 10);
if (port >= 0 && port <= 65535) {
return port;
} else {
throw new Error("Invalid port: " + port);
}
}
params = "?" head:param tail:("&"@param)* {
proxy["skip-cert-verify"] = toBool(params["allowInsecure"]);
proxy.sni = params["sni"] || params["peer"];
if (toBool(params["ws"])) {
proxy.network = "ws";
$set(proxy, "ws-opts.path", params["wspath"]);
}
if (params["type"]) {
let httpupgrade
proxy.network = params["type"]
if(proxy.network === 'httpupgrade') {
proxy.network = 'ws'
httpupgrade = true
}
if (['grpc'].includes(proxy.network)) {
proxy[proxy.network + '-opts'] = {
'grpc-service-name': params["serviceName"],
'_grpc-type': params["mode"],
};
} else {
if (params["path"]) {
$set(proxy, proxy.network+"-opts.path", decodeURIComponent(params["path"]));
}
if (params["host"]) {
$set(proxy, proxy.network+"-opts.headers.Host", decodeURIComponent(params["host"]));
}
if (httpupgrade) {
$set(proxy, proxy.network+"-opts.v2ray-http-upgrade", true);
$set(proxy, proxy.network+"-opts.v2ray-http-upgrade-fast-open", true);
}
}
}
proxy.udp = toBool(params["udp"]);
proxy.tfo = toBool(params["tfo"]);
}
param = kv/single;
kv = key:$[a-z]i+ "=" value:$[^&#]i* {
params[key] = value;
}
single = key:$[a-z]i+ {
params[key] = true;
};
name = "#" + match:$.* {
return decodeURIComponent(match);
}

View File

@@ -1,141 +0,0 @@
import * as peggy from "peggy";
const grammars = String.raw`
// global initializer
{{
function $set(obj, path, value) {
if (Object(obj) !== obj) return obj;
if (!Array.isArray(path)) path = path.toString().match(/[^.[\]]+/g) || [];
path
.slice(0, -1)
.reduce((a, c, i) => (Object(a[c]) === a[c] ? a[c] : (a[c] = Math.abs(path[i + 1]) >> 0 === +path[i + 1] ? [] : {})), obj)[
path[path.length - 1]
] = value;
return obj;
}
function toBool(str) {
if (typeof str === 'undefined' || str === null) return undefined;
return /(TRUE)|1/i.test(str);
}
}}
{
const proxy = {};
const obfs = {};
const $ = {};
const params = {};
}
start = (trojan) {
return proxy
}
trojan = "trojan://" password:password "@" server:server ":" port:port "/"? params? name:name?{
proxy.type = "trojan";
proxy.password = password;
proxy.server = server;
proxy.port = port;
proxy.name = name;
// name may be empty
if (!proxy.name) {
proxy.name = server + ":" + port;
}
};
password = match:$[^@]+ {
return decodeURIComponent(match);
};
server = ip/domain;
domain = match:[0-9a-zA-z-_.]+ {
const domain = match.join("");
if (/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/.test(domain)) {
return domain;
}
}
ip = & {
const start = peg$currPos;
let end;
let j = start;
while (j < input.length) {
if (input[j] === ",") break;
if (input[j] === ":") end = j;
j++;
}
peg$currPos = end || j;
$.ip = input.substring(start, end).trim();
return true;
} { return $.ip; }
port = digits:[0-9]+ {
const port = parseInt(digits.join(""), 10);
if (port >= 0 && port <= 65535) {
return port;
} else {
throw new Error("Invalid port: " + port);
}
}
params = "?" head:param tail:("&"@param)* {
proxy["skip-cert-verify"] = toBool(params["allowInsecure"]);
proxy.sni = params["sni"] || params["peer"];
if (toBool(params["ws"])) {
proxy.network = "ws";
$set(proxy, "ws-opts.path", params["wspath"]);
}
if (params["type"]) {
let httpupgrade
proxy.network = params["type"]
if(proxy.network === 'httpupgrade') {
proxy.network = 'ws'
httpupgrade = true
}
if (['grpc'].includes(proxy.network)) {
proxy[proxy.network + '-opts'] = {
'grpc-service-name': params["serviceName"],
'_grpc-type': params["mode"],
};
} else {
if (params["path"]) {
$set(proxy, proxy.network+"-opts.path", decodeURIComponent(params["path"]));
}
if (params["host"]) {
$set(proxy, proxy.network+"-opts.headers.Host", decodeURIComponent(params["host"]));
}
if (httpupgrade) {
$set(proxy, proxy.network+"-opts.v2ray-http-upgrade", true);
$set(proxy, proxy.network+"-opts.v2ray-http-upgrade-fast-open", true);
}
}
}
proxy.udp = toBool(params["udp"]);
proxy.tfo = toBool(params["tfo"]);
}
param = kv/single;
kv = key:$[a-z]i+ "=" value:$[^&#]i* {
params[key] = value;
}
single = key:$[a-z]i+ {
params[key] = true;
};
name = "#" + match:$.* {
return decodeURIComponent(match);
}
`;
let parser: any;
export default function getParser() {
if (!parser) {
parser = peggy.generate(grammars);
}
return parser;
}

View File

@@ -1,5 +1,3 @@
import getTrojanURIParser from "@/utils/trojan-uri";
export default function parseUri(uri: string): IProxyConfig {
const head = uri.split("://")[0];
switch (head) {
@@ -467,7 +465,19 @@ function URI_VMESS(line: string): IProxyVmessConfig {
opts["v2ray-http-upgrade"] = true;
opts["v2ray-http-upgrade-fast-open"] = true;
}
proxy[`${proxy.network}-opts`] = opts;
switch (proxy.network) {
case "ws":
proxy["ws-opts"] = opts;
break;
case "http":
proxy["http-opts"] = opts;
break;
case "h2":
proxy["h2-opts"] = opts;
break;
default:
break;
}
}
} else {
delete proxy.network;
@@ -530,16 +540,7 @@ function URI_VLESS(line: string): IProxyVlessConfig {
proxy.servername = params.sni || params.peer;
proxy.flow = params.flow ? "xtls-rprx-vision" : undefined;
proxy["client-fingerprint"] = params.fp as
| "chrome"
| "firefox"
| "safari"
| "iOS"
| "android"
| "edge"
| "360"
| "qq"
| "random";
proxy["client-fingerprint"] = params.fp as ClientFingerprint;
proxy.alpn = params.alpn ? params.alpn.split(",") : undefined;
proxy["skip-cert-verify"] = /(TRUE)|1/i.test(params.allowInsecure);
@@ -635,16 +636,89 @@ function URI_VLESS(line: string): IProxyVlessConfig {
}
function URI_Trojan(line: string): IProxyTrojanConfig {
let [newLine, name] = line.split(/#(.+)/, 2);
const parser = getTrojanURIParser();
const proxy: IProxyTrojanConfig = parser.parse(newLine);
if (isNotBlank(name)) {
try {
proxy.name = decodeURIComponent(name).trim();
} catch (e) {
throw Error("Can not get proxy name");
line = line.split("trojan://")[1];
let [__, password, server, ___, port, ____, addons = "", name] =
/^(.*?)@(.*?)(:(\d+))?\/?(\?(.*?))?(?:#(.*?))?$/.exec(line) || [];
let portNum = parseInt(`${port}`, 10);
if (isNaN(portNum)) {
portNum = 443;
}
password = decodeURIComponent(password);
let decodedName = trimStr(decodeURIComponent(name));
name = decodedName ?? `Trojan ${server}:${portNum}`;
const proxy: IProxyTrojanConfig = {
type: "trojan",
name,
server,
port: portNum,
password,
};
let host = "";
let path = "";
for (const addon of addons.split("&")) {
let [key, value] = addon.split("=");
value = decodeURIComponent(value);
switch (key) {
case "type":
if (["ws", "h2"].includes(value)) {
proxy.network = value as NetworkType;
} else {
proxy.network = "tcp";
}
break;
case "host":
host = value;
break;
case "path":
path = value;
break;
case "alpn":
proxy["alpn"] = value ? value.split(",") : undefined;
break;
case "sni":
proxy["sni"] = value;
break;
case "skip-cert-verify":
proxy["skip-cert-verify"] = /(TRUE)|1/i.test(value);
break;
case "fingerprint":
proxy["fingerprint"] = value;
break;
case "fp":
proxy["fingerprint"] = value;
break;
case "encryption":
let encryption = value.split(";");
if (encryption.length === 3) {
proxy["ss-opts"] = {
enabled: true,
method: encryption[1],
password: encryption[2],
};
}
case "client-fingerprint":
proxy["client-fingerprint"] = value as ClientFingerprint;
break;
default:
break;
}
}
if (proxy.network === "ws") {
proxy["ws-opts"] = {
headers: { Host: host },
path,
} as WsOptions;
} else if (proxy.network === "grpc") {
proxy["grpc-opts"] = {
"grpc-service-name": path,
} as GrpcOptions;
}
return proxy;
}