Compare commits

..

8 Commits

27 changed files with 856 additions and 75 deletions

View File

@@ -41,18 +41,18 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
Download from [release](https://github.com/clash-verge-rev/clash-verge-rev/releases). Supports Windows (x64/x86), Linux (x64/arm64) and macOS 10.15+ (intel/apple).
- [Windows x64](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.8/Clash.Verge_1.4.8_x64-setup.exe)
- [Windows x86](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.8/Clash.Verge_1.4.8_x86-setup.exe)
- [Windows arm64](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.8/Clash.Verge_1.4.8_arm64-setup.exe)
- [Windows x64](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.9/Clash.Verge_1.4.9_x64-setup.exe)
- [Windows x86](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.9/Clash.Verge_1.4.9_x86-setup.exe)
- [Windows arm64](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.9/Clash.Verge_1.4.9_arm64-setup.exe)
- [macOS intel](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.8/Clash.Verge_1.4.8_x64.dmg)
- [macOS apple](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.8/Clash.Verge_1.4.8_aarch64.dmg)
- [macOS intel](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.9/Clash.Verge_1.4.9_x64.dmg)
- [macOS apple](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.9/Clash.Verge_1.4.9_aarch64.dmg)
- [Linux x64 AppImage](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.8/clash-verge_1.4.8_amd64.AppImage)
- [Linux x64 deb](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.8/clash-verge_1.4.8_amd64.deb)
- [Linux x86 AppImage](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.8/clash-verge_1.4.8_i386.AppImage)
- [Linux x86 deb](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.8/clash-verge_1.4.8_i386.deb)
- [Linux arm64 deb](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.8/clash-verge_1.4.8_arm64.deb)
- [Linux x64 AppImage](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.9/clash-verge_1.4.9_amd64.AppImage)
- [Linux x64 deb](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.9/clash-verge_1.4.9_amd64.deb)
- [Linux x86 AppImage](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.9/clash-verge_1.4.9_i386.AppImage)
- [Linux x86 deb](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.9/clash-verge_1.4.9_i386.deb)
- [Linux arm64 deb](https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v1.4.9/clash-verge_1.4.9_arm64.deb)
Or you can build it yourself. Supports Windows, Linux and macOS 10.15+

View File

@@ -1,3 +1,16 @@
## v1.4.9
### Features
- 支持启动时运行脚本
- 支持代理组显示图标
- 新增测试页面
### Bugs Fixes
- 连接页面时间排序错误
- 连接页面表格宽度优化
## v1.4.8
### Features

View File

@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "1.4.8",
"version": "1.4.9",
"license": "GPL-3.0",
"scripts": {
"dev": "tauri dev",

2
src-tauri/Cargo.lock generated
View File

@@ -580,7 +580,7 @@ dependencies = [
[[package]]
name = "clash-verge"
version = "1.4.8"
version = "1.4.9"
dependencies = [
"anyhow",
"auto-launch",

View File

@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "1.4.8"
version = "1.4.9"
description = "clash verge"
authors = ["zzzgydi", "wonfen", "MystiPanda"]
license = "GPL-3.0"
@@ -39,7 +39,7 @@ tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
sysproxy = { git="https://github.com/clash-verge-rev/sysproxy-rs", branch = "main" }
tauri = { version = "1.5", features = [ "notification-all", "icon-png", "clipboard-all", "global-shortcut-all", "process-all", "shell-all", "system-tray", "updater", "window-all"] }
tauri = { version = "1.5", features = [ "dialog-open", "notification-all", "icon-png", "clipboard-all", "global-shortcut-all", "process-all", "shell-all", "system-tray", "updater", "window-all"] }
[target.'cfg(windows)'.dependencies]
runas = "=1.0.0" # 高版本会返回错误 Status

View File

@@ -261,6 +261,11 @@ pub fn get_portable_flag() -> CmdResult<bool> {
Ok(*dirs::PORTABLE_FLAG.get().unwrap_or(&false))
}
#[tauri::command]
pub async fn test_delay(url: String) -> CmdResult<u32> {
Ok(feat::test_delay(url).await.unwrap_or(10000u32))
}
#[cfg(windows)]
pub mod service {
use super::*;

View File

@@ -25,6 +25,9 @@ pub struct IVerge {
/// copy env type
pub env_type: Option<String>,
/// startup script path
pub startup_script: Option<String>,
/// enable traffic graph default is true
pub traffic_graph: Option<bool>,
@@ -88,6 +91,9 @@ pub struct IVerge {
/// proxy 页面布局 列数
pub proxy_layout_column: Option<i32>,
/// 测试网站列表
pub test_list: Option<Vec<IVergeTestItem>>,
/// 日志清理
/// 0: 不清理; 1: 7天; 2: 30天; 3: 90天
pub auto_log_clean: Option<i32>,
@@ -103,6 +109,14 @@ pub struct IVerge {
pub verge_mixed_port: Option<u16>,
}
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
pub struct IVergeTestItem {
pub uid: Option<String>,
pub name: Option<String>,
pub icon: Option<String>,
pub url: Option<String>,
}
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
pub struct IVergeTheme {
pub primary_color: Option<String>,
@@ -178,6 +192,7 @@ impl IVerge {
patch!(theme_mode);
patch!(tray_event);
patch!(env_type);
patch!(startup_script);
patch!(traffic_graph);
patch!(enable_memory_usage);
@@ -202,6 +217,7 @@ impl IVerge {
patch!(default_latency_test);
patch!(enable_builtin_enhanced);
patch!(proxy_layout_column);
patch!(test_list);
patch!(enable_clash_fields);
patch!(auto_log_clean);
patch!(window_size_position);

View File

@@ -368,3 +368,39 @@ pub fn copy_clash_env(app_handle: &AppHandle) {
_ => log::error!(target: "app", "copy_clash_env: Invalid env type! {env_type}"),
};
}
pub async fn test_delay(url: String) -> Result<u32> {
use tokio::time::{Duration, Instant};
let mut builder = reqwest::ClientBuilder::new().use_rustls_tls().no_proxy();
let port = Config::verge()
.latest()
.verge_mixed_port
.unwrap_or(Config::clash().data().get_mixed_port());
let proxy_scheme = format!("http://127.0.0.1:{port}");
if let Ok(proxy) = reqwest::Proxy::http(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = reqwest::Proxy::https(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = reqwest::Proxy::all(&proxy_scheme) {
builder = builder.proxy(proxy);
}
let request = builder
.timeout(Duration::from_millis(10000))
.build()?
.get(url);
let start = Instant::now();
let response = request.send().await?;
if response.status().is_success() {
let delay = start.elapsed().as_millis() as u32;
Ok(delay)
} else {
Ok(10000u32)
}
}

View File

@@ -54,6 +54,7 @@ fn main() -> std::io::Result<()> {
// verge
cmds::get_verge_config,
cmds::patch_verge_config,
cmds::test_delay,
// cmds::update_hotkeys,
// profile
cmds::get_profiles,

View File

@@ -8,7 +8,9 @@ use log4rs::append::file::FileAppender;
use log4rs::config::{Appender, Logger, Root};
use log4rs::encode::pattern::PatternEncoder;
use std::fs::{self, DirEntry};
use std::path::PathBuf;
use std::str::FromStr;
use tauri::api::process::Command;
/// initialize this instance's log file
fn init_log() -> Result<()> {
@@ -340,3 +342,44 @@ pub fn init_scheme() -> Result<()> {
pub fn init_scheme() -> Result<()> {
Ok(())
}
pub fn startup_script() -> Result<()> {
let path = {
let verge = Config::verge();
let verge = verge.latest();
verge.startup_script.clone().unwrap_or("".to_string())
};
if !path.is_empty() {
let mut shell = "";
if path.ends_with(".sh") {
shell = "bash";
}
if path.ends_with(".ps1") {
shell = "powershell";
}
if path.ends_with(".bat") {
shell = "cmd";
}
if shell.is_empty() {
return Err(anyhow::anyhow!("unsupported script: {path}"));
}
let current_dir = PathBuf::from(path.clone());
if !current_dir.exists() {
return Err(anyhow::anyhow!("script not found: {path}"));
}
let current_dir = current_dir.parent();
match current_dir {
Some(dir) => {
let _ = Command::new(shell)
.current_dir(dir.to_path_buf())
.args(&[path])
.output()?;
}
None => {
let _ = Command::new(shell).args(&[path]).output()?;
}
}
}
Ok(())
}

View File

@@ -44,7 +44,7 @@ pub fn resolve_setup(app: &mut App) {
#[cfg(target_os = "windows")]
log_err!(init::init_service());
log_err!(init::init_scheme());
log_err!(init::startup_script());
// 处理随机端口
let enable_random_port = Config::verge().latest().enable_random_port.unwrap_or(false);

View File

@@ -1,7 +1,7 @@
{
"package": {
"productName": "Clash Verge",
"version": "1.4.8"
"version": "1.4.9"
},
"build": {
"distDir": "../dist",
@@ -54,11 +54,15 @@
},
"notification": {
"all": true
},
"dialog": {
"all": false,
"open": true
}
},
"windows": [],
"security": {
"csp": "script-src 'unsafe-eval' 'self'; default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self'; img-src data: 'self';"
"csp": "script-src 'unsafe-eval' 'self'; default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self'; img-src http: https: data: 'self';"
}
}
}

View File

@@ -1,9 +1,12 @@
import dayjs from "dayjs";
import { useMemo, useState } from "react";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import {
DataGrid,
GridColDef,
GridValueFormatterParams,
} from "@mui/x-data-grid";
import { truncateStr } from "@/utils/truncate-str";
import parseTraffic from "@/utils/parse-traffic";
import { sortWithUnit, sortStringTime } from "@/utils/custom-comparator";
interface Props {
connections: IConnectionsItem[];
@@ -25,7 +28,8 @@ export const ConnectionTable = (props: Props) => {
width: 88,
align: "right",
headerAlign: "right",
sortComparator: sortWithUnit,
valueFormatter: (params: GridValueFormatterParams<number>) =>
parseTraffic(params.value).join(" "),
},
{
field: "upload",
@@ -33,7 +37,8 @@ export const ConnectionTable = (props: Props) => {
width: 88,
align: "right",
headerAlign: "right",
sortComparator: sortWithUnit,
valueFormatter: (params: GridValueFormatterParams<number>) =>
parseTraffic(params.value).join(" "),
},
{
field: "dlSpeed",
@@ -41,7 +46,8 @@ export const ConnectionTable = (props: Props) => {
width: 88,
align: "right",
headerAlign: "right",
sortComparator: sortWithUnit,
valueFormatter: (params: GridValueFormatterParams<number>) =>
parseTraffic(params.value).join(" ") + "/s",
},
{
field: "ulSpeed",
@@ -49,11 +55,12 @@ export const ConnectionTable = (props: Props) => {
width: 88,
align: "right",
headerAlign: "right",
sortComparator: sortWithUnit,
valueFormatter: (params: GridValueFormatterParams<number>) =>
parseTraffic(params.value).join(" ") + "/s",
},
{ field: "chains", headerName: "Chains", flex: 360, minWidth: 360 },
{ field: "rule", headerName: "Rule", flex: 300, minWidth: 250 },
{ field: "process", headerName: "Process", flex: 480, minWidth: 480 },
{ field: "process", headerName: "Process", flex: 240, minWidth: 120 },
{
field: "time",
headerName: "Time",
@@ -61,7 +68,11 @@ export const ConnectionTable = (props: Props) => {
minWidth: 100,
align: "right",
headerAlign: "right",
sortComparator: sortStringTime,
sortComparator: (v1, v2) => {
return new Date(v2).getTime() - new Date(v1).getTime();
},
valueFormatter: (params: GridValueFormatterParams<string>) =>
dayjs(params.value).fromNow(),
},
{ field: "source", headerName: "Source", flex: 200, minWidth: 130 },
{
@@ -83,14 +94,14 @@ export const ConnectionTable = (props: Props) => {
host: metadata.host
? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.destinationIP}:${metadata.destinationPort}`,
download: parseTraffic(each.download).join(" "),
upload: parseTraffic(each.upload).join(" "),
dlSpeed: parseTraffic(each.curDownload).join(" ") + "/s",
ulSpeed: parseTraffic(each.curUpload).join(" ") + "/s",
download: each.download,
upload: each.upload,
dlSpeed: each.curDownload,
ulSpeed: each.curUpload,
chains,
rule,
process: truncateStr(metadata.process || metadata.processPath),
time: dayjs(each.start).fromNow(),
time: each.start,
source: `${metadata.sourceIP}:${metadata.sourcePort}`,
destinationIP: metadata.destinationIP,
type: `${metadata.type}(${metadata.network})`,

View File

@@ -37,6 +37,18 @@ export const ProxyRender = (props: RenderProps) => {
dense
onClick={() => onHeadState(group.name, { open: !headState?.open })}
>
{group.icon && group.icon.trim().startsWith("http") && (
<img src={group.icon} height="40px" style={{ marginRight: "8px" }} />
)}
{group.icon && group.icon.trim().startsWith("data") && (
<img src={group.icon} height="40px" style={{ marginRight: "8px" }} />
)}
{group.icon && group.icon.trim().startsWith("<svg") && (
<img
src={`data:image/svg+xml;base64,${btoa(group.icon)}`}
height="40px"
/>
)}
<ListItemText
primary={group.name}
secondary={

View File

@@ -1,7 +1,15 @@
import { useRef } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { IconButton, MenuItem, Select, Typography } from "@mui/material";
import { open } from "@tauri-apps/api/dialog";
import {
Button,
IconButton,
MenuItem,
Select,
Input,
Typography,
} from "@mui/material";
import { openAppDir, openCoreDir, openLogsDir } from "@/services/cmds";
import { ArrowForward } from "@mui/icons-material";
import { checkUpdate } from "@tauri-apps/api/updater";
@@ -30,7 +38,8 @@ const SettingVerge = ({ onError }: Props) => {
const { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge();
const { theme_mode, language, tray_event, env_type } = verge ?? {};
const { theme_mode, language, tray_event, env_type, startup_script } =
verge ?? {};
const configRef = useRef<DialogRef>(null);
const hotkeyRef = useRef<DialogRef>(null);
const miscRef = useRef<DialogRef>(null);
@@ -125,7 +134,54 @@ const SettingVerge = ({ onError }: Props) => {
</Select>
</GuardState>
</SettingItem>
<SettingItem label={t("Startup Script")}>
<GuardState
value={startup_script ?? ""}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ startup_script: e })}
onGuard={(e) => patchVerge({ startup_script: e })}
>
<Input
value={startup_script}
disabled
endAdornment={
<>
<Button
onClick={async () => {
const path = await open({
directory: false,
multiple: false,
filters: [
{
name: "Shell Script",
extensions: ["sh", "bat", "ps1"],
},
],
});
if (path?.length) {
onChangeData({ startup_script: `${path}` });
patchVerge({ startup_script: `${path}` });
}
}}
>
{t("Browse")}
</Button>
{startup_script && (
<Button
onClick={async () => {
onChangeData({ startup_script: "" });
patchVerge({ startup_script: "" });
}}
>
{t("Clear")}
</Button>
)}
</>
}
></Input>
</GuardState>
</SettingItem>
<SettingItem label={t("Theme Setting")}>
<IconButton
color="inherit"

View File

@@ -0,0 +1,42 @@
import { alpha, Box, styled } from "@mui/material";
export const TestBox = styled(Box)(({ theme, "aria-selected": selected }) => {
const { mode, primary, text, grey, background } = theme.palette;
const key = `${mode}-${!!selected}`;
const backgroundColor = {
"light-true": alpha(primary.main, 0.2),
"light-false": alpha(background.paper, 0.75),
"dark-true": alpha(primary.main, 0.45),
"dark-false": alpha(grey[700], 0.45),
}[key]!;
const color = {
"light-true": text.secondary,
"light-false": text.secondary,
"dark-true": alpha(text.secondary, 0.85),
"dark-false": alpha(text.secondary, 0.65),
}[key]!;
const h2color = {
"light-true": primary.main,
"light-false": text.primary,
"dark-true": primary.light,
"dark-false": text.primary,
}[key]!;
return {
position: "relative",
width: "100%",
display: "block",
cursor: "pointer",
textAlign: "left",
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[2],
padding: "8px 16px",
boxSizing: "border-box",
backgroundColor,
color,
"& h2": { color: h2color },
};
});

View File

@@ -0,0 +1,217 @@
import { useEffect, useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Box,
Typography,
Divider,
MenuItem,
Menu,
styled,
alpha,
} from "@mui/material";
import { BaseLoading } from "@/components/base";
import { LanguageTwoTone } from "@mui/icons-material";
import { Notice } from "@/components/base";
import { TestBox } from "./test-box";
import delayManager from "@/services/delay";
import { cmdTestDelay } from "@/services/cmds";
import { listen, Event, UnlistenFn } from "@tauri-apps/api/event";
interface Props {
id: string;
itemData: IVergeTestItem;
onEdit: () => void;
onDelete: (uid: string) => void;
}
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 { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null);
const [position, setPosition] = useState({ left: 0, top: 0 });
const [delay, setDelay] = useState(-1);
const { uid, name, icon, url } = itemData;
const onDelay = async () => {
setDelay(-2);
const result = await cmdTestDelay(url);
setDelay(result);
};
const onEditTest = () => {
setAnchorEl(null);
onEdit();
};
const onDelete = useLockFn(async () => {
setAnchorEl(null);
try {
onDeleteItem(uid);
} catch (err: any) {
Notice.error(err?.message || err.toString());
}
});
const menu = [
{ label: "Edit", handler: onEditTest },
{ label: "Delete", handler: onDelete },
];
const listenTsetEvent = async () => {
if (eventListener !== null) {
eventListener();
}
eventListener = await listen("verge://test-all", () => {
onDelay();
});
};
useEffect(() => {
onDelay();
listenTsetEvent();
}, []);
return (
<Box
sx={{
transform: CSS.Transform.toString(transform),
transition,
}}
>
<TestBox
onClick={onEditTest}
onContextMenu={(event) => {
const { clientX, clientY } = event;
setPosition({ top: clientY, left: clientX });
setAnchorEl(event.currentTarget);
event.preventDefault();
}}
>
<Box
position="relative"
sx={{ cursor: "move" }}
ref={setNodeRef}
{...attributes}
{...listeners}
>
{icon && icon.trim() !== "" ? (
<Box sx={{ display: "flex", justifyContent: "center" }}>
{icon.trim().startsWith("http") && (
<img src={icon} height="40px" style={{ marginRight: "8px" }} />
)}
{icon.trim().startsWith("data") && (
<img src={icon} height="40px" style={{ marginRight: "8px" }} />
)}
{icon.trim().startsWith("<svg") && (
<img
src={`data:image/svg+xml;base64,${btoa(icon)}`}
height="40px"
/>
)}
</Box>
) : (
<Box sx={{ display: "flex", justifyContent: "center" }}>
<LanguageTwoTone sx={{ height: "40px" }} fontSize="large" />
</Box>
)}
<Box sx={{ display: "flex", justifyContent: "center" }}>
<Typography variant="h6" component="h2" noWrap title={name}>
{name}
</Typography>
</Box>
</Box>
<Divider sx={{ marginTop: "8px" }} />
<Box
sx={{
display: "flex",
justifyContent: "center",
marginTop: "8px",
color: "primary.main",
}}
>
{delay === -2 && (
<Widget>
<BaseLoading />
</Widget>
)}
{delay === -1 && (
<Widget
className="the-check"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelay();
}}
sx={({ palette }) => ({
":hover": { bgcolor: alpha(palette.primary.main, 0.15) },
})}
>
Check
</Widget>
)}
{delay >= 0 && (
// 显示延迟
<Widget
className="the-delay"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelay();
}}
color={delayManager.formatDelayColor(delay)}
sx={({ palette }) => ({
":hover": {
bgcolor: alpha(palette.primary.main, 0.15),
},
})}
>
{delayManager.formatDelay(delay)}
</Widget>
)}
</Box>
</TestBox>
<Menu
open={!!anchorEl}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorPosition={position}
anchorReference="anchorPosition"
transitionDuration={225}
MenuListProps={{ sx: { py: 0.5 } }}
onContextMenu={(e) => {
setAnchorEl(null);
e.preventDefault();
}}
>
{menu.map((item) => (
<MenuItem
key={item.label}
onClick={item.handler}
sx={{ minWidth: 120 }}
dense
>
{t(item.label)}
</MenuItem>
))}
</Menu>
</Box>
);
};
const Widget = styled(Box)(({ theme: { typography } }) => ({
padding: "3px 6px",
fontSize: 14,
fontFamily: typography.fontFamily,
borderRadius: "4px",
}));

View File

@@ -0,0 +1,153 @@
import { forwardRef, useImperativeHandle, useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { TextField } from "@mui/material";
import { useVerge } from "@/hooks/use-verge";
import { BaseDialog, Notice } from "@/components/base";
interface Props {
onChange: (uid: string, patch?: Partial<IVergeTestItem>) => void;
}
export interface TestViewerRef {
create: () => void;
edit: (item: IVergeTestItem) => void;
}
// create or edit the test item
export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [openType, setOpenType] = useState<"new" | "edit">("new");
const [loading, setLoading] = useState(false);
const { verge, patchVerge } = useVerge();
const testList = verge?.test_list ?? [];
const { control, watch, register, ...formIns } = useForm<IVergeTestItem>({
defaultValues: {
name: "",
icon: "",
url: "",
},
});
const patchTestList = async (uid: string, patch: Partial<IVergeTestItem>) => {
const newList = testList.map((x) => {
if (x.uid === uid) {
return { ...x, ...patch };
}
return x;
});
await patchVerge({ ...verge, test_list: newList });
};
useImperativeHandle(ref, () => ({
create: () => {
setOpenType("new");
setOpen(true);
},
edit: (item) => {
if (item) {
Object.entries(item).forEach(([key, value]) => {
formIns.setValue(key as any, value);
});
}
setOpenType("edit");
setOpen(true);
},
}));
const handleOk = useLockFn(
formIns.handleSubmit(async (form) => {
setLoading(true);
try {
if (!form.name) throw new Error("`Name` should not be null");
if (!form.url) throw new Error("`Url` should not be null");
let newList;
let uid;
if (openType === "new") {
uid = crypto.randomUUID();
const item = { ...form, uid };
newList = [...testList, item];
await patchVerge({ test_list: newList });
props.onChange(uid);
} else {
if (!form.uid) throw new Error("UID not found");
uid = form.uid;
await patchTestList(uid, form);
props.onChange(uid, form);
}
setOpen(false);
setLoading(false);
setTimeout(() => formIns.reset(), 500);
} catch (err: any) {
Notice.error(err.message || err.toString());
setLoading(false);
}
})
);
const handleClose = () => {
setOpen(false);
setTimeout(() => formIns.reset(), 500);
};
const text = {
fullWidth: true,
size: "small",
margin: "normal",
variant: "outlined",
autoComplete: "off",
autoCorrect: "off",
} as const;
return (
<BaseDialog
open={open}
title={openType === "new" ? t("Create Test") : t("Edit Test")}
contentSx={{ width: 375, pb: 0, maxHeight: "80%" }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={handleClose}
onCancel={handleClose}
onOk={handleOk}
loading={loading}
>
<Controller
name="name"
control={control}
render={({ field }) => (
<TextField {...text} {...field} label={t("Name")} />
)}
/>
<Controller
name="icon"
control={control}
render={({ field }) => (
<TextField
{...text}
{...field}
multiline
maxRows={5}
label={t("Icon")}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field }) => (
<TextField
{...text}
{...field}
multiline
maxRows={3}
label={t("Test URL")}
/>
)}
/>
</BaseDialog>
);
});

View File

@@ -1,5 +1,6 @@
{
"Label-Proxies": "Proxies",
"Label-Test": "Test",
"Label-Profiles": "Profiles",
"Label-Connections": "Connections",
"Label-Logs": "Logs",
@@ -11,11 +12,17 @@
"Clear": "Clear",
"Proxies": "Proxies",
"Proxy Groups": "Proxy Groups",
"Test": "Test",
"rule": "rule",
"global": "global",
"direct": "direct",
"script": "script",
"Edit": "Edit",
"Icon": "Icon",
"Test URL": "Test URL",
"Test All": "Test All",
"Profiles": "Profiles",
"Profile URL": "Profile URL",
"Import": "Import",
@@ -92,6 +99,8 @@
"Theme Mode": "Theme Mode",
"Tray Click Event": "Tray Click Event",
"Copy Env Type": "Copy Env Type",
"Startup Script": "Startup Script",
"Browse": "Browse",
"Show Main Window": "Show Main Window",
"Theme Setting": "Theme Setting",
"Layout Setting": "Layout Setting",

View File

@@ -1,5 +1,6 @@
{
"Label-Proxies": "Прокси",
"Label-Test": "Тест",
"Label-Profiles": "Профили",
"Label-Connections": "Соединения",
"Label-Logs": "Логи",
@@ -10,12 +11,18 @@
"Logs": "Логи",
"Clear": "Очистить",
"Proxies": "Прокси",
"Test": "Тест",
"Proxy Groups": "Группы прокси",
"rule": "правила",
"global": "глобальный",
"direct": "прямой",
"script": "скриптовый",
"Edit": "Редактировать",
"Icon": "Икона",
"Test URL": "Тестовый URL",
"Test All": "Тест Все",
"Profiles": "Профили",
"Profile URL": "URL профиля",
"Import": "Импорт",
@@ -83,6 +90,8 @@
"Theme Mode": "Режим темы",
"Tray Click Event": "Событие щелчка в лотке",
"Copy Env Type": "Скопировать тип Env",
"Startup Script": "Скрипт запуска",
"Browse": "Просмотреть",
"Show Main Window": "Показать главное окно",
"Theme Setting": "Настройка темы",
"Hotkey Setting": "Настройка клавиатурных сокращений",

View File

@@ -1,5 +1,6 @@
{
"Label-Proxies": "代 理",
"Label-Test": "测 试",
"Label-Profiles": "订 阅",
"Label-Connections": "连 接",
"Label-Logs": "日 志",
@@ -11,11 +12,17 @@
"Clear": "清除",
"Proxies": "代理",
"Proxy Groups": "代理组",
"Test": "测试",
"rule": "规则",
"global": "全局",
"direct": "直连",
"script": "脚本",
"Edit": "编辑",
"Icon": "图标",
"Test URL": "测试地址",
"Test All": "测试全部",
"Profiles": "订阅",
"Profile URL": "订阅文件链接",
"Import": "导入",
@@ -92,6 +99,8 @@
"Theme Mode": "主题模式",
"Tray Click Event": "托盘点击事件",
"Copy Env Type": "复制环境变量类型",
"Startup Script": "启动脚本",
"Browse": "浏览",
"Show Main Window": "显示主窗口",
"Theme Setting": "主题设置",
"Layout Setting": "界面设置",

View File

@@ -1,5 +1,6 @@
import LogsPage from "./logs";
import ProxiesPage from "./proxies";
import TestPage from "./test";
import ProfilesPage from "./profiles";
import SettingsPage from "./settings";
import ConnectionsPage from "./connections";
@@ -11,6 +12,11 @@ export const routers = [
link: "/",
ele: ProxiesPage,
},
{
label: "Label-Test",
link: "/test",
ele: TestPage,
},
{
label: "Label-Profiles",
link: "/profile",

164
src/pages/test.tsx Normal file
View File

@@ -0,0 +1,164 @@
import { useEffect, useRef } from "react";
import { useVerge } from "@/hooks/use-verge";
import { Box, Button, Grid } from "@mui/material";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { useTranslation } from "react-i18next";
import { BasePage } from "@/components/base";
import { TestViewer, TestViewerRef } from "@/components/test/test-viewer";
import { TestItem } from "@/components/test/test-item";
import { emit } from "@tauri-apps/api/event";
const TestPage = () => {
const { t } = useTranslation();
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const { verge, mutateVerge, patchVerge } = useVerge();
// test list
const testList = verge?.test_list ?? [
{
uid: crypto.randomUUID(),
name: "Apple",
url: "https://www.apple.com",
icon: "https://www.apple.com/favicon.ico",
},
{
uid: crypto.randomUUID(),
name: "GitHub",
url: "https://www.github.com",
icon: `<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#000000"/></svg>`,
},
{
uid: crypto.randomUUID(),
name: "Google",
url: "https://www.google.com",
icon: `<svg enable-background="new 0 0 48 48" height="48" viewBox="0 0 48 48" width="48" xmlns="http://www.w3.org/2000/svg"><path d="m43.611 20.083h-1.611v-.083h-18v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657c-3.572-3.329-8.35-5.382-13.618-5.382-11.045 0-20 8.955-20 20s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z" fill="#ffc107"/><path d="m6.306 14.691 6.571 4.819c1.778-4.402 6.084-7.51 11.123-7.51 3.059 0 5.842 1.154 7.961 3.039l5.657-5.657c-3.572-3.329-8.35-5.382-13.618-5.382-7.682 0-14.344 4.337-17.694 10.691z" fill="#ff3d00"/><path d="m24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238c-2.008 1.521-4.504 2.43-7.219 2.43-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025c3.31 6.477 10.032 10.921 17.805 10.921z" fill="#4caf50"/><path d="m43.611 20.083h-1.611v-.083h-18v8h11.303c-.792 2.237-2.231 4.166-4.087 5.571.001-.001.002-.001.003-.002l6.19 5.238c-.438.398 6.591-4.807 6.591-14.807 0-1.341-.138-2.65-.389-3.917z" fill="#1976d2"/></svg>`,
},
];
const onTestListItemChange = (
uid: string,
patch?: Partial<IVergeTestItem>
) => {
if (patch) {
const newList = testList.map((x) => {
if (x.uid === uid) {
return { ...x, ...patch };
}
return x;
});
mutateVerge({ ...verge, test_list: newList }, false);
} else {
mutateVerge();
}
};
const onDeleteTestListItem = (uid: string) => {
const newList = testList.filter((x) => x.uid !== uid);
patchVerge({ test_list: newList });
mutateVerge({ ...verge, test_list: newList }, false);
};
const reorder = (list: any[], startIndex: number, endIndex: number) => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
const onDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over) {
if (active.id !== over.id) {
let old_index = testList.findIndex((x) => x.uid === active.id);
let new_index = testList.findIndex((x) => x.uid === over.id);
if (old_index < 0 || new_index < 0) {
return;
}
let newList = reorder(testList, old_index, new_index);
await mutateVerge({ ...verge, test_list: newList }, false);
await patchVerge({ test_list: newList });
}
}
};
useEffect(() => {
if (!verge) return;
if (!verge?.test_list) {
patchVerge({ test_list: testList });
}
}, [verge]);
const viewerRef = useRef<TestViewerRef>(null);
return (
<BasePage
title={t("Test")}
header={
<Box sx={{ mt: 1, display: "flex", alignItems: "center", gap: 1 }}>
<Button
variant="contained"
size="small"
onClick={() => emit("verge://test-all")}
>
{t("Test All")}
</Button>
<Button
variant="contained"
size="small"
onClick={() => viewerRef.current?.create()}
>
{t("New")}
</Button>
</Box>
}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<Box sx={{ mb: 4.5 }}>
<Grid container spacing={{ xs: 1, lg: 1 }}>
<SortableContext
items={testList.map((x) => {
return x.uid;
})}
>
{testList.map((item) => (
<Grid item xs={6} sm={4} md={3} lg={2} key={item.uid}>
<TestItem
id={item.uid}
itemData={item}
onEdit={() => viewerRef.current?.edit(item)}
onDelete={onDeleteTestListItem}
/>
</Grid>
))}
</SortableContext>
</Grid>
</Box>
</DndContext>
<TestViewer ref={viewerRef} onChange={onTestListItemChange} />
</BasePage>
);
};
export default TestPage;

View File

@@ -165,6 +165,10 @@ export async function cmdGetProxyDelay(name: string, url?: string) {
return invoke<{ delay: number }>("clash_api_get_proxy_delay", { name, url });
}
export async function cmdTestDelay(url: string) {
return invoke<number>("test_delay", { url });
}
/// service mode
export async function checkService() {

View File

@@ -109,17 +109,16 @@ class DelayManager {
}
formatDelay(delay: number) {
if (delay < 0) return "-";
if (delay <= 0) return "Error";
if (delay > 1e5) return "Error";
if (delay >= 10000) return "Timeout"; // 10s
return `${delay}`;
return `${delay} ms`;
}
formatDelayColor(delay: number) {
if (delay >= 10000) return "error.main";
/*if (delay <= 0) return "text.secondary";
if (delay <= 0) return "error.main";
if (delay > 500) return "warning.main";
if (delay > 100) return "text.secondary";*/
return "success.main";
}
}

View File

@@ -53,6 +53,7 @@ interface IProxyItem {
all?: string[];
now?: string;
hidden?: boolean;
icon?: string;
provider?: string; // 记录是否来自provider
}
@@ -154,11 +155,19 @@ interface IProfilesConfig {
items?: IProfileItem[];
}
interface IVergeTestItem {
uid: string;
name?: string;
icon?: string;
url: string;
}
interface IVergeConfig {
app_log_level?: "trace" | "debug" | "info" | "warn" | "error" | string;
language?: string;
tray_event?: "main_window" | "system_proxy" | "tun_mode" | string;
env_type?: "bash" | "cmd" | "powershell" | string;
startup_script?: string;
clash_core?: string;
theme_mode?: "light" | "dark" | "system";
traffic_graph?: boolean;
@@ -194,6 +203,7 @@ interface IVergeConfig {
enable_builtin_enhanced?: boolean;
auto_log_clean?: 0 | 1 | 2 | 3;
proxy_layout_column?: number;
test_list?: IVergeTestItem[];
}
type IClashConfigValue = any;

View File

@@ -1,38 +0,0 @@
import { GridComparatorFn } from "@mui/x-data-grid";
const UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const unitMap = new Map<string, number>();
unitMap.set("分钟前", 60);
unitMap.set("小时前", 60 * 60);
unitMap.set("天前", 60 * 60 * 24);
unitMap.set("个月前", 60 * 60 * 24 * 30);
unitMap.set("年前", 60 * 60 * 24 * 30 * 12);
export const sortWithUnit: GridComparatorFn<string> = (v1, v2) => {
const [ret1, unit1] = v1.split(" ");
const [ret2, unit2] = v2.split(" ");
let value1 =
parseFloat(ret1) *
Math.pow(1024, UNITS.indexOf(unit1.replace("/s", "").trim()));
let value2 =
parseFloat(ret2) *
Math.pow(1024, UNITS.indexOf(unit2.replace("/s", "").trim()));
return value1 - value2;
};
export const sortStringTime: GridComparatorFn<string> = (v1, v2) => {
if (v1 === "几秒前") {
return -1;
}
if (v2 === "几秒前") {
return 1;
}
const matches1 = v1.match(/[0-9]+/);
const num1 = matches1 !== null ? parseInt(matches1[0]) : 0;
const matches2 = v2.match(/[0-9]+/);
const num2 = matches2 !== null ? parseInt(matches2[0]) : 0;
const unit1 = unitMap.get(v1.replace(num1.toString(), "").trim()) || 0;
const unit2 = unitMap.get(v2.replace(num2.toString(), "").trim()) || 0;
return num1 * unit1 - num2 * unit2;
};