Compare commits

..
Author SHA1 Message Date
wonfen 396a3e81d8 fix: alpha build_2 2025-02-17 09:22:02 +08:00
wonfen 17fd1248d0 fix: alpha build 2025-02-17 06:50:52 +08:00
48 changed files with 854 additions and 2079 deletions
+13 -28
View File
@@ -67,9 +67,6 @@ jobs:
pnpm i
pnpm check ${{ matrix.target }}
- name: Alpha Version update
run: pnpm run fix-alpha-version
- name: Tauri build
uses: tauri-apps/tauri-action@v0
env:
@@ -84,8 +81,8 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: alpha
releaseName: "Clash Verge Rev Alpha"
tagName: alpha-__VERSION__
releaseName: "Clash Verge Rev Alpha __VERSION__"
releaseBody: "More new features are now supported."
releaseDraft: false
prerelease: true
@@ -218,6 +215,8 @@ jobs:
files: |
src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm
updater/update-alpha.json
updater/update-alpha-proxy.json
alpha-for-fixed-webview2:
strategy:
@@ -281,23 +280,9 @@ jobs:
- name: Rename
run: |
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe"
foreach ($file in $files) {
$newName = $file.Name -replace "-setup\.exe$", "_fixed_webview2-setup.exe"
Rename-Item $file.FullName $newName
}
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*.nsis.zip"
foreach ($file in $files) {
$newName = $file.Name -replace "-setup\.nsis\.zip$", "_fixed_webview2-setup.nsis.zip"
Rename-Item $file.FullName $newName
}
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*.nsis.zip.sig"
foreach ($file in $files) {
$newName = $file.Name -replace "-setup\.nsis\.zip\.sig$", "_fixed_webview2-setup.nsis.zip.sig"
Rename-Item $file.FullName $newName
}
Rename-Item '.\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}-setup.exe' 'Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}_fixed_webview2-setup.exe'
Rename-Item '.\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}-setup.nsis.zip' 'Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}_fixed_webview2-setup.nsis.zip'
Rename-Item '.\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}-setup.nsis.zip.sig' 'Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}_fixed_webview2-setup.nsis.zip.sig'
- name: Upload Release
uses: softprops/action-gh-release@v2
@@ -327,12 +312,12 @@ jobs:
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
shell: bash
# - name: Update Tag
# uses: richardsimko/update-tag@v1
# with:
# tag_name: alpha
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update Tag
uses: richardsimko/update-tag@v1
with:
tag_name: alpha
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
cat > release.txt << 'EOF'
-80
View File
@@ -1,83 +1,3 @@
## v2.1.0 发行代号:臻
代号释义: 千锤百炼臻至善,集性能跃升、功能拓展、交互焕新于一体,彰显持续打磨、全方位优化的迭代精神。
### 功能新增
- 新增窗口状态实时监控与自动保存功能
- 增强核心配置变更时的验证与错误处理机制
- 支持通过环境变量`CLASH_VERGE_REV_IP`自定义复制IP地址
- 添加连接表列宽持久化设置与进程过滤功能
- 新增代理组首字母导航与动态滚动定位功能
- 实现连接追踪暂停/恢复功能
- 支持从托盘菜单快速切换代理配置
- 添加轻量级模式开关选项
- 允许用户自定义TUN模式增强类型和FakeIP范围
- 新增系统代理状态指示器
- 增加Alpha版本自动重命名逻辑
- 优化字母导航工具提示与防抖交互机制
### 性能优化
- 重构代理列表渲染逻辑,提升布局计算效率
- 优化代理数据更新机制,采用乐观UI策略
- 改进虚拟列表渲染性能(Virtuoso)
- 提升主窗口Clash模式切换速度 (感谢Tunglies)
- 加速内核关闭流程并优化管理逻辑
- 优化节点延迟刷新速率
- 改进托盘网速显示更新逻辑
- 提升配置验证错误信息的可读性
- 重构服务架构,优化代码组织结构 (感谢Tunglies)
- 优化内核启动时的配置验证流程
### 问题修复
- 修复删除节点时关联组信息残留问题
- 解决菜单切换异常与重复勾选问题
- 修正连接页流量计算错误
- 修复Windows圆角显示异常问题
- 解决控制台废弃API警告
- 修复全局热键空值导致的崩溃
- 修复Alpha版本Windows打包重命名问题
- 修复MacOS端口切换崩溃问题
- 解决Linux持续集成更新器问题
- 修复静默启动后热键失效问题
- 修正TypeScript代理组类型定义
- 修复Windows托盘图标空白问题
- 优化远程目标地址显示(替换旧版IP展示)
### 交互体验
- 统一多平台托盘图标点击行为
- 优化代理列表滚动流畅度
- 改进日志搜索功能与数据管理
- 重构热键管理逻辑,修复托盘冻结问题
- 优化托盘网速显示样式
- 增强字母导航工具提示的动态响应
### 国际化
- 新增配置检查多语言支持
- 添加轻量级模式多语言文本
- 完善多语言翻译内容
### 维护更新
- 将默认TUN协议栈改为gVisor
- 更新Node.js运行版本
- 移除自动生成更新器文件
- 清理废弃代码与未使用组件
- 禁用工作流自动Alpha标签更新
- 更新依赖库版本
- 添加MacOS格式转换函数专项测试
- 优化开发模式日志输出
### 安全增强
- 强化应用启动时的配置验证机制
- 改进脚本验证与异常处理流程
- 修复编译警告(移除无用导入)
## v2.0.3
### Notice
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.1.0",
"version": "2.0.4-alpha",
"license": "GPL-3.0-only",
"scripts": {
"dev": "cross-env RUST_BACKTRACE=1 tauri dev",
@@ -15,7 +15,6 @@
"updater-fixed-webview2": "node scripts/updater-fixed-webview2.mjs",
"portable": "node scripts/portable.mjs",
"portable-fixed-webview2": "node scripts/portable-fixed-webview2.mjs",
"fix-alpha-version": "node scripts/alpha_version.mjs",
"prepare": "husky"
},
"dependencies": {
-54
View File
@@ -1,54 +0,0 @@
import { exec } from "child_process";
import { promisify } from "util";
import fs from "fs/promises";
import path from "path";
/**
* 为Alpha版本重命名版本号
*/
const execPromise = promisify(exec);
/**
* 标准输出HEAD hash
*/
async function getLatestCommitHash() {
try {
const { stdout } = await execPromise("git rev-parse HEAD");
const commitHash = stdout.trim();
// 格式化,只截取前7位字符
const formathash = commitHash.substring(0, 7);
console.log(`Found the latest commit hash code: ${commitHash}`);
return formathash;
} catch (error) {
console.error("pnpm run fix-alpha-version ERROR", error);
}
}
/**
* @param string 传入格式化后的hash
* 将新的版本号写入文件 package.json
*/
async function updatePackageVersion(newVersion) {
// 获取内容根目录
const _dirname = process.cwd();
const packageJsonPath = path.join(_dirname, "package.json");
try {
const data = await fs.readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(data);
const initversion = packageJson.version;
// 将匹配到的第一个 "alpha" => 具体的hash
const fixversion = initversion.replace("alpha", newVersion);
packageJson.version = fixversion;
// 写入版本号
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2),
"utf8",
);
console.log(`Alpha version update to: ${fixversion}`);
} catch (error) {
console.error("pnpm run fix-alpha-version ERROR", error);
}
}
const newVersion = await getLatestCommitHash();
updatePackageVersion(newVersion);
+1 -1
View File
@@ -999,7 +999,7 @@ dependencies = [
[[package]]
name = "clash-verge"
version = "2.1.0"
version = "2.0.4-alpha"
dependencies = [
"aes-gcm",
"anyhow",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.1.0"
version = "2.0.4-alpha"
description = "clash verge"
authors = ["zzzgydi", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -31,7 +31,7 @@ serde_json = "1.0"
serde_yaml = "0.9"
once_cell = "1.19"
port_scanner = "0.1.5"
delay_timer = "0.11.6"
delay_timer = "0.11"
parking_lot = "0.12"
percent-encoding = "2.3.1"
window-shadows = { version = "0.2.2" }
+21 -188
View File
@@ -13,7 +13,6 @@ use sysproxy::{Autoproxy, Sysproxy};
type CmdResult<T = ()> = Result<T, String>;
use reqwest_dav::list_cmd::ListFile;
use tauri::Manager;
use std::fs;
#[tauri::command]
pub fn copy_clash_env() -> CmdResult {
@@ -29,24 +28,10 @@ pub fn get_profiles() -> CmdResult<IProfiles> {
#[tauri::command]
pub async fn enhance_profiles() -> CmdResult {
match CoreManager::global().update_config().await {
Ok((true, _)) => {
println!("[enhance_profiles] 配置更新成功");
log_err!(tray::Tray::global().update_tooltip());
handle::Handle::refresh_clash();
Ok(())
}
Ok((false, error_msg)) => {
println!("[enhance_profiles] 配置验证失败: {}", error_msg);
handle::Handle::notice_message("config_validate::error", &error_msg);
Ok(())
}
Err(e) => {
println!("[enhance_profiles] 更新过程发生错误: {}", e);
handle::Handle::notice_message("config_validate::process_terminated", &e.to_string());
Ok(())
}
}
wrap_err!(CoreManager::global().update_config().await)?;
log_err!(tray::Tray::global().update_tooltip());
handle::Handle::refresh_clash();
Ok(())
}
#[tauri::command]
@@ -81,68 +66,29 @@ pub async fn delete_profile(index: String) -> CmdResult {
Ok(())
}
/// 修改profiles的配置
/// 修改profiles的
#[tauri::command]
pub async fn patch_profiles_config(
profiles: IProfiles
) -> CmdResult<bool> {
println!("[cmd配置patch] 开始修改配置文件");
// 保存当前配置,以便在验证失败时恢复
let current_profile = Config::profiles().latest().current.clone();
println!("[cmd配置patch] 当前配置: {:?}", current_profile);
// 更新profiles配置
println!("[cmd配置patch] 正在更新配置草稿");
pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult {
wrap_err!({ Config::profiles().draft().patch_config(profiles) })?;
// 更新配置并进行验证
match CoreManager::global().update_config().await {
Ok((true, _)) => {
println!("[cmd配置patch] 配置更新成功");
Ok(_) => {
handle::Handle::refresh_clash();
let _ = tray::Tray::global().update_tooltip();
Config::profiles().apply();
wrap_err!(Config::profiles().data().save_file())?;
Ok(true)
Ok(())
}
Ok((false, error_msg)) => {
println!("[cmd配置patch] 配置验证失败: {}", error_msg);
Err(err) => {
Config::profiles().discard();
// 如果验证失败,恢复到之前的配置
if let Some(prev_profile) = current_profile {
println!("[cmd配置patch] 尝试恢复到之前的配置: {}", prev_profile);
let restore_profiles = IProfiles {
current: Some(prev_profile),
items: None,
};
// 静默恢复,不触发验证
wrap_err!({ Config::profiles().draft().patch_config(restore_profiles) })?;
Config::profiles().apply();
wrap_err!(Config::profiles().data().save_file())?;
println!("[cmd配置patch] 成功恢复到之前的配置");
}
// 发送验证错误通知
handle::Handle::notice_message("config_validate::error", &error_msg);
Ok(false)
}
Err(e) => {
println!("[cmd配置patch] 更新过程发生错误: {}", e);
Config::profiles().discard();
handle::Handle::notice_message("config_validate::boot_error", &e.to_string());
Ok(false)
log::error!(target: "app", "{err}");
Err(format!("{err}"))
}
}
}
/// 根据profile name修改profiles
#[tauri::command]
pub async fn patch_profiles_config_by_profile_index(
_app_handle: tauri::AppHandle,
profile_index: String
) -> CmdResult<bool> {
pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> CmdResult {
let profiles = IProfiles{current: Some(profile_index), items: None};
patch_profiles_config(profiles).await
}
@@ -151,7 +97,7 @@ pub async fn patch_profiles_config_by_profile_index(
#[tauri::command]
pub fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
wrap_err!(Config::profiles().data().patch_item(index, profile))?;
Ok(())
wrap_err!(timer::Timer::global().refresh())
}
#[tauri::command]
@@ -179,65 +125,17 @@ pub fn read_profile_file(index: String) -> CmdResult<String> {
let data = wrap_err!(item.read_file())?;
Ok(data)
}
/// 保存profiles的配置
#[tauri::command]
pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdResult {
pub fn save_profile_file(index: String, file_data: Option<String>) -> CmdResult {
if file_data.is_none() {
return Ok(());
}
// 在异步操作前完成所有文件操作
let (file_path, original_content) = {
let profiles = Config::profiles();
let profiles_guard = profiles.latest();
let item = wrap_err!(profiles_guard.get_item(&index))?;
let content = wrap_err!(item.read_file())?;
let path = item.file.clone().ok_or("file field is null")?;
let profiles_dir = wrap_err!(dirs::app_profiles_dir())?;
(profiles_dir.join(path), content)
};
// 保存新的配置文件
wrap_err!(fs::write(&file_path, file_data.clone().unwrap()))?;
let file_path_str = file_path.to_string_lossy();
println!("[cmd配置save] 开始验证配置文件: {}", file_path_str);
// 验证配置文件
match CoreManager::global().validate_config_file(&file_path_str).await {
Ok((true, _)) => {
println!("[cmd配置save] 验证成功");
Ok(())
}
Ok((false, error_msg)) => {
println!("[cmd配置save] 验证失败: {}", error_msg);
// 恢复原始配置文件
wrap_err!(fs::write(&file_path, original_content))?;
// 智能判断是否为脚本错误
let is_script_error = file_path_str.ends_with(".js") ||
error_msg.contains("Script syntax error") ||
error_msg.contains("Script must contain a main function") ||
error_msg.contains("Failed to read script file");
if is_script_error {
// 脚本错误使用专门的通知处理
let result = (false, error_msg.clone());
handle_script_validation_notice(&result, "脚本文件");
} else {
// 普通配置错误使用一般通知
handle::Handle::notice_message("config_validate::error", &error_msg);
}
Ok(())
}
Err(e) => {
println!("[cmd配置save] 验证过程发生错误: {}", e);
// 恢复原始配置文件
wrap_err!(fs::write(&file_path, original_content))?;
Err(e.to_string())
}
}
let profiles = Config::profiles();
let profiles = profiles.latest();
let item = wrap_err!(profiles.get_item(&index))?;
wrap_err!(item.save_file(file_data.unwrap()))
}
#[tauri::command]
@@ -296,23 +194,8 @@ pub async fn patch_verge_config(payload: IVerge) -> CmdResult {
}
#[tauri::command]
pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>> {
log::info!(target: "app", "changing core to {clash_core}");
match CoreManager::global().change_core(Some(clash_core.clone())).await {
Ok(_) => {
log::info!(target: "app", "core changed to {clash_core}");
handle::Handle::notice_message("config_core::change_success", &clash_core);
handle::Handle::refresh_clash();
Ok(None)
}
Err(err) => {
let error_msg = err.to_string();
log::error!(target: "app", "failed to change core: {error_msg}");
handle::Handle::notice_message("config_core::change_error", &error_msg);
Ok(Some(error_msg))
}
}
pub async fn change_clash_core(clash_core: Option<String>) -> CmdResult {
wrap_err!(CoreManager::global().change_core(clash_core).await)
}
/// restart the sidecar
@@ -556,53 +439,3 @@ pub mod uwp {
Ok(())
}
}
#[tauri::command]
pub async fn script_validate_notice(status: String, msg: String) -> CmdResult {
handle::Handle::notice_message(&status, &msg);
Ok(())
}
/// 处理脚本验证相关的所有消息通知
/// 统一通知接口,保持消息类型一致性
pub fn handle_script_validation_notice(result: &(bool, String), file_type: &str) {
if !result.0 {
let error_msg = &result.1;
// 根据错误消息内容判断错误类型
let status = if error_msg.starts_with("File not found:") {
"config_validate::file_not_found"
} else if error_msg.starts_with("Failed to read script file:") {
"config_validate::script_error"
} else if error_msg.starts_with("Script syntax error:") {
"config_validate::script_syntax_error"
} else if error_msg == "Script must contain a main function" {
"config_validate::script_missing_main"
} else {
// 如果是其他类型错误,作为一般脚本错误处理
"config_validate::script_error"
};
log::warn!(target: "app", "{} 验证失败: {}", file_type, error_msg);
handle::Handle::notice_message(status, error_msg);
}
}
/// 验证指定脚本文件
#[tauri::command]
pub async fn validate_script_file(file_path: String) -> CmdResult<bool> {
log::info!(target: "app", "验证脚本文件: {}", file_path);
match CoreManager::global().validate_config_file(&file_path).await {
Ok(result) => {
handle_script_validation_notice(&result, "脚本文件");
Ok(result.0) // 返回验证结果布尔值
},
Err(e) => {
let error_msg = e.to_string();
log::error!(target: "app", "验证脚本文件过程发生错误: {}", error_msg);
handle::Handle::notice_message("config_validate::process_terminated", &error_msg);
Ok(false)
}
}
}
+10 -49
View File
@@ -3,12 +3,10 @@ use crate::{
config::PrfItem,
enhance,
utils::{dirs, help},
core::{handle, CoreManager},
};
use anyhow::{anyhow, Result};
use once_cell::sync::OnceCell;
use std::path::PathBuf;
use tokio::time::{sleep, Duration};
pub const RUNTIME_CONFIG: &str = "clash-verge.yaml";
pub const CHECK_CONFIG: &str = "clash-verge-check.yaml";
@@ -66,57 +64,20 @@ impl Config {
let script_item = PrfItem::from_script(Some("Script".to_string()))?;
Self::profiles().data().append_item(script_item.clone())?;
}
// 生成运行时配置
crate::log_err!(Self::generate().await);
if let Err(err) = Self::generate_file(ConfigType::Run) {
log::error!(target: "app", "{err}");
// 生成运行时配置文件并验证
let config_result = Self::generate_file(ConfigType::Run);
let validation_result = if let Ok(_) = config_result {
// 验证配置文件
println!("[首次启动] 开始验证配置");
match CoreManager::global().validate_config().await {
Ok((is_valid, error_msg)) => {
if !is_valid {
println!("[首次启动] 配置验证失败,使用默认最小配置启动: {}", error_msg);
CoreManager::global()
.use_default_config("config_validate::boot_error", &error_msg)
.await?;
Some(("config_validate::boot_error", error_msg))
} else {
println!("[首次启动] 配置验证成功");
Some(("config_validate::success", String::new()))
}
}
Err(err) => {
println!("[首次启动] 验证进程执行失败: {}", err);
CoreManager::global()
.use_default_config("config_validate::process_terminated", "")
.await?;
Some(("config_validate::process_terminated", String::new()))
}
let runtime_path = dirs::app_home_dir()?.join(RUNTIME_CONFIG);
// 如果不存在就将默认的clash文件拿过来
if !runtime_path.exists() {
help::save_yaml(
&runtime_path,
&Config::clash().latest().0,
Some("# Clash Verge Runtime"),
)?;
}
} else {
println!("[首次启动] 生成配置文件失败,使用默认配置");
CoreManager::global()
.use_default_config(
"config_validate::error",
"",
)
.await?;
Some(("config_validate::error", String::new()))
};
// 在单独的任务中发送通知
if let Some((msg_type, msg_content)) = validation_result {
tauri::async_runtime::spawn(async move {
sleep(Duration::from_secs(2)).await;
handle::Handle::notice_message(msg_type, &msg_content);
});
}
Ok(())
}
+56 -356
View File
@@ -2,7 +2,7 @@ use crate::config::*;
use crate::core::{clash_api, handle, service};
use crate::core::tray::Tray;
use crate::log_err;
use crate::utils::{dirs, help};
use crate::utils::dirs;
use anyhow::{bail, Result};
use once_cell::sync::OnceCell;
use serde_yaml::Mapping;
@@ -32,6 +32,28 @@ impl CoreManager {
Ok(())
}
/// 检查订阅是否正确
pub async fn check_config(&self) -> Result<()> {
let config_path = Config::generate_file(ConfigType::Check)?;
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("verge-mihomo".into());
let test_dir = dirs::app_home_dir()?.join("test");
let test_dir = dirs::path_to_str(&test_dir)?;
let app_handle = handle::Handle::global().app_handle().unwrap();
let _ = app_handle
.shell()
.sidecar(clash_core)?
.args(["-t", "-d", test_dir, "-f", config_path])
.output()
.await?;
Ok(())
}
/// 停止核心运行
pub async fn stop_core(&self) -> Result<()> {
let mut running = self.running.lock().await;
@@ -90,23 +112,6 @@ impl CoreManager {
Ok(())
}
/// 使用默认配置
pub async fn use_default_config(&self, msg_type: &str, msg_content: &str) -> Result<()> {
let runtime_path = dirs::app_home_dir()?.join(RUNTIME_CONFIG);
*Config::runtime().draft() = IRuntime {
config: Some(Config::clash().latest().0.clone()),
exists_keys: vec![],
chain_logs: Default::default(),
};
help::save_yaml(
&runtime_path,
&Config::clash().latest().0,
Some("# Clash Verge Runtime"),
)?;
handle::Handle::notice_message(msg_type, msg_content);
Ok(())
}
/// 切换核心
pub async fn change_core(&self, clash_core: Option<String>) -> Result<()> {
let clash_core = clash_core.ok_or(anyhow::anyhow!("clash core is null"))?;
@@ -117,362 +122,57 @@ impl CoreManager {
}
log::info!(target: "app", "change core to `{clash_core}`");
// 1. 先更新内核配置(但不应用)
Config::verge().draft().clash_core = Some(clash_core);
// 2. 使用新内核验证配置
println!("[切换内核] 使用新内核验证配置");
match self.validate_config().await {
Ok((true, _)) => {
println!("[切换内核] 配置验证通过,开始切换内核");
// 3. 验证通过后,应用内核配置并重启
// 更新订阅
Config::generate().await?;
self.check_config().await?;
match self.restart_core().await {
Ok(_) => {
Config::verge().apply();
Config::runtime().apply();
log_err!(Config::verge().latest().save_file());
match self.restart_core().await {
Ok(_) => {
println!("[切换内核] 内核切换成功");
Config::runtime().apply();
Ok(())
}
Err(err) => {
println!("[切换内核] 内核切换失败: {}", err);
Config::verge().discard();
Config::runtime().discard();
Err(err)
}
}
}
Ok((false, error_msg)) => {
println!("[切换内核] 配置验证失败: {}", error_msg);
// 使用默认配置并继续切换内核
self.use_default_config("config_validate::core_change", &error_msg).await?;
Config::verge().apply();
log_err!(Config::verge().latest().save_file());
match self.restart_core().await {
Ok(_) => {
println!("[切换内核] 内核切换成功(使用默认配置)");
Ok(())
}
Err(err) => {
println!("[切换内核] 内核切换失败: {}", err);
Config::verge().discard();
Err(err)
}
}
Ok(())
}
Err(err) => {
println!("[切换内核] 验证过程发生错误: {}", err);
Config::verge().discard();
Config::runtime().discard();
Err(err)
}
}
}
/// 内部验证配置文件的实现
async fn validate_config_internal(&self, config_path: &str) -> Result<(bool, String)> {
println!("[core配置验证] 开始验证配置文件: {}", config_path);
let clash_core = { Config::verge().latest().clash_core.clone() };
let clash_core = clash_core.unwrap_or("verge-mihomo".into());
println!("[core配置验证] 使用内核: {}", clash_core);
let app_handle = handle::Handle::global().app_handle().unwrap();
let test_dir = dirs::app_home_dir()?.join("test");
let test_dir = dirs::path_to_str(&test_dir)?;
println!("[core配置验证] 测试目录: {}", test_dir);
// 使用子进程运行clash验证配置
println!("[core配置验证] 运行子进程验证配置");
let output = app_handle
.shell()
.sidecar(clash_core)?
.args(["-t", "-d", test_dir, "-f", config_path])
.output()
.await?;
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
// 检查进程退出状态和错误输出
let error_keywords = ["FATA", "fatal", "Parse config error", "level=fatal"];
let has_error = !output.status.success() || error_keywords.iter().any(|&kw| stderr.contains(kw));
println!("\n[core配置验证] -------- 验证结果 --------");
println!("[core配置验证] 进程退出状态: {:?}", output.status);
if !stderr.is_empty() {
println!("[core配置验证] stderr输出:\n{}", stderr);
}
if !stdout.is_empty() {
println!("[core配置验证] stdout输出:\n{}", stdout);
}
if has_error {
println!("[core配置验证] 发现错误,开始处理错误信息");
let error_msg = if !stdout.is_empty() {
stdout.to_string()
} else if !stderr.is_empty() {
stderr.to_string()
} else if let Some(code) = output.status.code() {
format!("验证进程异常退出,退出码: {}", code)
} else {
"验证进程被终止".to_string()
};
println!("[core配置验证] -------- 验证结束 --------\n");
Ok((false, error_msg)) // 返回错误消息给调用者处理
} else {
println!("[core配置验证] 验证成功");
println!("[core配置验证] -------- 验证结束 --------\n");
Ok((true, String::new()))
}
}
/// 验证运行时配置
pub async fn validate_config(&self) -> Result<(bool, String)> {
let config_path = Config::generate_file(ConfigType::Check)?;
let config_path = dirs::path_to_str(&config_path)?;
self.validate_config_internal(config_path).await
}
/// 验证指定的配置文件
pub async fn validate_config_file(&self, config_path: &str) -> Result<(bool, String)> {
// 检查文件是否存在
if !std::path::Path::new(config_path).exists() {
let error_msg = format!("File not found: {}", config_path);
//handle::Handle::notice_message("config_validate::file_not_found", &error_msg);
return Ok((false, error_msg));
}
// 检查是否为脚本文件
let is_script = if config_path.ends_with(".js") {
true
} else {
match self.is_script_file(config_path) {
Ok(result) => result,
Err(err) => {
// 如果无法确定文件类型,尝试使用Clash内核验证
log::warn!(target: "app", "无法确定文件类型: {}, 错误: {}", config_path, err);
return self.validate_config_internal(config_path).await;
}
}
};
if is_script {
log::info!(target: "app", "检测到脚本文件,使用JavaScript验证: {}", config_path);
return self.validate_script_file(config_path).await;
}
// 对YAML配置文件使用Clash内核验证
log::info!(target: "app", "使用Clash内核验证配置文件: {}", config_path);
self.validate_config_internal(config_path).await
}
/// 检查文件是否为脚本文件
fn is_script_file(&self, path: &str) -> Result<bool> {
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(err) => {
log::warn!(target: "app", "无法读取文件以检测类型: {}, 错误: {}", path, err);
return Err(anyhow::anyhow!("Failed to read file to detect type: {}", err));
}
};
// 检查文件前几行是否包含JavaScript特征
let first_lines = content.lines().take(5).collect::<String>();
Ok(first_lines.contains("function") ||
first_lines.contains("//") ||
first_lines.contains("/*") ||
first_lines.contains("import") ||
first_lines.contains("export") ||
first_lines.contains("const ") ||
first_lines.contains("let "))
}
/// 验证脚本文件语法
async fn validate_script_file(&self, path: &str) -> Result<(bool, String)> {
// 读取脚本内容
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(err) => {
let error_msg = format!("Failed to read script file: {}", err);
//handle::Handle::notice_message("config_validate::script_error", &error_msg);
return Ok((false, error_msg));
}
};
log::debug!(target: "app", "验证脚本文件: {}", path);
// 使用boa引擎进行基本语法检查
use boa_engine::{Context, Source};
let mut context = Context::default();
let result = context.eval(Source::from_bytes(&content));
match result {
Ok(_) => {
log::debug!(target: "app", "脚本语法验证通过: {}", path);
// 检查脚本是否包含main函数
if !content.contains("function main") && !content.contains("const main") && !content.contains("let main") {
let error_msg = "Script must contain a main function";
log::warn!(target: "app", "脚本缺少main函数: {}", path);
//handle::Handle::notice_message("config_validate::script_missing_main", error_msg);
return Ok((false, error_msg.to_string()));
}
Ok((true, String::new()))
},
Err(err) => {
let error_msg = format!("Script syntax error: {}", err);
log::warn!(target: "app", "脚本语法错误: {}", err);
//handle::Handle::notice_message("config_validate::script_syntax_error", &error_msg);
Ok((false, error_msg))
}
}
}
/// 更新proxies等配置
pub async fn update_config(&self) -> Result<(bool, String)> {
println!("[core配置更新] 开始更新配置");
// 1. 先生成新的配置内容
println!("[core配置更新] 生成新的配置内容");
/// 更新proxies那些
/// 如果涉及端口和外部控制则需要重启
pub async fn update_config(&self) -> Result<()> {
log::debug!(target: "app", "try to update clash config");
// 更新订阅
Config::generate().await?;
// 2. 生成临时文件并进行验证
println!("[core配置更新] 生成临时配置文件用于验证");
let temp_config = Config::generate_file(ConfigType::Check)?;
let temp_config = dirs::path_to_str(&temp_config)?;
println!("[core配置更新] 临时配置文件路径: {}", temp_config);
// 3. 验证配置
match self.validate_config().await {
Ok((true, _)) => {
println!("[core配置更新] 配置验证通过");
// 4. 验证通过后,生成正式的运行时配置
println!("[core配置更新] 生成运行时配置");
let run_path = Config::generate_file(ConfigType::Run)?;
let run_path = dirs::path_to_str(&run_path)?;
// 检查订阅是否正常
self.check_config().await?;
// 5. 应用新配置
println!("[core配置更新] 应用新配置");
for i in 0..3 {
match clash_api::put_configs(run_path).await {
Ok(_) => {
println!("[core配置更新] 配置应用成功");
Config::runtime().apply();
return Ok((true, String::new()));
}
Err(err) => {
if i < 2 {
println!("[core配置更新] 第{}次重试应用配置", i + 1);
log::info!(target: "app", "{err}");
sleep(Duration::from_millis(100)).await;
} else {
println!("[core配置更新] 配置应用失败: {}", err);
Config::runtime().discard();
return Ok((false, err.to_string()));
}
}
// 更新运行时订阅
let path = Config::generate_file(ConfigType::Run)?;
let path = dirs::path_to_str(&path)?;
// 发送请求 发送5次
for i in 0..10 {
match clash_api::put_configs(path).await {
Ok(_) => break,
Err(err) => {
if i < 9 {
log::info!(target: "app", "{err}");
} else {
bail!(err);
}
}
Ok((true, String::new()))
}
Ok((false, error_msg)) => {
println!("[core配置更新] 配置验证失败: {}", error_msg);
Config::runtime().discard();
Ok((false, error_msg))
}
Err(e) => {
println!("[core配置更新] 验证过程发生错误: {}", e);
Config::runtime().discard();
Err(e)
}
sleep(Duration::from_millis(100)).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
async fn create_test_script() -> Result<String> {
let temp_dir = std::env::temp_dir();
let script_path = temp_dir.join("test_script.js");
let script_content = r#"
// This is a test script
function main(config) {
console.log("Testing script");
return config;
}
"#;
fs::write(&script_path, script_content)?;
Ok(script_path.to_string_lossy().to_string())
}
async fn create_invalid_script() -> Result<String> {
let temp_dir = std::env::temp_dir();
let script_path = temp_dir.join("invalid_script.js");
let script_content = r#"
// This is an invalid script
function main(config { // Missing closing parenthesis
console.log("Testing script");
return config;
}
"#;
fs::write(&script_path, script_content)?;
Ok(script_path.to_string_lossy().to_string())
}
async fn create_no_main_script() -> Result<String> {
let temp_dir = std::env::temp_dir();
let script_path = temp_dir.join("no_main_script.js");
let script_content = r#"
// This script has no main function
function helper(config) {
console.log("Testing script");
return config;
}
"#;
fs::write(&script_path, script_content)?;
Ok(script_path.to_string_lossy().to_string())
}
#[tokio::test]
async fn test_validate_script_file() -> Result<()> {
let core_manager = CoreManager::global();
// 测试有效脚本
let script_path = create_test_script().await?;
let result = core_manager.validate_config_file(&script_path).await?;
assert!(result.0, "有效脚本应该通过验证");
// 测试无效脚本
let invalid_script_path = create_invalid_script().await?;
let result = core_manager.validate_config_file(&invalid_script_path).await?;
assert!(!result.0, "无效脚本不应该通过验证");
assert!(result.1.contains("脚本语法错误"), "无效脚本应该返回语法错误");
// 测试缺少main函数的脚本
let no_main_script_path = create_no_main_script().await?;
let result = core_manager.validate_config_file(&no_main_script_path).await?;
assert!(!result.0, "缺少main函数的脚本不应该通过验证");
assert!(result.1.contains("缺少main函数"), "应该提示缺少main函数");
// 清理测试文件
let _ = fs::remove_file(script_path);
let _ = fs::remove_file(invalid_script_path);
let _ = fs::remove_file(no_main_script_path);
Ok(())
}
}
+59
View File
@@ -0,0 +1,59 @@
#[cfg(not(target_os = "macos"))]
use anyhow::{bail, Result};
#[cfg(target_os = "macos")]
use anyhow::Result;
use sysinfo::{Pid, System};
#[cfg(not(target_os = "macos"))]
use crate::config::Config;
use crate::core::service;
#[cfg(not(target_os = "macos"))]
use port_scanner::local_port_available;
#[derive(Debug, Clone)]
pub struct HealthChecker;
impl HealthChecker {
pub fn new() -> Self {
Self
}
#[cfg(not(target_os = "macos"))]
pub async fn check_ports(&self) -> Result<()> {
let verge = Config::verge();
let verge_config = verge.latest();
let mixed_port = verge_config.verge_mixed_port.unwrap_or(7897);
let socks_port = verge_config.verge_socks_port.unwrap_or(7890);
let http_port = verge_config.verge_port.unwrap_or(7891);
if !local_port_available(mixed_port) {
bail!("Mixed port {} is already in use", mixed_port);
}
if verge_config.verge_socks_enabled.unwrap_or(true) && !local_port_available(socks_port) {
bail!("Socks port {} is already in use", socks_port);
}
if verge_config.verge_http_enabled.unwrap_or(true) && !local_port_available(http_port) {
bail!("Http port {} is already in use", http_port);
}
Ok(())
}
pub async fn check_service_health(&self) -> Result<()> {
if let Ok(response) = service::check_service().await {
if let Some(body) = response.data {
let sys = System::new_all();
if let Ok(pid) = body.bin_path.parse::<u32>() {
if let Some(process) = sys.process(Pid::from(pid as usize)) {
if !process.name().to_string_lossy().contains("mihomo") {
log::warn!(target: "app", "Found non-mihomo process using service port");
return Ok(());
}
}
}
}
}
Ok(())
}
}
+170
View File
@@ -0,0 +1,170 @@
use anyhow::{bail, Result};
use std::fs;
use std::path::PathBuf;
use sysinfo::{Pid, System, Signal};
use crate::utils::dirs;
use std::time::Duration;
const KILL_WAIT: Duration = Duration::from_millis(500);
const FINAL_WAIT: Duration = Duration::from_millis(1000);
#[derive(Debug)]
pub struct ProcessLock {
pid_file: PathBuf,
}
impl ProcessLock {
pub fn new() -> Result<Self> {
let pid_file = dirs::app_home_dir()?.join("mihomo.pid");
println!("Creating ProcessLock with PID file: {:?}", pid_file);
log::info!(target: "app", "Creating ProcessLock with PID file: {:?}", pid_file);
Ok(Self { pid_file })
}
fn is_target_process(process_name: &str) -> bool {
let name = process_name.to_lowercase();
(name.contains("mihomo") || name.contains("clash"))
&& !name.contains("clash-verge")
&& !name.contains("clash.meta")
}
fn kill_process(pid: Pid, process: &sysinfo::Process) -> bool {
let process_name = process.name().to_string_lossy().to_lowercase();
let process_pid = pid.as_u32();
println!("Force killing process (PID: {}, Name: {})", process_pid, process_name);
log::info!(target: "app", "Force killing process (PID: {}, Name: {})", process_pid, process_name);
// 直接使用 SIGKILL 强制终止
#[cfg(not(target_os = "windows"))]
{
println!("Sending SIGKILL to process {}", process_pid);
log::info!(target: "app", "Sending SIGKILL to process {}", process_pid);
let _ = process.kill_with(Signal::Kill);
}
#[cfg(target_os = "windows")]
{
println!("Force killing process {}", process_pid);
log::info!(target: "app", "Force killing process {}", process_pid);
process.kill();
}
std::thread::sleep(KILL_WAIT);
// 检查进程是否还在运行
let mut sys = System::new();
sys.refresh_all();
let is_terminated = sys.process(pid).is_none();
if !is_terminated {
println!("Failed to terminate process {}", process_pid);
log::error!(target: "app", "Failed to terminate process {}", process_pid);
} else {
println!("Process {} has been terminated", process_pid);
log::info!(target: "app", "Process {} has been terminated", process_pid);
}
is_terminated
}
fn kill_other_processes(include_current: bool) -> Result<()> {
println!("Starting process cleanup (include_current: {})", include_current);
log::info!(target: "app", "Starting process cleanup (include_current: {})", include_current);
let mut sys = System::new();
sys.refresh_all();
let current_pid = std::process::id();
let mut failed_kills = Vec::new();
// 收集需要终止的进程
let target_processes: Vec<_> = sys.processes()
.iter()
.filter(|(pid, process)| {
let process_pid = pid.as_u32();
let process_name = process.name().to_string_lossy();
Self::is_target_process(&process_name) &&
(include_current || process_pid != current_pid)
})
.collect();
// 如果没有目标进程,直接返回
if target_processes.is_empty() {
println!("No target processes found");
log::info!(target: "app", "No target processes found");
return Ok(());
}
// 终止进程
for (pid, process) in target_processes {
if !Self::kill_process(*pid, process) {
failed_kills.push((pid.as_u32(), process.name().to_string_lossy().to_string()));
}
}
// 最终检查
std::thread::sleep(FINAL_WAIT);
sys.refresh_all();
let remaining: Vec<_> = sys.processes()
.iter()
.filter(|(_, process)| Self::is_target_process(&process.name().to_string_lossy()))
.map(|(pid, process)| (pid.as_u32(), process.name().to_string_lossy().to_string()))
.collect();
if !remaining.is_empty() {
println!("Failed to terminate processes: {:?}", remaining);
log::error!(target: "app", "Failed to terminate processes: {:?}", remaining);
bail!("Failed to terminate processes: {:?}", remaining);
}
println!("Process cleanup completed");
log::info!(target: "app", "Process cleanup completed");
Ok(())
}
pub fn acquire(&self) -> Result<()> {
println!("Attempting to acquire process lock");
log::info!(target: "app", "Attempting to acquire process lock");
// 首先尝试终止其他进程
Self::kill_other_processes(false)?;
if self.pid_file.exists() {
fs::remove_file(&self.pid_file)?;
}
fs::write(&self.pid_file, std::process::id().to_string())?;
println!("Process lock acquired successfully");
log::info!(target: "app", "Process lock acquired successfully");
Ok(())
}
pub fn release(&self) -> Result<()> {
println!("Starting release process");
log::info!(target: "app", "Starting release process");
Self::kill_other_processes(true)?;
if self.pid_file.exists() {
println!("Removing PID file");
log::info!(target: "app", "Removing PID file");
fs::remove_file(&self.pid_file)?;
}
println!("Release process completed");
log::info!(target: "app", "Release process completed");
Ok(())
}
}
impl Drop for ProcessLock {
fn drop(&mut self) {
if self.pid_file.exists() {
println!("ProcessLock being dropped");
log::info!(target: "app", "ProcessLock being dropped");
let _ = self.release();
}
}
}
+1 -12
View File
@@ -1,6 +1,5 @@
use crate::config::Config;
use crate::feat;
use crate::core::CoreManager;
use anyhow::{Context, Result};
use delay_timer::prelude::{DelayTimer, DelayTimerBuilder, TaskBuilder};
use once_cell::sync::OnceCell;
@@ -173,17 +172,7 @@ impl Timer {
/// the task runner
async fn async_task(uid: String) {
log::info!(target: "app", "running timer task `{uid}`");
// 使用更轻量级的更新方式
if let Err(e) = feat::update_profile(uid.clone(), None).await {
log::error!(target: "app", "timer task update error: {}", e);
return;
}
// 只有更新成功后才刷新配置
if let Err(e) = CoreManager::global().update_config().await {
log::error!(target: "app", "timer task refresh error: {}", e);
}
crate::log_err!(feat::update_profile(uid, None).await);
}
}
+53 -36
View File
@@ -68,47 +68,64 @@ impl Tray {
pub fn create_systray(&self) -> Result<()> {
let app_handle = handle::Handle::global().app_handle().unwrap();
let tray_incon_id = TrayIconId::new("main");
let tray = app_handle.tray_by_id(&tray_incon_id).unwrap();
#[cfg(target_os = "macos")]
tray.set_show_menu_on_left_click(false)?;
// 创建初始菜单
let initial_menu = create_tray_menu(
&app_handle,
Some("rule"),
false,
false,
Vec::new(),
)?;
tray.on_tray_icon_event(|_, event| {
let tray_event = { Config::verge().latest().tray_event.clone() };
let tray_event: String = tray_event.unwrap_or("main_window".into());
// 使用 TrayIconBuilder 构建托盘
let _tray = tauri::tray::TrayIconBuilder::<Wry>::with_id(TrayIconId::new("main"))
.menu(&initial_menu) // 先设置菜单
.show_menu_on_left_click(false) // 再禁用左键菜单
.on_tray_icon_event(move |_tray, event| {
// 处理左键点击事件
if let TrayIconEvent::Click {
button,
button_state: MouseButtonState::Down,
..
} = event
{
match button {
// 左键点击
MouseButton::Left => {
#[cfg(target_os = "macos")]
{
// 确保菜单不会显示
let _ = _tray.set_show_menu_on_left_click(false);
}
#[cfg(target_os = "macos")]
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Down,
..
} = event
{
match tray_event.as_str() {
"system_proxy" => feat::toggle_system_proxy(),
"tun_mode" => feat::toggle_tun_mode(),
"main_window" => resolve::create_window(),
_ => {}
// 获取并执行自定义事件
let tray_event = Config::verge()
.latest()
.tray_event
.clone()
.unwrap_or("main_window".into());
match tray_event.as_str() {
"system_proxy" => feat::toggle_system_proxy(),
"tun_mode" => feat::toggle_tun_mode(),
"main_window" => resolve::create_window(),
_ => {}
}
}
// 其他按钮点击不处理
_ => {}
}
}
}
})
.on_menu_event(on_menu_event)
.build(&app_handle)?;
// 初始化托盘状态
self.update_menu()?;
self.update_icon(None)?;
self.update_tooltip()?;
#[cfg(not(target_os = "macos"))]
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Down,
..
} = event
{
match tray_event.as_str() {
"system_proxy" => feat::toggle_system_proxy(),
"tun_mode" => feat::toggle_tun_mode(),
"main_window" => resolve::create_window(),
_ => {}
}
}
});
tray.on_menu_event(on_menu_event);
Ok(())
}
+20 -68
View File
@@ -13,46 +13,22 @@ use tokio_tungstenite::tungstenite::Message;
#[derive(Debug, Clone)]
pub struct SpeedRate {
rate: Arc<Mutex<(Rate, Rate)>>,
last_update: Arc<Mutex<std::time::Instant>>,
base_image: Arc<Mutex<Option<(ImageBuffer<Rgba<u8>, Vec<u8>>, u32, u32)>>>, // 存储基础图像和尺寸
}
impl SpeedRate {
pub fn new() -> Self {
Self {
rate: Arc::new(Mutex::new((Rate::default(), Rate::default()))),
last_update: Arc::new(Mutex::new(std::time::Instant::now())),
base_image: Arc::new(Mutex::new(None)),
}
}
pub fn update_and_check_changed(&self, up: u64, down: u64) -> Option<Rate> {
let mut rates = self.rate.lock();
let mut last_update = self.last_update.lock();
let now = std::time::Instant::now();
// 限制更新频率为每秒最多2次(500ms)
if now.duration_since(*last_update).as_millis() < 500 {
return None;
}
let (current, previous) = &mut *rates;
// 如果速率变化不大(小于10%),则不更新
let should_update = {
let up_change = (current.up as f64 - up as f64).abs() / (current.up as f64 + 1.0);
let down_change = (current.down as f64 - down as f64).abs() / (current.down as f64 + 1.0);
up_change > 0.1 || down_change > 0.1
};
if !should_update {
return None;
}
*previous = current.clone();
current.up = up;
current.down = down;
*last_update = now;
if previous != current {
Some(current.clone())
@@ -69,27 +45,9 @@ impl SpeedRate {
pub fn add_speed_text(icon: Vec<u8>, rate: Option<Rate>) -> Result<Vec<u8>> {
let rate = rate.unwrap_or(Rate { up: 0, down: 0 });
// 获取或创建基础图像
let base_image = {
let tray = Self::global();
let mut base = tray.base_image.lock();
if base.is_none() {
let img = image::load_from_memory(&icon)?;
let (width, height) = (img.width(), img.height());
let icon_text_gap = 10;
let max_text_width = 510.0;
let total_width = width as f32 + icon_text_gap as f32 + max_text_width;
let mut image = ImageBuffer::new(total_width.ceil() as u32, height);
image::imageops::replace(&mut image, &img, 0_i64, 0_i64);
*base = Some((image, width, height));
}
base.clone().unwrap()
};
let img = image::load_from_memory(&icon)?;
let (width, height) = (img.width(), img.height());
let (mut image, width, height) = base_image;
let font =
Font::try_from_bytes(include_bytes!("../../../assets/fonts/FiraCode-Medium.ttf")).unwrap();
@@ -102,7 +60,7 @@ impl SpeedRate {
let up_text = format_bytes_speed(rate.up);
let down_text = format_bytes_speed(rate.down);
// 计算文本宽度
// 计算文本位置(保持不变)
let up_width = font
.layout(&up_text, scale, rusttype::Point { x: 0.0, y: 0.0 })
.map(|g| g.position().x + g.unpositioned().h_metrics().advance_width)
@@ -115,32 +73,31 @@ impl SpeedRate {
.last()
.unwrap_or(0.0);
let icon_text_gap = 10;
let max_text_width: f32 = 510.0;
let text_area_start = width as i32 + icon_text_gap;
let icon_text_gap = 40; // 图标和文字之间的间隔
// 用透明色清除文字区域
for x in text_area_start..image.width() as i32 {
for y in 0..image.height() as i32 {
image.put_pixel(x as u32, y as u32, Rgba([0, 0, 0, 0]));
}
}
// 计算所需的总宽度:图标宽度 + 间隔 + 最大文本宽度
let max_text_width = up_width.max(down_width);
let total_width = width as f32 + icon_text_gap as f32 + max_text_width;
let mut image = ImageBuffer::new(total_width.ceil() as u32, height);
// 将图标绘制在最左边
image::imageops::replace(&mut image, &img, 0_i64, 0_i64);
// 计算文字的起始x坐标,使文字右对齐
let text_start_x_up = (width as f32 + icon_text_gap as f32 + max_text_width - up_width).max(width as f32 + icon_text_gap as f32) as i32;
let text_start_x_down = (width as f32 + icon_text_gap as f32 + max_text_width - down_width).max(width as f32 + icon_text_gap as f32) as i32;
// 计算文字的起始x坐标(图标宽度 + 间隔)
let text_start_x = width as i32 + icon_text_gap as i32;
// 添加阴影效果
let shadow_offset = 1;
// 计算垂直位置
let up_y = 0; // 上行速率紧贴顶部
let down_y = height as i32 - base_size as i32; // 下行速率紧贴底部
let shadow_offset = 1;
// 绘制上行速率(先画阴影,再画文字)
draw_text_mut(
&mut image,
shadow_color,
text_start_x_up + shadow_offset,
text_start_x + shadow_offset,
up_y + shadow_offset,
scale,
&font,
@@ -149,7 +106,7 @@ impl SpeedRate {
draw_text_mut(
&mut image,
text_color,
text_start_x_up,
text_start_x,
up_y,
scale,
&font,
@@ -160,7 +117,7 @@ impl SpeedRate {
draw_text_mut(
&mut image,
shadow_color,
text_start_x_down + shadow_offset,
text_start_x + shadow_offset,
down_y + shadow_offset,
scale,
&font,
@@ -169,7 +126,7 @@ impl SpeedRate {
draw_text_mut(
&mut image,
text_color,
text_start_x_down,
text_start_x,
down_y,
scale,
&font,
@@ -181,11 +138,6 @@ impl SpeedRate {
image.write_to(&mut cursor, image::ImageFormat::Png)?;
Ok(bytes)
}
pub fn global() -> &'static SpeedRate {
static INSTANCE: once_cell::sync::OnceCell<SpeedRate> = once_cell::sync::OnceCell::new();
INSTANCE.get_or_init(SpeedRate::new)
}
}
#[derive(Debug, Clone)]
+42 -125
View File
@@ -1,138 +1,55 @@
use serde::{Deserialize, Serialize};
use serde_yaml::{Mapping, Sequence, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SeqMap {
pub prepend: Sequence,
pub append: Sequence,
pub delete: Vec<String>,
prepend: Sequence,
append: Sequence,
delete: Sequence,
}
pub fn use_seq(seq: SeqMap, mut config: Mapping, field: &str) -> Mapping {
let SeqMap {
prepend,
append,
delete,
} = seq;
pub fn use_seq(seq_map: SeqMap, config: Mapping, name: &str) -> Mapping {
let mut prepend = seq_map.prepend;
let append = seq_map.append;
let delete = seq_map.delete;
let mut new_seq = Sequence::new();
new_seq.extend(prepend);
let origin_seq = config.get(name).map_or(Sequence::default(), |val| {
val.as_sequence().unwrap_or(&Sequence::default()).clone()
});
let mut seq = origin_seq.clone();
if let Some(Value::Sequence(origin)) = config.get(field) {
// Filter out deleted items
let filtered: Sequence = origin
.iter()
.filter(|item| {
if let Value::String(s) = item {
!delete.contains(s)
} else if let Value::Mapping(m) = item {
if let Some(Value::String(name)) = m.get("name") {
!delete.contains(name)
} else {
true
}
} else {
true
}
})
.cloned()
.collect();
new_seq.extend(filtered);
}
new_seq.extend(append);
config.insert(Value::String(field.into()), Value::Sequence(new_seq));
// If this is proxies field, we also need to filter proxy-groups
if field == "proxies" {
if let Some(Value::Sequence(groups)) = config.get_mut("proxy-groups") {
let mut new_groups = Sequence::new();
for group in groups {
if let Value::Mapping(group_map) = group {
let mut new_group = group_map.clone();
if let Some(Value::Sequence(proxies)) = group_map.get("proxies") {
let filtered_proxies: Sequence = proxies
.iter()
.filter(|p| {
if let Value::String(name) = p {
!delete.contains(name)
} else {
true
}
})
.cloned()
.collect();
new_group.insert(
Value::String("proxies".into()),
Value::Sequence(filtered_proxies),
);
}
new_groups.push(Value::Mapping(new_group));
} else {
new_groups.push(group.clone());
}
}
config.insert(
Value::String("proxy-groups".into()),
Value::Sequence(new_groups),
);
let mut delete_names = Vec::new();
for item in delete {
let item = item.clone();
if let Some(name) = if item.is_string() {
Some(item)
} else {
item.get("name").cloned()
} {
delete_names.push(name.clone());
}
}
seq.retain(|x| {
if let Some(x_name) = if x.is_string() {
Some(x)
} else {
x.get("name")
} {
!delete_names.contains(x_name)
} else {
true
}
});
prepend.reverse();
for item in prepend {
seq.insert(0, item);
}
for item in append {
seq.push(item);
}
let mut config = config.clone();
config.insert(Value::from(name), Value::from(seq));
config
}
#[cfg(test)]
mod tests {
use super::*;
use serde_yaml::Value;
#[test]
fn test_delete_proxy_and_references() {
let config_str = r#"
proxies:
- name: "proxy1"
type: "ss"
- name: "proxy2"
type: "vmess"
proxy-groups:
- name: "group1"
type: "select"
proxies:
- "proxy1"
- "proxy2"
- name: "group2"
type: "select"
proxies:
- "proxy1"
"#;
let mut config: Mapping = serde_yaml::from_str(config_str).unwrap();
let seq = SeqMap {
prepend: Sequence::new(),
append: Sequence::new(),
delete: vec!["proxy1".to_string()],
};
config = use_seq(seq, config, "proxies");
// Check if proxy1 is removed from proxies
let proxies = config.get("proxies").unwrap().as_sequence().unwrap();
assert_eq!(proxies.len(), 1);
assert_eq!(
proxies[0].as_mapping().unwrap().get("name").unwrap().as_str().unwrap(),
"proxy2"
);
// Check if proxy1 is removed from all groups
let groups = config.get("proxy-groups").unwrap().as_sequence().unwrap();
let group1_proxies = groups[0].as_mapping().unwrap()
.get("proxies").unwrap().as_sequence().unwrap();
let group2_proxies = groups[1].as_mapping().unwrap()
.get("proxies").unwrap().as_sequence().unwrap();
assert_eq!(group1_proxies.len(), 1);
assert_eq!(group1_proxies[0].as_str().unwrap(), "proxy2");
assert_eq!(group2_proxies.len(), 0);
}
}
+9 -57
View File
@@ -17,7 +17,6 @@ use std::fs;
use tauri::Manager;
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
use std::env;
// 打开面板
#[allow(dead_code)]
@@ -85,40 +84,11 @@ pub fn restart_app() {
resolve::resolve_reset();
let app_handle = handle::Handle::global().app_handle().unwrap();
std::thread::sleep(std::time::Duration::from_secs(1));
let _ = app_handle.save_window_state(StateFlags::default());
tauri::process::restart(&app_handle.env());
});
}
/// 设置窗口状态监控,实时保存窗口位置和大小
pub fn setup_window_state_monitor(app_handle: &tauri::AppHandle) {
let window = app_handle.get_webview_window("main").unwrap();
let app_handle_clone = app_handle.clone();
// 监听窗口移动事件
let app_handle_move = app_handle_clone.clone();
window.on_window_event(move |event| {
match event {
// 窗口移动时保存状态
tauri::WindowEvent::Moved(_) => {
let _ = app_handle_move.save_window_state(StateFlags::all());
},
// 窗口调整大小时保存状态
tauri::WindowEvent::Resized(_) => {
let _ = app_handle_move.save_window_state(StateFlags::all());
},
// 其他可能改变窗口状态的事件
tauri::WindowEvent::ScaleFactorChanged { .. } => {
let _ = app_handle_move.save_window_state(StateFlags::all());
},
// 窗口关闭时保存
tauri::WindowEvent::CloseRequested { .. } => {
let _ = app_handle_move.save_window_state(StateFlags::all());
},
_ => {}
}
});
}
// 切换模式 rule/global/direct/script mode
pub fn change_clash_mode(mode: String) {
let mut mapping = Mapping::new();
@@ -163,14 +133,12 @@ pub fn toggle_system_proxy() {
// 切换代理文件
pub fn toggle_proxy_profile(profile_index: String) {
tauri::async_runtime::spawn(async move {
let app_handle = handle::Handle::global().app_handle().unwrap();
match cmds::patch_profiles_config_by_profile_index(app_handle, profile_index).await {
match cmds::patch_profiles_config_by_profile_index(profile_index).await {
Ok(_) => {
let _ = tray::Tray::global().update_menu();
}
Err(err) => {
log::error!(target: "app", "{err}");
}
handle::Handle::refresh_verge();
},
Err(err) => log::error!(target: "app", "{err}"),
}
});
}
@@ -411,8 +379,6 @@ pub async fn patch_verge(patch: IVerge) -> Result<()> {
/// 更新某个profile
/// 如果更新当前订阅就激活订阅
pub async fn update_profile(uid: String, option: Option<PrfOption>) -> Result<()> {
println!("[订阅更新] 开始更新订阅 {}", uid);
let url_opt = {
let profiles = Config::profiles();
let profiles = profiles.latest();
@@ -420,44 +386,33 @@ pub async fn update_profile(uid: String, option: Option<PrfOption>) -> Result<()
let is_remote = item.itype.as_ref().map_or(false, |s| s == "remote");
if !is_remote {
println!("[订阅更新] {} 不是远程订阅,跳过更新", uid);
None // 非远程订阅直接更新
None // 直接更新
} else if item.url.is_none() {
println!("[订阅更新] {} 缺少URL,无法更新", uid);
bail!("failed to get the profile item url");
} else {
println!("[订阅更新] {} 是远程订阅,URL: {}", uid, item.url.clone().unwrap());
Some((item.url.clone().unwrap(), item.option.clone()))
}
};
let should_update = match url_opt {
Some((url, opt)) => {
println!("[订阅更新] 开始下载新的订阅内容");
let merged_opt = PrfOption::merge(opt, option);
let item = PrfItem::from_url(&url, None, None, merged_opt).await?;
println!("[订阅更新] 更新订阅配置");
let profiles = Config::profiles();
let mut profiles = profiles.latest();
profiles.update_item(uid.clone(), item)?;
let is_current = Some(uid.clone()) == profiles.get_current();
println!("[订阅更新] 是否为当前使用的订阅: {}", is_current);
is_current
Some(uid) == profiles.get_current()
}
None => true,
};
if should_update {
println!("[订阅更新] 更新内核配置");
match CoreManager::global().update_config().await {
Ok(_) => {
println!("[订阅更新] 更新成功");
handle::Handle::refresh_clash();
}
Err(err) => {
println!("[订阅更新] 更新失败: {}", err);
handle::Handle::notice_message("set_config::error", format!("{err}"));
log::error!(target: "app", "{err}");
}
@@ -469,13 +424,10 @@ pub async fn update_profile(uid: String, option: Option<PrfOption>) -> Result<()
/// copy env variable
pub fn copy_clash_env() {
// 从环境变量获取IP地址,默认127.0.0.1
let clash_verge_rev_ip = env::var("CLASH_VERGE_REV_IP").unwrap_or_else(|_| "127.0.0.1".to_string());
let app_handle = handle::Handle::global().app_handle().unwrap();
let port = { Config::verge().latest().verge_mixed_port.unwrap_or(7897) };
let http_proxy = format!("http://{clash_verge_rev_ip}:{}", port);
let socks5_proxy = format!("socks5://{clash_verge_rev_ip}:{}", port);
let http_proxy = format!("http://127.0.0.1:{}", port);
let socks5_proxy = format!("socks5://127.0.0.1:{}", port);
let sh =
format!("export https_proxy={http_proxy} http_proxy={http_proxy} all_proxy={socks5_proxy}");
-3
View File
@@ -114,9 +114,6 @@ pub fn run() {
cmds::delete_profile,
cmds::read_profile_file,
cmds::save_profile_file,
// script validation
cmds::script_validate_notice,
cmds::validate_script_file,
// clash api
cmds::clash_api_get_proxy_delay,
// backup
-3
View File
@@ -196,9 +196,6 @@ pub fn create_window() {
log::info!(target: "app", "Window created successfully, attempting to show");
let _ = window.show();
let _ = window.set_focus();
// 设置窗口状态监控,实时保存窗口位置和大小
crate::feat::setup_window_state_monitor(&app_handle);
}
Err(e) => {
println!("Failed to create window: {:?}", e);
+2 -2
View File
@@ -16,7 +16,7 @@
"copyright": "GNU General Public License v3.0",
"category": "DeveloperTool",
"shortDescription": "Clash Verge Rev",
"createUpdaterArtifacts": true
"createUpdaterArtifacts": "v1Compatible"
},
"build": {
"beforeBuildCommand": "pnpm run web:build",
@@ -25,7 +25,7 @@
"devUrl": "http://localhost:3000/"
},
"productName": "Clash Verge",
"version": "2.1.0",
"version": "2.0.4-alpha",
"identifier": "io.github.clash-verge-rev.clash-verge-rev",
"plugins": {
"deep-link": {
+40
View File
@@ -0,0 +1,40 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"identifier": "io.github.clash-verge-rev.clash-verge-rev",
"bundle": {
"targets": ["deb", "rpm"],
"linux": {
"deb": {
"depends": ["openssl"],
"desktopTemplate": "./packages/linux/clash-verge.desktop",
"provides": ["clash-verge"],
"conflicts": ["clash-verge"],
"replaces": ["clash-verge"],
"postInstallScript": "./packages/linux/post-install.sh",
"preRemoveScript": "./packages/linux/pre-remove.sh"
},
"rpm": {
"depends": ["openssl"],
"desktopTemplate": "./packages/linux/clash-verge.desktop",
"provides": ["clash-verge"],
"conflicts": ["clash-verge"],
"obsoletes": ["clash-verge"],
"postInstallScript": "./packages/linux/post-install.sh",
"preRemoveScript": "./packages/linux/pre-remove.sh"
}
},
"externalBin": [
"./resources/clash-verge-service",
"./resources/install-service",
"./resources/uninstall-service",
"./sidecar/verge-mihomo",
"./sidecar/verge-mihomo-alpha"
]
},
"app": {
"trayIcon": {
"iconPath": "icons/tray-icon.ico",
"iconAsTemplate": true
}
}
}
+2 -1
View File
@@ -33,7 +33,8 @@
},
"app": {
"trayIcon": {
"iconPath": "icons/tray-icon.ico"
"iconPath": "icons/tray-icon.ico",
"iconAsTemplate": true
}
}
}
-8
View File
@@ -19,13 +19,5 @@
"template": "./packages/windows/installer.nsi"
}
}
},
"app": {
"trayIcon": {
"iconPath": "icons/tray-icon.ico",
"iconAsTemplate": true,
"showMenuOnLeftClick": false
},
"windows": []
}
}
+7 -37
View File
@@ -18,7 +18,7 @@ interface InnerProps {
}
const NoticeInner = (props: InnerProps) => {
const { type, message, duration, onClose } = props;
const { type, message, duration = 1500, onClose } = props;
const [visible, setVisible] = useState(true);
const [isDark, setIsDark] = useState(false);
const { verge } = useVerge();
@@ -43,7 +43,7 @@ const NoticeInner = (props: InnerProps) => {
appWindow.theme().then((m) => m && setIsDark(m === "dark"));
const unlisten = appWindow.onThemeChanged((e) =>
setIsDark(e.payload === "dark"),
setIsDark(e.payload === "dark")
);
return () => {
@@ -72,7 +72,7 @@ const NoticeInner = (props: InnerProps) => {
<Snackbar
open={visible}
anchorOrigin={{ vertical: "top", horizontal: "right" }}
autoHideDuration={duration === -1 ? null : duration || 1500}
autoHideDuration={duration}
onClose={onAutoClose}
message={msgElement}
sx={{
@@ -105,55 +105,25 @@ let parent: HTMLDivElement = null!;
// @ts-ignore
export const Notice: NoticeInstance = (props) => {
const { type, message, duration } = props;
// 验证必要的参数
if (!message) {
return;
}
if (!parent) {
parent = document.createElement("div");
parent.setAttribute("id", "notice-container"); // 添加 id 便于调试
document.body.appendChild(parent);
}
const container = document.createElement("div");
parent.appendChild(container);
const root = createRoot(container);
const onUnmount = () => {
root.unmount();
if (parent && container.parentNode === parent) {
setTimeout(() => {
parent.removeChild(container);
}, 500);
}
if (parent) setTimeout(() => parent.removeChild(container), 500);
};
root.render(
<NoticeInner
type={type}
message={message}
duration={duration || 1500}
onClose={onUnmount}
/>,
);
root.render(<NoticeInner {...props} onClose={onUnmount} />);
};
(["info", "error", "success"] as const).forEach((type) => {
Notice[type] = (message: ReactNode, duration?: number) => {
// 确保消息不为空
if (!message) {
return;
}
Notice({
type,
message,
// 错误类型通知显示 8 秒,其他类型默认 1.5 秒
duration: type === "error" ? 8000 : duration || 1500,
});
Notice[type] = (message, duration) => {
setTimeout(() => Notice({ type, message, duration }), 0);
};
});
+66 -63
View File
@@ -1,6 +1,6 @@
import { Box, SvgIcon, TextField, styled } from "@mui/material";
import Tooltip from "@mui/material/Tooltip";
import { ChangeEvent, useEffect, useRef, useState, useMemo } from "react";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import matchCaseIcon from "@/assets/image/component/match_case.svg?react";
@@ -22,20 +22,7 @@ type SearchProps = {
onSearch: (match: (content: string) => boolean, state: SearchState) => void;
};
const StyledTextField = styled(TextField)(({ theme }) => ({
"& .MuiInputBase-root": {
background: theme.palette.mode === "light" ? "#fff" : undefined,
paddingRight: "4px",
},
"& .MuiInputBase-root svg[aria-label='active'] path": {
fill: theme.palette.primary.light,
},
"& .MuiInputBase-root svg[aria-label='inactive'] path": {
fill: "#A7A7A7",
},
}));
export const BaseSearchBox = (props: SearchProps) => {
export const BaseSearchBox = styled((props: SearchProps) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [matchCase, setMatchCase] = useState(props.matchCase ?? false);
@@ -56,58 +43,58 @@ export const BaseSearchBox = (props: SearchProps) => {
inheritViewBox: true,
};
const createMatcher = useMemo(() => {
return (searchText: string) => {
try {
return (content: string) => {
if (!searchText) return true;
let item = !matchCase ? content.toLowerCase() : content;
let searchItem = !matchCase ? searchText.toLowerCase() : searchText;
if (useRegularExpression) {
return new RegExp(searchItem).test(item);
}
if (matchWholeWord) {
return new RegExp(`\\b${searchItem}\\b`).test(item);
}
return item.includes(searchItem);
};
} catch (err) {
setErrorMessage(`${err}`);
return () => true;
}
};
}, [matchCase, matchWholeWord, useRegularExpression]);
useEffect(() => {
if (!inputRef.current) return;
const value = inputRef.current.value;
setErrorMessage("");
props.onSearch(createMatcher(value), {
text: value,
matchCase,
matchWholeWord,
useRegularExpression,
});
}, [matchCase, matchWholeWord, useRegularExpression, createMatcher]);
onChange({
target: inputRef.current,
} as ChangeEvent<HTMLInputElement>);
}, [matchCase, matchWholeWord, useRegularExpression]);
const onChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target?.value ?? "";
props.onSearch(
(content) => doSearch([content], e.target?.value ?? "").length > 0,
{
text: e.target?.value ?? "",
matchCase,
matchWholeWord,
useRegularExpression,
},
);
};
const doSearch = (searchList: string[], searchItem: string) => {
setErrorMessage("");
props.onSearch(createMatcher(value), {
text: value,
matchCase,
matchWholeWord,
useRegularExpression,
return searchList.filter((item) => {
try {
let searchItemCopy = searchItem;
if (!matchCase) {
item = item.toLowerCase();
searchItemCopy = searchItemCopy.toLowerCase();
}
if (matchWholeWord) {
const regex = new RegExp(`\\b${searchItemCopy}\\b`);
if (useRegularExpression) {
const regexWithOptions = new RegExp(searchItemCopy);
return regexWithOptions.test(item) && regex.test(item);
} else {
return regex.test(item);
}
} else if (useRegularExpression) {
const regex = new RegExp(searchItemCopy);
return regex.test(item);
} else {
return item.includes(searchItemCopy);
}
} catch (err) {
setErrorMessage(`${err}`);
}
});
};
return (
<Tooltip title={errorMessage} placement="bottom-start">
<StyledTextField
<TextField
autoComplete="new-password"
inputRef={inputRef}
hiddenLabel
@@ -128,7 +115,9 @@ export const BaseSearchBox = (props: SearchProps) => {
component={matchCaseIcon}
{...iconStyle}
aria-label={matchCase ? "active" : "inactive"}
onClick={() => setMatchCase(!matchCase)}
onClick={() => {
setMatchCase(!matchCase);
}}
/>
</div>
</Tooltip>
@@ -138,7 +127,9 @@ export const BaseSearchBox = (props: SearchProps) => {
component={matchWholeWordIcon}
{...iconStyle}
aria-label={matchWholeWord ? "active" : "inactive"}
onClick={() => setMatchWholeWord(!matchWholeWord)}
onClick={() => {
setMatchWholeWord(!matchWholeWord);
}}
/>
</div>
</Tooltip>
@@ -148,16 +139,28 @@ export const BaseSearchBox = (props: SearchProps) => {
component={useRegularExpressionIcon}
aria-label={useRegularExpression ? "active" : "inactive"}
{...iconStyle}
onClick={() =>
setUseRegularExpression(!useRegularExpression)
}
onClick={() => {
setUseRegularExpression(!useRegularExpression);
}}
/>{" "}
</div>
</Tooltip>
</Box>
),
}}
{...props}
/>
</Tooltip>
);
};
})(({ theme }) => ({
"& .MuiInputBase-root": {
background: theme.palette.mode === "light" ? "#fff" : undefined,
"padding-right": "4px",
},
"& .MuiInputBase-root svg[aria-label='active'] path": {
fill: theme.palette.primary.light,
},
"& .MuiInputBase-root svg[aria-label='inactive'] path": {
fill: "#A7A7A7",
},
}));
+14 -73
View File
@@ -1,6 +1,6 @@
import dayjs from "dayjs";
import { useMemo, useState, useEffect } from "react";
import { DataGrid, GridColDef, GridColumnResizeParams } from "@mui/x-data-grid";
import { useMemo, useState } from "react";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { useThemeMode } from "@/services/states";
import { truncateStr } from "@/utils/truncate-str";
import parseTraffic from "@/utils/parse-traffic";
@@ -21,24 +21,12 @@ export const ConnectionTable = (props: Props) => {
Partial<Record<keyof IConnectionsItem, boolean>>
>({});
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(
() => {
const saved = localStorage.getItem("connection-table-widths");
return saved ? JSON.parse(saved) : {};
},
);
const [columns] = useState<GridColDef[]>([
{
field: "host",
headerName: t("Host"),
width: columnWidths["host"] || 220,
minWidth: 180,
},
{ field: "host", headerName: t("Host"), flex: 220, minWidth: 220 },
{
field: "download",
headerName: t("Downloaded"),
width: columnWidths["download"] || 88,
width: 88,
align: "right",
headerAlign: "right",
valueFormatter: (value: number) => parseTraffic(value).join(" "),
@@ -46,7 +34,7 @@ export const ConnectionTable = (props: Props) => {
{
field: "upload",
headerName: t("Uploaded"),
width: columnWidths["upload"] || 88,
width: 88,
align: "right",
headerAlign: "right",
valueFormatter: (value: number) => parseTraffic(value).join(" "),
@@ -54,7 +42,7 @@ export const ConnectionTable = (props: Props) => {
{
field: "dlSpeed",
headerName: t("DL Speed"),
width: columnWidths["dlSpeed"] || 88,
width: 88,
align: "right",
headerAlign: "right",
valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s",
@@ -62,33 +50,18 @@ export const ConnectionTable = (props: Props) => {
{
field: "ulSpeed",
headerName: t("UL Speed"),
width: columnWidths["ulSpeed"] || 88,
width: 88,
align: "right",
headerAlign: "right",
valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s",
},
{
field: "chains",
headerName: t("Chains"),
width: columnWidths["chains"] || 340,
minWidth: 180,
},
{
field: "rule",
headerName: t("Rule"),
width: columnWidths["rule"] || 280,
minWidth: 180,
},
{
field: "process",
headerName: t("Process"),
width: columnWidths["process"] || 220,
minWidth: 180,
},
{ field: "chains", headerName: t("Chains"), flex: 360, minWidth: 360 },
{ field: "rule", headerName: t("Rule"), flex: 300, minWidth: 250 },
{ field: "process", headerName: t("Process"), flex: 240, minWidth: 120 },
{
field: "time",
headerName: t("Time"),
width: columnWidths["time"] || 120,
flex: 120,
minWidth: 100,
align: "right",
headerAlign: "right",
@@ -96,43 +69,16 @@ export const ConnectionTable = (props: Props) => {
new Date(v2).getTime() - new Date(v1).getTime(),
valueFormatter: (value: number) => dayjs(value).fromNow(),
},
{
field: "source",
headerName: t("Source"),
width: columnWidths["source"] || 200,
minWidth: 130,
},
{ field: "source", headerName: t("Source"), flex: 200, minWidth: 130 },
{
field: "remoteDestination",
headerName: t("Destination"),
width: columnWidths["remoteDestination"] || 200,
flex: 200,
minWidth: 130,
},
{
field: "type",
headerName: t("Type"),
width: columnWidths["type"] || 160,
minWidth: 100,
},
{ field: "type", headerName: t("Type"), flex: 160, minWidth: 100 },
]);
useEffect(() => {
console.log("Saving column widths:", columnWidths);
localStorage.setItem(
"connection-table-widths",
JSON.stringify(columnWidths),
);
}, [columnWidths]);
const handleColumnResize = (params: GridColumnResizeParams) => {
const { colDef, width } = params;
console.log("Column resize:", colDef.field, width);
setColumnWidths((prev) => ({
...prev,
[colDef.field]: width,
}));
};
const connRows = useMemo(() => {
return connections.map((each) => {
const { metadata, rulePayload } = each;
@@ -169,14 +115,9 @@ export const ConnectionTable = (props: Props) => {
sx={{
border: "none",
"div:focus": { outline: "none !important" },
"& .MuiDataGrid-columnHeader": {
userSelect: "none",
},
}}
columnVisibilityModel={columnVisible}
onColumnVisibilityModelChange={(e) => setColumnVisible(e)}
onColumnResize={handleColumnResize}
disableColumnMenu={false}
/>
);
};
+2 -2
View File
@@ -85,9 +85,9 @@ const LogItem = ({ value, searchState }: Props) => {
return (
<Item>
<div>
<span className="time">{renderHighlightText(value.time || "")}</span>
<span className="time">{value.time}</span>
<span className="type" data-type={value.type.toLowerCase()}>
{renderHighlightText(value.type)}
{value.type}
</span>
</div>
<div>
+26 -384
View File
@@ -1,4 +1,4 @@
import { useRef, useState, useEffect, useCallback, useMemo } from "react";
import { useRef, useState, useEffect } from "react";
import { useLockFn } from "ahooks";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import {
@@ -16,173 +16,6 @@ import { ProxyRender } from "./proxy-render";
import delayManager from "@/services/delay";
import { useTranslation } from "react-i18next";
import { ScrollTopButton } from "../layout/scroll-top-button";
import { Box, styled } from "@mui/material";
import { memo } from "react";
import { createPortal } from "react-dom";
// 将选择器组件抽离出来,避免主组件重渲染时重复创建样式
const AlphabetSelector = styled(Box)(({ theme }) => ({
position: "fixed",
right: 4,
top: "50%",
transform: "translateY(-50%)",
display: "flex",
flexDirection: "column",
background: "transparent",
zIndex: 1000,
gap: "2px",
// padding: "4px 2px",
willChange: "transform",
"&:hover": {
background: theme.palette.background.paper,
boxShadow: theme.shadows[2],
borderRadius: "8px",
},
"& .scroll-container": {
overflow: "hidden",
maxHeight: "inherit",
},
"& .letter-container": {
display: "flex",
flexDirection: "column",
gap: "2px",
transition: "transform 0.2s ease",
},
"& .letter": {
padding: "1px 4px",
fontSize: "12px",
cursor: "pointer",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
color: theme.palette.text.secondary,
position: "relative",
width: "1.5em",
height: "1.5em",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)",
transform: "scale(1) translateZ(0)",
backfaceVisibility: "hidden",
borderRadius: "6px",
"&:hover": {
color: theme.palette.primary.main,
transform: "scale(1.4) translateZ(0)",
backgroundColor: theme.palette.action.hover,
},
},
}));
// 创建一个单独的 Tooltip 组件
const Tooltip = styled("div")(({ theme }) => ({
position: "fixed",
background: theme.palette.background.paper,
padding: "4px 8px",
borderRadius: "6px",
boxShadow: theme.shadows[3],
whiteSpace: "nowrap",
fontSize: "16px",
color: theme.palette.text.primary,
pointerEvents: "none",
"&::after": {
content: '""',
position: "absolute",
right: "-4px",
top: "50%",
transform: "translateY(-50%)",
width: 0,
height: 0,
borderTop: "4px solid transparent",
borderBottom: "4px solid transparent",
borderLeft: `4px solid ${theme.palette.background.paper}`,
},
}));
// 抽离字母选择器子组件
const LetterItem = memo(
({
name,
onClick,
getFirstChar,
}: {
name: string;
onClick: (name: string) => void;
getFirstChar: (str: string) => string;
}) => {
const [showTooltip, setShowTooltip] = useState(false);
const letterRef = useRef<HTMLDivElement>(null);
const [tooltipPosition, setTooltipPosition] = useState({
top: 0,
right: 0,
});
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const updateTooltipPosition = useCallback(() => {
if (!letterRef.current) return;
const rect = letterRef.current.getBoundingClientRect();
setTooltipPosition({
top: rect.top + rect.height / 2,
right: window.innerWidth - rect.left + 8,
});
}, []);
useEffect(() => {
if (showTooltip) {
updateTooltipPosition();
}
}, [showTooltip, updateTooltipPosition]);
const handleMouseEnter = useCallback(() => {
setShowTooltip(true);
// 添加 200ms 的延迟,避免鼠标快速划过时触发滚动
hoverTimeoutRef.current = setTimeout(() => {
onClick(name);
}, 100);
}, [name, onClick]);
const handleMouseLeave = useCallback(() => {
setShowTooltip(false);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
}, []);
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
};
}, []);
return (
<>
<div
ref={letterRef}
className="letter"
onClick={() => onClick(name)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<span>{getFirstChar(name)}</span>
</div>
{showTooltip &&
createPortal(
<Tooltip
style={{
top: tooltipPosition.top,
right: tooltipPosition.right,
transform: "translateY(-50%)",
}}
>
{name}
</Tooltip>,
document.body,
)}
</>
);
},
);
interface Props {
mode: string;
@@ -199,128 +32,22 @@ export const ProxyGroups = (props: Props) => {
const timeout = verge?.default_latency_timeout || 10000;
const virtuosoRef = useRef<VirtuosoHandle>(null);
const scrollPositionRef = useRef<Record<string, number>>({});
const [showScrollTop, setShowScrollTop] = useState(false);
const scrollerRef = useRef<Element | null>(null);
const letterContainerRef = useRef<HTMLDivElement>(null);
const alphabetSelectorRef = useRef<HTMLDivElement>(null);
const [maxHeight, setMaxHeight] = useState("auto");
// 使用useMemo缓存字母索引数据
const { groupFirstLetters, letterIndexMap } = useMemo(() => {
const letters = new Set<string>();
const indexMap: Record<string, number> = {};
renderList.forEach((item, index) => {
if (item.type === 0) {
const fullName = item.group.name;
letters.add(fullName);
if (!(fullName in indexMap)) {
indexMap[fullName] = index;
}
}
});
return {
groupFirstLetters: Array.from(letters),
letterIndexMap: indexMap,
};
}, [renderList]);
// 缓存getFirstChar函数
const getFirstChar = useCallback((str: string) => {
const regex = /\p{Extended_Pictographic}|\p{L}|\p{N}|./u;
const match = str.match(regex);
return match ? match[0] : str.charAt(0);
}, []);
// 从 localStorage 恢复滚动位置
useEffect(() => {
if (renderList.length === 0) return;
try {
const savedPositions = localStorage.getItem("proxy-scroll-positions");
if (savedPositions) {
const positions = JSON.parse(savedPositions);
scrollPositionRef.current = positions;
const savedPosition = positions[mode];
if (savedPosition !== undefined) {
setTimeout(() => {
virtuosoRef.current?.scrollTo({
top: savedPosition,
behavior: "auto",
});
}, 100);
}
}
} catch (e) {
console.error("Error restoring scroll position:", e);
}
}, [mode, renderList]);
// 使用防抖保存滚动位置
const saveScrollPosition = useCallback(
(scrollTop: number) => {
try {
scrollPositionRef.current[mode] = scrollTop;
localStorage.setItem(
"proxy-scroll-positions",
JSON.stringify(scrollPositionRef.current),
);
} catch (e) {
console.error("Error saving scroll position:", e);
}
},
[mode],
);
// 优化滚动处理函数,使用防抖
const handleScroll = useCallback(
debounce((e: any) => {
const scrollTop = e.target.scrollTop;
setShowScrollTop(scrollTop > 100);
saveScrollPosition(scrollTop);
}, 16),
[saveScrollPosition],
);
// 添加和清理滚动事件监听器
useEffect(() => {
const currentScroller = scrollerRef.current;
if (currentScroller) {
currentScroller.addEventListener("scroll", handleScroll, {
passive: true,
});
return () => {
currentScroller.removeEventListener("scroll", handleScroll);
};
}
}, [handleScroll]);
// 添加滚动处理函数
const handleScroll = (e: any) => {
const scrollTop = e.target.scrollTop;
setShowScrollTop(scrollTop > 100);
};
// 滚动到顶部
const scrollToTop = useCallback(() => {
const scrollToTop = () => {
virtuosoRef.current?.scrollTo?.({
top: 0,
behavior: "smooth",
});
saveScrollPosition(0);
}, [saveScrollPosition]);
// 处理字母点击,使用useCallback
const handleLetterClick = useCallback(
(name: string) => {
const index = letterIndexMap[name];
if (index !== undefined) {
virtuosoRef.current?.scrollToIndex({
index,
align: "start",
behavior: "smooth",
});
}
},
[letterIndexMap],
);
};
// 切换分组的节点代理
const handleChangeProxy = useLockFn(
@@ -407,64 +134,6 @@ export const ProxyGroups = (props: Props) => {
}
};
// 添加滚轮事件处理函数
const handleWheel = useCallback((e: WheelEvent) => {
e.preventDefault();
if (!letterContainerRef.current) return;
const container = letterContainerRef.current;
const scrollAmount = e.deltaY;
const currentTransform = new WebKitCSSMatrix(container.style.transform);
const currentY = currentTransform.m42 || 0;
const containerHeight = container.getBoundingClientRect().height;
const parentHeight =
container.parentElement?.getBoundingClientRect().height || 0;
const maxScroll = Math.max(0, containerHeight - parentHeight);
let newY = currentY - scrollAmount;
newY = Math.min(0, Math.max(-maxScroll, newY));
container.style.transform = `translateY(${newY}px)`;
}, []);
// 添加和移除滚轮事件监听
useEffect(() => {
const container = letterContainerRef.current?.parentElement;
if (container) {
container.addEventListener("wheel", handleWheel, { passive: false });
return () => {
container.removeEventListener("wheel", handleWheel);
};
}
}, [handleWheel]);
// 添加窗口大小变化监听和最大高度计算
const updateMaxHeight = useCallback(() => {
if (!alphabetSelectorRef.current) return;
const windowHeight = window.innerHeight;
const bottomMargin = 60; // 底部边距
const topMargin = bottomMargin * 2; // 顶部边距是底部的2倍
const availableHeight = windowHeight - (topMargin + bottomMargin);
// 调整选择器的位置,使其偏下
const offsetPercentage =
(((topMargin - bottomMargin) / windowHeight) * 100) / 2;
alphabetSelectorRef.current.style.top = `calc(48% + ${offsetPercentage}vh)`;
setMaxHeight(`${availableHeight}px`);
}, []);
// 监听窗口大小变化
useEffect(() => {
updateMaxHeight();
window.addEventListener("resize", updateMaxHeight);
return () => {
window.removeEventListener("resize", updateMaxHeight);
};
}, [updateMaxHeight]);
if (mode === "direct") {
return <BaseEmpty text={t("clash_mode_direct")} />;
}
@@ -473,57 +142,30 @@ export const ProxyGroups = (props: Props) => {
<div style={{ position: "relative", height: "100%" }}>
<Virtuoso
ref={virtuosoRef}
style={{ height: "calc(100% - 14px)" }}
style={{ height: "calc(100% - 16px)" }}
totalCount={renderList.length}
increaseViewportBy={{ top: 256, bottom: 256 }}
overscan={150}
defaultItemHeight={56}
increaseViewportBy={256}
scrollerRef={(ref) => {
scrollerRef.current = ref as Element;
}}
components={{
Footer: () => <div style={{ height: "8px" }} />,
if (ref) {
ref.addEventListener("scroll", handleScroll);
}
}}
itemContent={(index) => (
<ProxyRender
key={renderList[index].key}
item={renderList[index]}
indent={mode === "rule" || mode === "script"}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
<>
<ProxyRender
key={renderList[index].key}
item={renderList[index]}
indent={mode === "rule" || mode === "script"}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
</>
)}
/>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
<AlphabetSelector ref={alphabetSelectorRef} style={{ maxHeight }}>
<div className="scroll-container">
<div ref={letterContainerRef} className="letter-container">
{groupFirstLetters.map((name) => (
<LetterItem
key={name}
name={name}
onClick={handleLetterClick}
getFirstChar={getFirstChar}
/>
))}
</div>
</div>
</AlphabetSelector>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
</div>
);
};
// 简单的防抖函数
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
+11 -10
View File
@@ -100,7 +100,7 @@ export const ProxyRender = (props: RenderProps) => {
<ListItemText
primary={<StyledPrimary>{group.name}</StyledPrimary>}
secondary={
<Box
<ListItemTextChild
sx={{
overflow: "hidden",
display: "flex",
@@ -108,19 +108,16 @@ export const ProxyRender = (props: RenderProps) => {
pt: "2px",
}}
>
<Box component="span" sx={{ marginTop: "2px" }}>
<Box sx={{ marginTop: "2px" }}>
<StyledTypeBox>{group.type}</StyledTypeBox>
<StyledSubtitle sx={{ color: "text.secondary" }}>
{group.now}
</StyledSubtitle>
</Box>
</Box>
</ListItemTextChild>
}
slotProps={{
secondary: {
component: "div",
sx: { display: "flex", alignItems: "center", color: "#ccc" },
},
secondaryTypographyProps={{
sx: { display: "flex", alignItems: "center", color: "#ccc" },
}}
/>
{headState?.open ? <ExpandLessRounded /> : <ExpandMoreRounded />}
@@ -207,7 +204,7 @@ export const ProxyRender = (props: RenderProps) => {
};
const StyledPrimary = styled("span")`
font-size: 16px;
font-size: 15px;
font-weight: 700;
line-height: 1.5;
overflow: hidden;
@@ -222,7 +219,11 @@ const StyledSubtitle = styled("span")`
white-space: nowrap;
`;
const StyledTypeBox = styled(Box)(({ theme }) => ({
const ListItemTextChild = styled("span")`
display: block;
`;
const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.primary.main, 0.5),
+54 -79
View File
@@ -1,5 +1,5 @@
import useSWR from "swr";
import { useEffect, useMemo, useCallback } from "react";
import { useEffect, useMemo } from "react";
import { getProxies } from "@/services/api";
import { useVerge } from "@/hooks/use-verge";
import { filterSort } from "./use-filter-sort";
@@ -11,7 +11,7 @@ import {
} from "./use-head-state";
export interface IRenderItem {
// 组 | head | item | empty | item col
// 组 | head | item | empty | item col
type: 0 | 1 | 2 | 3 | 4;
key: string;
group: IProxyGroupItem;
@@ -19,59 +19,36 @@ export interface IRenderItem {
col?: number;
proxyCol?: IProxyItem[];
headState?: HeadState;
// 新增支持图标和其他元数据
icon?: string;
provider?: string;
testUrl?: string;
}
// 优化列布局计算
const calculateColumns = (width: number, configCol: number): number => {
if (configCol > 0 && configCol < 6) return configCol;
if (width > 1920) return 5;
if (width > 1450) return 4;
if (width > 1024) return 3;
if (width > 900) return 2;
if (width >= 600) return 2;
return 1;
};
// 优化分组逻辑
const groupProxies = <T = any>(list: T[], size: number): T[][] => {
return list.reduce((acc, item) => {
const lastGroup = acc[acc.length - 1];
if (!lastGroup || lastGroup.length >= size) {
acc.push([item]);
} else {
lastGroup.push(item);
}
return acc;
}, [] as T[][]);
};
export const useRenderList = (mode: string) => {
const { data: proxiesData, mutate: mutateProxies } = useSWR(
"getProxies",
getProxies,
{
refreshInterval: 2000,
dedupingInterval: 1000,
revalidateOnFocus: false,
revalidateOnReconnect: true,
},
);
const { verge } = useVerge();
const { width } = useWindowWidth();
let col = Math.floor(verge?.proxy_layout_column || 6);
// 自适应
if (col >= 6 || col <= 0) {
if (width > 1450) col = 4;
else if (width > 1024) col = 3;
else if (width > 900) col = 2;
else if (width >= 600) col = 2;
else col = 1;
}
const [headStates, setHeadState] = useHeadStateNew();
// 计算列数
const col = useMemo(
() => calculateColumns(width, verge?.proxy_layout_column || 6),
[width, verge?.proxy_layout_column],
);
// 确保代理数据加载
// make sure that fetch the proxies successfully
useEffect(() => {
if (!proxiesData) return;
const { groups, proxies } = proxiesData;
@@ -82,29 +59,22 @@ export const useRenderList = (mode: string) => {
) {
setTimeout(() => mutateProxies(), 500);
}
}, [proxiesData, mode, mutateProxies]);
}, [proxiesData, mode]);
// 处理渲染列表
const renderList: IRenderItem[] = useMemo(() => {
if (!proxiesData) return [];
// global 和 direct 使用展开的样式
const useRule = mode === "rule" || mode === "script";
const renderGroups =
useRule && proxiesData.groups.length
(useRule && proxiesData.groups.length
? proxiesData.groups
: [proxiesData.global!];
: [proxiesData.global!]) || [];
const retList = renderGroups.flatMap((group) => {
const headState = headStates[group.name] || DEFAULT_STATE;
const ret: IRenderItem[] = [
{
type: 0,
key: group.name,
group,
headState,
icon: group.icon,
testUrl: group.testUrl,
},
{ type: 0, key: group.name, group, headState },
];
if (headState?.open || !useRule) {
@@ -115,56 +85,61 @@ export const useRenderList = (mode: string) => {
headState.sortType,
);
ret.push({
type: 1,
key: `head-${group.name}`,
group,
headState,
});
ret.push({ type: 1, key: `head-${group.name}`, group, headState });
if (!proxies.length) {
ret.push({
type: 3,
key: `empty-${group.name}`,
group,
headState,
});
} else if (col > 1) {
ret.push({ type: 3, key: `empty-${group.name}`, group, headState });
}
// 支持多列布局
if (col > 1) {
return ret.concat(
groupProxies(proxies, col).map((proxyCol) => ({
groupList(proxies, col).map((proxyCol) => ({
type: 4,
key: `col-${group.name}-${proxyCol[0].name}`,
group,
headState,
col,
proxyCol,
provider: proxyCol[0].provider,
})),
);
} else {
return ret.concat(
proxies.map((proxy) => ({
type: 2,
key: `${group.name}-${proxy!.name}`,
group,
proxy,
headState,
provider: proxy.provider,
})),
);
}
return ret.concat(
proxies.map((proxy) => ({
type: 2,
key: `${group.name}-${proxy!.name}`,
group,
proxy,
headState,
})),
);
}
return ret;
});
if (!useRule) return retList.slice(1);
return retList.filter((item) => !item.group.hidden);
return retList.filter((item) => item.group.hidden === false);
}, [headStates, proxiesData, mode, col]);
return {
renderList,
onProxies: mutateProxies,
onHeadState: setHeadState,
currentColumns: col,
};
};
function groupList<T = any>(list: T[], size: number): T[][] {
return list.reduce((p, n) => {
if (!p.length) return [[n]];
const i = p.length - 1;
if (p[i].length < size) {
p[i].push(n);
return p;
}
p.push([n]);
return p;
}, [] as T[][]);
}
@@ -51,7 +51,7 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
mutate("getClashConfig");
mutate("getVersion");
}, 100);
// Notice.success(t("Switched to _clash Core", { core: `${core}` }), 1000);
Notice.success(t("Switched to _clash Core", { core: `${core}` }), 1000);
} catch (err: any) {
Notice.error(err?.message || err.toString());
}
+3 -3
View File
@@ -35,7 +35,7 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
open: () => {
setOpen(true);
setValues({
stack: clash?.tun.stack ?? "gvisor",
stack: clash?.tun.stack ?? "mixed",
device: clash?.tun.device ?? "Mihomo",
autoRoute: clash?.tun["auto-route"] ?? true,
autoDetectInterface: clash?.tun["auto-detect-interface"] ?? true,
@@ -89,7 +89,7 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
size="small"
onClick={async () => {
let tun = {
stack: "gvisor",
stack: "mixed",
device: "Mihomo",
"auto-route": true,
"auto-detect-interface": true,
@@ -98,7 +98,7 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
mtu: 1500,
};
setValues({
stack: "gvisor",
stack: "mixed",
device: "Mihomo",
autoRoute: true,
autoDetectInterface: true,
+3 -31
View File
@@ -1,11 +1,7 @@
import useSWR, { mutate } from "swr";
import useSWR from "swr";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import {
SettingsRounded,
PlayArrowRounded,
PauseRounded,
} from "@mui/icons-material";
import { SettingsRounded } from "@mui/icons-material";
import { useVerge } from "@/hooks/use-verge";
import { DialogRef, Notice, Switch } from "@/components/base";
import { SettingList, SettingItem } from "./mods/setting-comp";
@@ -13,7 +9,6 @@ import { GuardState } from "./mods/guard-state";
import { SysproxyViewer } from "./mods/sysproxy-viewer";
import { TunViewer } from "./mods/tun-viewer";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { getSystemProxy, getAutotemProxy } from "@/services/cmds";
interface Props {
onError?: (err: Error) => void;
@@ -24,9 +19,6 @@ const SettingSystem = ({ onError }: Props) => {
const { verge, mutateVerge, patchVerge } = useVerge();
const { data: sysproxy } = useSWR("getSystemProxy", getSystemProxy);
const { data: autoproxy } = useSWR("getAutotemProxy", getAutotemProxy);
const sysproxyRef = useRef<DialogRef>(null);
const tunRef = useRef<DialogRef>(null);
@@ -35,25 +27,13 @@ const SettingSystem = ({ onError }: Props) => {
enable_auto_launch,
enable_silent_start,
enable_system_proxy,
proxy_auto_config,
} = verge ?? {};
const isProxyEnabled = proxy_auto_config
? autoproxy?.enable
: sysproxy?.enable;
const onSwitchFormat = (_e: any, value: boolean) => value;
const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false);
};
const updateProxyStatus = async () => {
// 等待一小段时间让系统代理状态变化
await new Promise((resolve) => setTimeout(resolve, 100));
await mutate("getSystemProxy");
await mutate("getAutotemProxy");
};
return (
<SettingList title={t("System Setting")}>
<SysproxyViewer ref={sysproxyRef} />
@@ -93,11 +73,6 @@ const SettingSystem = ({ onError }: Props) => {
icon={SettingsRounded}
onClick={() => sysproxyRef.current?.open()}
/>
{isProxyEnabled ? (
<PlayArrowRounded sx={{ color: "success.main", mr: 1 }} />
) : (
<PauseRounded sx={{ color: "error.main", mr: 1 }} />
)}
</>
}
>
@@ -107,10 +82,7 @@ const SettingSystem = ({ onError }: Props) => {
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_system_proxy: e })}
onGuard={async (e) => {
await patchVerge({ enable_system_proxy: e });
await updateProxyStatus();
}}
onGuard={(e) => patchVerge({ enable_system_proxy: e })}
>
<Switch edge="end" />
</GuardState>
+1 -5
View File
@@ -182,11 +182,7 @@ const SettingVerge = ({ onError }: Props) => {
>
<Select size="small" sx={{ width: 140, "> div": { py: "7.5px" } }}>
{routers.map((page: { label: string; path: string }) => {
return (
<MenuItem key={page.path} value={page.path}>
{t(page.label)}
</MenuItem>
);
return <MenuItem value={page.path}>{t(page.label)}</MenuItem>;
})}
</Select>
</GuardState>
+25 -19
View File
@@ -34,25 +34,34 @@ const buildWSUrl = (server: string, secret: string, logLevel: LogLevel) => {
};
interface LogStore {
logs: ILogItem[];
clearLogs: () => void;
appendLog: (log: ILogItem) => void;
logs: Record<LogLevel, ILogItem[]>;
clearLogs: (level?: LogLevel) => void;
appendLog: (level: LogLevel, log: ILogItem) => void;
}
const useLogStore = create<LogStore>(
(set: (fn: (state: LogStore) => Partial<LogStore>) => void) => ({
logs: [],
clearLogs: () =>
set(() => ({
logs: [],
logs: {
warning: [],
info: [],
debug: [],
error: [],
all: [],
},
clearLogs: (level?: LogLevel) =>
set((state: LogStore) => ({
logs: level
? { ...state.logs, [level]: [] }
: { warning: [], info: [], debug: [], error: [], all: [] },
})),
appendLog: (log: ILogItem) =>
appendLog: (level: LogLevel, log: ILogItem) =>
set((state: LogStore) => {
const currentLogs = state.logs[level];
const newLogs =
state.logs.length >= MAX_LOG_NUM
? [...state.logs.slice(1), log]
: [...state.logs, log];
return { logs: newLogs };
currentLogs.length >= MAX_LOG_NUM
? [...currentLogs.slice(1), log]
: [...currentLogs, log];
return { logs: { ...state.logs, [level]: newLogs } };
}),
}),
);
@@ -75,7 +84,7 @@ export const useLogData = (logLevel: LogLevel) => {
if (!isActive) return;
const data = JSON.parse(event.data) as ILogItem;
const time = dayjs().format("MM-DD HH:mm:ss");
appendLog({ ...data, time });
appendLog(logLevel, { ...data, time });
},
onerror() {
if (!isActive) return;
@@ -89,13 +98,10 @@ export const useLogData = (logLevel: LogLevel) => {
};
}, [clashInfo, enableLog, logLevel]);
// 根据当前选择的日志等级过滤日志
return logLevel === "all"
? logs
: logs.filter((log) => log.type.toLowerCase() === logLevel);
return logs[logLevel];
};
// 导出清空日志的方法
export const clearLogs = () => {
useLogStore.getState().clearLogs();
export const clearLogs = (level?: LogLevel) => {
useLogStore.getState().clearLogs(level);
};
+2 -11
View File
@@ -370,6 +370,7 @@
"Permissions Granted Successfully for _clash Core": "تم منح الأذونات بنجاح لـ {{core}} Core",
"Core Version Updated": "تم تحديث إصدار النواة",
"Clash Core Restarted": "تم إعادة تشغيل نواة Clash",
"Switched to _clash Core": "تم التبديل إلى {{core}} Core",
"GeoData Updated": "تم تحديث البيانات الجغرافية",
"Currently on the Latest Version": "أنت على أحدث إصدار حاليًا",
"Import Subscription Successful": "تم استيراد الاشتراك بنجاح",
@@ -437,15 +438,5 @@
"Direct Mode": "الوضع المباشر",
"Enable Tray Speed": "تفعيل سرعة التراي",
"Lite Mode": "وضع الأداء الخفيف",
"Lite Mode Info": "إيقاف الواجهة الرسومية والإبقاء على تشغيل النواة",
"Config Validation Failed": "فشل التحقق من تكوين الاشتراك، يرجى فحص ملف التكوين، تم التراجع عن التغييرات، تفاصيل الخطأ:",
"Boot Config Validation Failed": "فشل التحقق من التكوين عند الإقلاع، تم استخدام التكوين الافتراضي، يرجى فحص ملف التكوين، تفاصيل الخطأ:",
"Core Change Config Validation Failed": "فشل التحقق من التكوين عند تغيير النواة، تم استخدام التكوين الافتراضي، يرجى فحص ملف التكوين، تفاصيل الخطأ:",
"Config Validation Process Terminated": "تم إنهاء عملية التحقق",
"Script Syntax Error": "خطأ في بناء جملة السكريبت، تم التراجع عن التغييرات",
"Script Missing Main": "خطأ في السكريبت، تم التراجع عن التغييرات",
"File Not Found": "الملف غير موجود، تم التراجع عن التغييرات",
"Script File Error": "خطأ في ملف السكريبت، تم التراجع عن التغييرات",
"Core Changed Successfully": "تم تغيير النواة بنجاح",
"Failed to Change Core": "فشل تغيير النواة"
"Lite Mode Info": "إيقاف الواجهة الرسومية والإبقاء على تشغيل النواة"
}
+2 -6
View File
@@ -178,7 +178,7 @@
"Process": "Process",
"Time": "Time",
"Source": "Source",
"Destination": "Destination",
"Destination": "Destination IP",
"DestinationPort": "Destination Port",
"Close Connection": "Close Connection",
"Rules": "Rules",
@@ -441,9 +441,5 @@
"Direct Mode": "Direct Mode",
"Enable Tray Speed": "Enable Tray Speed",
"Lite Mode": "Lightweight Mode",
"Lite Mode Info": "Close the GUI and keep only the kernel running",
"Config Validation Failed": "Subscription configuration validation failed. Please check the subscription configuration file; modifications have been rolled back.",
"Boot Config Validation Failed": "Boot subscription configuration validation failed. Started with the default configuration; please check the subscription configuration file.",
"Core Change Config Validation Failed": "Configuration validation failed when switching the kernel. Started with the default configuration; please check the subscription configuration file.",
"Config Validation Process Terminated": "The validation process has been terminated."
"Lite Mode Info": "Close the GUI and keep only the kernel running"
}
+2 -11
View File
@@ -373,6 +373,7 @@
"Permissions Granted Successfully for _clash Core": "مجوزها با موفقیت برای هسته {{core}} اعطا شد",
"Core Version Updated": "نسخه هسته به‌روزرسانی شد",
"Clash Core Restarted": "هسته Clash مجدداً راه‌اندازی شد",
"Switched to _clash Core": "تغییر به هسته {{core}}",
"GeoData Updated": "GeoData به‌روزرسانی شد",
"Currently on the Latest Version": "در حال حاضر در آخرین نسخه",
"Import Subscription Successful": "وارد کردن اشتراک با موفقیت انجام شد",
@@ -434,15 +435,5 @@
"Direct Mode": "حالت مستقیم",
"Enable Tray Speed": "فعال کردن سرعت ترای",
"Lite Mode": "در فارسی",
"Lite Mode Info": "رابط کاربری گرافیکی را ببندید و فقط هسته را در حال اجرا نگه دارید",
"Config Validation Failed": "اعتبارسنجی پیکربندی اشتراک ناموفق بود، فایل پیکربندی را بررسی کنید، تغییرات برگشت داده شد، جزئیات خطا:",
"Boot Config Validation Failed": "اعتبارسنجی پیکربندی هنگام راه‌اندازی ناموفق بود، پیکربندی پیش‌فرض استفاده شد، فایل پیکربندی را بررسی کنید، جزئیات خطا:",
"Core Change Config Validation Failed": "اعتبارسنجی پیکربندی هنگام تغییر هسته ناموفق بود، پیکربندی پیش‌فرض استفاده شد، فایل پیکربندی را بررسی کنید، جزئیات خطا:",
"Config Validation Process Terminated": "فرآیند اعتبارسنجی متوقف شد",
"Script Syntax Error": "خطای نحوی اسکریپت، تغییرات برگشت داده شد",
"Script Missing Main": "خطای اسکریپت، تغییرات برگشت داده شد",
"File Not Found": "فایل یافت نشد، تغییرات برگشت داده شد",
"Script File Error": "خطای فایل اسکریپت، تغییرات برگشت داده شد",
"Core Changed Successfully": "هسته با موفقیت تغییر کرد",
"Failed to Change Core": "تغییر هسته ناموفق بود"
"Lite Mode Info": "رابط کاربری گرافیکی را ببندید و فقط هسته را در حال اجرا نگه دارید"
}
+2 -11
View File
@@ -404,6 +404,7 @@
"Permissions Granted Successfully for _clash Core": "Izin Berhasil Diberikan untuk Core {{core}}",
"Core Version Updated": "Versi Core Diperbarui",
"Clash Core Restarted": "Core Clash Dimulai Ulang",
"Switched to _clash Core": "Beralih ke Core {{core}}",
"GeoData Updated": "GeoData Diperbarui",
"Currently on the Latest Version": "Saat ini pada Versi Terbaru",
"Import Subscription Successful": "Berlangganan Berhasil Diimpor",
@@ -433,15 +434,5 @@
"Failed to fetch backup files": "Gagal mengambil file cadangan",
"Enable Tray Speed": "Aktifkan Tray Speed",
"Lite Mode": "Mode Ringan",
"Lite Mode Info": "Tutup GUI dan biarkan hanya kernel yang berjalan",
"Config Validation Failed": "Validasi konfigurasi langganan gagal, periksa file konfigurasi, perubahan dibatalkan, detail kesalahan:",
"Boot Config Validation Failed": "Validasi konfigurasi saat boot gagal, menggunakan konfigurasi default, periksa file konfigurasi, detail kesalahan:",
"Core Change Config Validation Failed": "Validasi konfigurasi saat ganti inti gagal, menggunakan konfigurasi default, periksa file konfigurasi, detail kesalahan:",
"Config Validation Process Terminated": "Proses validasi dihentikan",
"Script Syntax Error": "Kesalahan sintaks skrip, perubahan dibatalkan",
"Script Missing Main": "Kesalahan skrip, perubahan dibatalkan",
"File Not Found": "File tidak ditemukan, perubahan dibatalkan",
"Script File Error": "Kesalahan file skrip, perubahan dibatalkan",
"Core Changed Successfully": "Inti berhasil diubah",
"Failed to Change Core": "Gagal mengubah inti"
"Lite Mode Info": "Tutup GUI dan biarkan hanya kernel yang berjalan"
}
+2 -11
View File
@@ -373,6 +373,7 @@
"Permissions Granted Successfully for _clash Core": "Разрешения успешно предоставлены для ядра {{core}}",
"Core Version Updated": "Обновлена версия ядра",
"Clash Core Restarted": "Clash ядра перезапущено",
"Switched to _clash Core": "Переключено на ядра {{core}}",
"GeoData Updated": "GeoData Обновлена",
"Currently on the Latest Version": "В настоящее время используется последняя версия",
"Import subscription successful": "Импорт подписки успешно",
@@ -434,15 +435,5 @@
"Direct Mode": "Прямой режим",
"Enable Tray Speed": "Включить скорость в лотке",
"Lite Mode": "Облегченный режим",
"Lite Mode Info": "Закройте графический интерфейс и оставьте работать только ядро",
"Config Validation Failed": "Ошибка проверки конфигурации подписки, проверьте файл конфигурации, изменения отменены, ошибка:",
"Boot Config Validation Failed": "Ошибка проверки конфигурации при запуске, используется конфигурация по умолчанию, проверьте файл конфигурации, ошибка:",
"Core Change Config Validation Failed": "Ошибка проверки конфигурации при смене ядра, используется конфигурация по умолчанию, проверьте файл конфигурации, ошибка:",
"Config Validation Process Terminated": "Процесс проверки прерван",
"Script Syntax Error": "Ошибка синтаксиса скрипта, изменения отменены",
"Script Missing Main": "Ошибка скрипта, изменения отменены",
"File Not Found": "Файл не найден, изменения отменены",
"Script File Error": "Ошибка файла скрипта, изменения отменены",
"Core Changed Successfully": "Ядро успешно сменено",
"Failed to Change Core": "Не удалось сменить ядро"
"Lite Mode Info": "Закройте графический интерфейс и оставьте работать только ядро"
}
+2 -11
View File
@@ -372,6 +372,7 @@
"Permissions Granted Successfully for _clash Core": "{{core}} ядросы өчен рөхсәтләр бирелде",
"Core Version Updated": "Ядро версиясе яңартылды",
"Clash Core Restarted": "Clash ядросы яңадан башланды",
"Switched to _clash Core": "{{core}} ядросына күчү башкарылды",
"GeoData Updated": "GeoData яңартылды",
"Currently on the Latest Version": "Сездә иң соңгы версия урнаштырылган",
"Import subscription successful": "Подписка уңышлы импортланды",
@@ -433,15 +434,5 @@
"Direct Mode": "Туры режим",
"Enable Tray Speed": "Трей скоростьне үстерү",
"Lite Mode": "Җиңел Режим",
"Lite Mode Info": "GUI-ны ябыгыз һәм бары тик төшне генә эшләтеп калдырыгыз",
"Config Validation Failed": "Язылу көйләү тикшерүе уңышсыз, көйләү файлын тикшерегез, үзгәрешләр кире кайтарылды, хата:",
"Boot Config Validation Failed": "Йөкләү вакытында көйләү тикшерүе уңышсыз, стандарт көйләү кулланылды, көйләү файлын тикшерегез, хата:",
"Core Change Config Validation Failed": "Ядро алыштырганда көйләү тикшерүе уңышсыз, стандарт көйләү кулланылды, көйләү файлын тикшерегез, хата:",
"Config Validation Process Terminated": "Тикшерү процессы туктатылды",
"Script Syntax Error": "Скрипт синтаксик хатасы, үзгәрешләр кире кайтарылды",
"Script Missing Main": "Скрипт хатасы, үзгәрешләр кире кайтарылды",
"File Not Found": "Файл табылмады, үзгәрешләр кире кайтарылды",
"Script File Error": "Скрипт файлы хатасы, үзгәрешләр кире кайтарылды",
"Core Changed Successfully": "Ядро уңышлы алыштырылды",
"Failed to Change Core": "Ядро алыштыру уңышсыз булды"
"Lite Mode Info": "GUI-ны ябыгыз һәм бары тик төшне генә эшләтеп калдырыгыз"
}
+2 -11
View File
@@ -373,6 +373,7 @@
"Permissions Granted Successfully for _clash Core": "{{core}} 内核授权成功",
"Core Version Updated": "内核版本已更新",
"Clash Core Restarted": "已重启 Clash 内核",
"Switched to _clash Core": "已切换至 {{core}} 内核",
"GeoData Updated": "已更新 GeoData",
"Currently on the Latest Version": "当前已是最新版本",
"Import Subscription Successful": "导入订阅成功",
@@ -434,15 +435,5 @@
"Direct Mode": "直连模式",
"Enable Tray Speed": "启用托盘速率",
"Lite Mode": "轻量模式",
"Lite Mode Info": "关闭GUI界面,仅保留内核运行",
"Config Validation Failed": "订阅配置校验失败,请检查订阅配置文件,变更已撤销,错误详情:",
"Boot Config Validation Failed": "启动订阅配置校验失败,已使用默认配置启动;请检查订阅配置文件,错误详情:",
"Core Change Config Validation Failed": "切换内核时配置校验失败,已使用默认配置启动;请检查订阅配置文件,错误详情:",
"Config Validation Process Terminated": "验证进程被终止",
"Script Syntax Error": "脚本语法错误,变更已撤销",
"Script Missing Main": "脚本错误,变更已撤销",
"File Not Found": "文件丢失,变更已撤销",
"Script File Error": "脚本文件错误,变更已撤销",
"Core Changed Successfully": "内核切换成功",
"Failed to Change Core": "无法切换内核"
"Lite Mode Info": "关闭GUI界面,仅保留内核运行"
}
+78 -131
View File
@@ -2,7 +2,7 @@ import dayjs from "dayjs";
import i18next from "i18next";
import relativeTime from "dayjs/plugin/relativeTime";
import { SWRConfig, mutate } from "swr";
import { useEffect, useCallback } from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useRoutes, useNavigate } from "react-router-dom";
import { List, Paper, ThemeProvider, SvgIcon } from "@mui/material";
@@ -36,151 +36,91 @@ dayjs.extend(relativeTime);
const OS = getSystem();
// 通知处理函数
const handleNoticeMessage = (
status: string,
msg: string,
t: (key: string) => string,
navigate: (path: string, options?: any) => void,
) => {
console.log("[通知监听] 收到消息:", status, msg);
switch (status) {
case "import_sub_url::ok":
navigate("/profile", { state: { current: msg } });
Notice.success(t("Import Subscription Successful"));
break;
case "import_sub_url::error":
navigate("/profile");
Notice.error(msg);
break;
case "set_config::error":
Notice.error(msg);
break;
case "config_validate::boot_error":
Notice.error(`${t("Boot Config Validation Failed")} ${msg}`);
break;
case "config_validate::core_change":
Notice.error(`${t("Core Change Config Validation Failed")} ${msg}`);
break;
case "config_validate::error":
Notice.error(`${t("Config Validation Failed")} ${msg}`);
break;
case "config_validate::process_terminated":
Notice.error(t("Config Validation Process Terminated"));
break;
case "config_validate::stdout_error":
Notice.error(`${t("Config Validation Failed")} ${msg}`);
break;
case "config_validate::script_error":
Notice.error(`${t("Script File Error")} ${msg}`);
break;
case "config_validate::script_syntax_error":
Notice.error(`${t("Script Syntax Error")} ${msg}`);
break;
case "config_validate::script_missing_main":
Notice.error(`${t("Script Missing Main")} ${msg}`);
break;
case "config_validate::file_not_found":
Notice.error(`${t("File Not Found")} ${msg}`);
break;
case "config_core::change_success":
Notice.success(`${t("Core Changed Successfully")}: ${msg}`);
break;
case "config_core::change_error":
Notice.error(`${t("Failed to Change Core")}: ${msg}`);
break;
}
};
const Layout = () => {
const mode = useThemeMode();
const isDark = mode === "light" ? false : true;
const { t } = useTranslation();
const { theme } = useCustomTheme();
const { verge } = useVerge();
const { language, start_page } = verge ?? {};
const { language, start_page } = verge || {};
const navigate = useNavigate();
const location = useLocation();
const routersEles = useRoutes(routers);
const { addListener, setupCloseListener } = useListen();
if (!routersEles) return null;
const handleNotice = useCallback(
(payload: [string, string]) => {
const [status, msg] = payload;
handleNoticeMessage(status, msg, t, navigate);
},
[t, navigate],
);
setupCloseListener();
// 设置监听器
useEffect(() => {
const listeners = [
// 配置更新监听
addListener("verge://refresh-clash-config", async () => {
await getAxios(true);
mutate("getProxies");
mutate("getVersion");
mutate("getClashConfig");
mutate("getProxyProviders");
}),
addListener("verge://refresh-clash-config", async () => {
// the clash info may be updated
await getAxios(true);
mutate("getProxies");
mutate("getVersion");
mutate("getClashConfig");
mutate("getProxyProviders");
});
// verge 配置更新监听
addListener("verge://refresh-verge-config", () =>
mutate("getVergeConfig"),
),
// update the verge config
addListener("verge://refresh-verge-config", () => mutate("getVergeConfig"));
// 通知消息监听
addListener("verge://notice-message", ({ payload }) =>
handleNotice(payload as [string, string]),
),
];
// 设置提示监听
addListener("verge://notice-message", ({ payload }) => {
const [status, msg] = payload as [string, string];
switch (status) {
case "import_sub_url::ok":
navigate("/profile", { state: { current: msg } });
// 设置窗口显示/隐藏监听
const setupWindowListeners = async () => {
const [hideUnlisten, showUnlisten] = await Promise.all([
listen("verge://hide-window", () => appWindow.hide()),
listen("verge://show-window", () => appWindow.show()),
]);
Notice.success(t("Import Subscription Successful"));
break;
case "import_sub_url::error":
navigate("/profile");
Notice.error(msg);
break;
case "set_config::error":
Notice.error(msg);
break;
default:
break;
}
});
setTimeout(async () => {
portableFlag = await getPortableFlag();
await appWindow.unminimize();
await appWindow.show();
await appWindow.setFocus();
}, 50);
// 监听窗口显示/隐藏事件
const setupListeners = async () => {
const unlisten1 = await listen("verge://hide-window", () => {
appWindow.hide();
});
const unlisten2 = await listen("verge://show-window", () => {
appWindow.show();
});
return () => {
hideUnlisten();
showUnlisten();
unlisten1();
unlisten2();
};
};
// 初始化
setupCloseListener();
const cleanupWindow = setupWindowListeners();
setupListeners();
}, []);
// 清理函数
return () => {
// 清理主要监听器
listeners.forEach((listener) => {
if (typeof listener.then === "function") {
listener.then((unlisten) => unlisten());
}
});
// 清理窗口监听器
cleanupWindow.then((cleanup) => cleanup());
};
}, [handleNotice]);
// 语言和起始页设置
useEffect(() => {
if (language) {
dayjs.locale(language === "zh" ? "zh-cn" : language);
i18next.changeLanguage(language);
}
}, [language]);
useEffect(() => {
if (start_page) {
navigate(start_page, { replace: true });
navigate(start_page);
}
}, [start_page]);
if (!routersEles) return null;
}, [language, start_page]);
return (
<SWRConfig value={{ errorRetryCount: 3 }}>
@@ -190,18 +130,23 @@ const Layout = () => {
elevation={0}
className={`${OS} layout`}
onContextMenu={(e) => {
// only prevent it on Windows
const validList = ["input", "textarea"];
const target = e.currentTarget;
if (
OS === "windows" &&
!["input", "textarea"].includes(
e.currentTarget.tagName.toLowerCase(),
) &&
!e.currentTarget.isContentEditable
!(
validList.includes(target.tagName.toLowerCase()) ||
target.isContentEditable
)
) {
e.preventDefault();
}
}}
sx={[
({ palette }) => ({ bgcolor: palette.background.paper }),
({ palette }) => ({
bgcolor: palette.background.paper,
}),
{
borderRadius: "8px",
border: "2px solid var(--divider-color)",
@@ -232,7 +177,7 @@ const Layout = () => {
/>
<LogoSvg fill={isDark ? "white" : "black"} />
</div>
<UpdateButton className="the-newbtn" />
{<UpdateButton className="the-newbtn" />}
</div>
<List className="the-menu">
@@ -253,14 +198,16 @@ const Layout = () => {
</div>
<div className="layout__right">
<div className="the-bar">
<div
className="the-dragbar"
data-tauri-drag-region="true"
style={{ width: "100%" }}
/>
{OS !== "macos" && <LayoutControl />}
</div>
{
<div className="the-bar">
<div
className="the-dragbar"
data-tauri-drag-region="true"
style={{ width: "100%" }}
></div>
{OS !== "macos" && <LayoutControl />}
</div>
}
<TransitionGroup className="the-content">
<CSSTransition
+31 -59
View File
@@ -1,14 +1,9 @@
import { useMemo, useRef, useState, useCallback } from "react";
import { useMemo, useRef, useState } from "react";
import { useLockFn } from "ahooks";
import { Box, Button, IconButton, MenuItem } from "@mui/material";
import { Virtuoso } from "react-virtuoso";
import { useTranslation } from "react-i18next";
import {
TableChartRounded,
TableRowsRounded,
PlayCircleOutlineRounded,
PauseCircleOutlineRounded,
} from "@mui/icons-material";
import { TableChartRounded, TableRowsRounded } from "@mui/icons-material";
import { closeAllConnections } from "@/services/api";
import { useConnectionSetting } from "@/services/states";
import { useClashInfo } from "@/hooks/use-clash";
@@ -20,10 +15,7 @@ import {
ConnectionDetailRef,
} from "@/components/connection/connection-detail";
import parseTraffic from "@/utils/parse-traffic";
import {
BaseSearchBox,
type SearchState,
} from "@/components/base/base-search-box";
import { BaseSearchBox } from "@/components/base/base-search-box";
import { BaseStyledSelect } from "@/components/base/base-styled-select";
import useSWRSubscription from "swr/subscription";
import { createSockette } from "@/utils/websocket";
@@ -63,9 +55,6 @@ const ConnectionsPage = () => {
list.sort((a, b) => b.curDownload! - a.curDownload!),
};
const [isPaused, setIsPaused] = useState(false);
const [frozenData, setFrozenData] = useState<IConnections | null>(null);
const { data: connData = initConn } = useSWRSubscription<
IConnections,
any,
@@ -78,7 +67,9 @@ const ConnectionsPage = () => {
`ws://${server}/connections?token=${encodeURIComponent(secret)}`,
{
onmessage(event) {
// meta v1.15.0 出现 data.connections 为 null 的情况
const data = JSON.parse(event.data) as IConnections;
// 尽量与前一次 connections 的展示顺序保持一致
next(null, (old = initConn) => {
const oldConn = old.connections;
const maxLen = data.connections?.length;
@@ -123,43 +114,27 @@ const ConnectionsPage = () => {
},
);
const displayData = useMemo(() => {
return isPaused ? (frozenData ?? connData) : connData;
}, [isPaused, frozenData, connData]);
const [filterConn] = useMemo(() => {
const [filterConn, download, upload] = useMemo(() => {
const orderFunc = orderOpts[curOrderOpt];
let connections = displayData.connections.filter((conn) => {
const { host, destinationIP, process } = conn.metadata;
return (
match(host || "") || match(destinationIP || "") || match(process || "")
);
});
let connections = connData.connections.filter((conn) =>
match(conn.metadata.host || conn.metadata.destinationIP || ""),
);
if (orderFunc) connections = orderFunc(connections);
return [connections];
}, [displayData, match, curOrderOpt]);
let download = 0;
let upload = 0;
connections.forEach((x) => {
download += x.download;
upload += x.upload;
});
return [connections, download, upload];
}, [connData, match, curOrderOpt]);
const onCloseAll = useLockFn(closeAllConnections);
const detailRef = useRef<ConnectionDetailRef>(null!);
const handleSearch = useCallback((match: (content: string) => boolean) => {
setMatch(() => match);
}, []);
const handlePauseToggle = useCallback(() => {
setIsPaused((prev) => {
if (!prev) {
setFrozenData(connData);
} else {
setFrozenData(null);
}
return !prev;
});
}, [connData]);
return (
<BasePage
full
@@ -169,15 +144,17 @@ const ConnectionsPage = () => {
display: "flex",
flexDirection: "column",
overflow: "auto",
// margin: "0 10px",
// backgroundColor: isDark ? "#282a36" : "#ffffff",
borderRadius: "8px",
}}
header={
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ mx: 1 }}>
{t("Downloaded")}: {parseTraffic(displayData.downloadTotal)}
{t("Downloaded")}: {parseTraffic(download)}
</Box>
<Box sx={{ mx: 1 }}>
{t("Uploaded")}: {parseTraffic(displayData.uploadTotal)}
{t("Uploaded")}: {parseTraffic(upload)}
</Box>
<IconButton
color="inherit"
@@ -191,23 +168,16 @@ const ConnectionsPage = () => {
}
>
{isTableLayout ? (
<TableRowsRounded titleAccess={t("List View")} />
<span title={t("List View")}>
<TableRowsRounded fontSize="inherit" />
</span>
) : (
<TableChartRounded titleAccess={t("Table View")} />
)}
</IconButton>
<IconButton
color="inherit"
size="small"
onClick={handlePauseToggle}
title={isPaused ? t("Resume") : t("Pause")}
>
{isPaused ? (
<PlayCircleOutlineRounded />
) : (
<PauseCircleOutlineRounded />
<span title={t("Table View")}>
<TableChartRounded fontSize="inherit" />
</span>
)}
</IconButton>
<Button size="small" variant="contained" onClick={onCloseAll}>
<span style={{ whiteSpace: "nowrap" }}>{t("Close All")}</span>
</Button>
@@ -223,6 +193,7 @@ const ConnectionsPage = () => {
display: "flex",
alignItems: "center",
userSelect: "text",
// backgroundColor: isDark ? "#282a36" : "#ffffff",
position: "sticky",
top: 0,
zIndex: 2,
@@ -240,7 +211,7 @@ const ConnectionsPage = () => {
))}
</BaseStyledSelect>
)}
<BaseSearchBox onSearch={handleSearch} />
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
</Box>
{filterConn.length === 0 ? (
@@ -254,6 +225,7 @@ const ConnectionsPage = () => {
<Virtuoso
style={{
flex: 1,
// backgroundColor: isDark ? "#282a36" : "#ffffff",
borderRadius: "8px",
}}
data={filterConn}
+6 -10
View File
@@ -32,15 +32,11 @@ const LogPage = () => {
const filterLogs = useMemo(() => {
return logData
? logData.filter((data) => {
// 构建完整的搜索文本,包含时间、类型和内容
const searchText =
`${data.time || ""} ${data.type} ${data.payload}`.toLowerCase();
return logLevel === "all"
? match(searchText)
: data.type.toLowerCase() === logLevel && match(searchText);
})
? logData.filter((data) =>
logLevel === "all"
? match(data.payload)
: data.type.includes(logLevel) && match(data.payload),
)
: [];
}, [logData, logLevel, match]);
@@ -74,7 +70,7 @@ const LogPage = () => {
size="small"
variant="contained"
onClick={() => {
clearLogs();
clearLogs(logLevel);
}}
>
{t("Clear")}
+7 -7
View File
@@ -27,7 +27,7 @@ import { useTranslation } from "react-i18next";
import {
importProfile,
enhanceProfiles,
//restartCore,
restartCore,
getRuntimeLogs,
deleteProfile,
updateProfile,
@@ -174,11 +174,11 @@ const ProfilePage = () => {
}, 100);
try {
const success = await patchProfiles({ current: profile });
await patchProfiles({ current: profile });
await mutateLogs();
closeAllConnections();
await activateSelected();
if (notifySuccess && success) {
if (notifySuccess) {
Notice.success(t("Profile Switched"), 1000);
}
} catch (err: any) {
@@ -377,7 +377,7 @@ const ProfilePage = () => {
sx={{
pl: "10px",
pr: "10px",
height: "calc(100% - 48px)",
height: "calc(100% - 68px)",
overflowY: "auto",
}}
>
@@ -400,8 +400,8 @@ const ProfilePage = () => {
onSave={async (prev, curr) => {
if (prev !== curr && profiles.current === item.uid) {
await onEnhance(false);
// await restartCore();
// Notice.success(t("Clash Core Restarted"), 1000);
await restartCore();
Notice.success(t("Clash Core Restarted"), 1000);
}
}}
onDelete={() => onDelete(item.uid)}
@@ -416,7 +416,7 @@ const ProfilePage = () => {
flexItem
sx={{ width: `calc(100% - 32px)`, borderColor: dividercolor }}
></Divider>
<Box sx={{ mt: 1.5, mb: "10px" }}>
<Box sx={{ mt: 1.5 }}>
<Grid2 container spacing={{ xs: 1, lg: 1 }}>
<Grid2 size={{ xs: 12, sm: 6, md: 6, lg: 6 }}>
<ProfileMore
+1 -1
View File
@@ -42,7 +42,7 @@ const ProxyPage = () => {
return (
<BasePage
full
contentStyle={{ height: "101.5%" }}
contentStyle={{ height: "100%" }}
title={t("Proxy Groups")}
header={
<Box display="flex" alignItems="center" gap={1}>
+1 -8
View File
@@ -1,11 +1,4 @@
import {
Box,
ButtonGroup,
Grid,
IconButton,
Select,
MenuItem,
} from "@mui/material";
import { Box, ButtonGroup, Grid, IconButton } from "@mui/material";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { BasePage, Notice } from "@/components/base";
+1 -9
View File
@@ -119,7 +119,7 @@ export async function getAutotemProxy() {
}
export async function changeClashCore(clashCore: string) {
return invoke<string | null>("change_clash_core", { clashCore });
return invoke<any>("change_clash_core", { clashCore });
}
export async function restartCore() {
@@ -241,11 +241,3 @@ export async function listWebDavBackup() {
});
return list;
}
export async function scriptValidateNotice(status: string, msg: string) {
return invoke<void>("script_validate_notice", { status, msg });
}
export async function validateScriptFile(filePath: string) {
return invoke<boolean>("validate_script_file", { filePath });
}