Compare commits

..

54 Commits

46 changed files with 857 additions and 548 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ dist-ssr
*.local
update.json
scripts/_env.sh
.vscode

View File

@@ -1,3 +1,35 @@
## v1.1.2
### Features
- the system tray follows i18n
- change the proxy group ui of global mode
- support to update profile with the system proxy/clash proxy
- check the remote profile more strictly
### Bug Fixes
- use app version as default user agent
- the clash not exit in service mode
- reset the system proxy when quit the app
- fix some other glitches
---
## v1.1.1
### Features
- optimize clash config feedback
- hide macOS dock icon
- use clash meta compatible version (Linux)
### Bug Fixes
- fix some other glitches
---
## v1.1.0
### Features

View File

@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "1.1.0",
"version": "1.1.2",
"license": "GPL-3.0",
"scripts": {
"dev": "tauri dev",
@@ -33,7 +33,7 @@
"react-dom": "^17.0.2",
"react-i18next": "^11.17.4",
"react-router-dom": "^6.3.0",
"react-virtuoso": "^2.17.2",
"react-virtuoso": "^3.1.0",
"recoil": "^0.7.5",
"snarkdown": "^2.0.0",
"swr": "^1.3.0"

View File

@@ -28,6 +28,7 @@ function resolveClash() {
"darwin-x64": "clash-darwin-amd64",
"darwin-arm64": "clash-darwin-arm64",
"linux-x64": "clash-linux-amd64",
"linux-arm64": "clash-linux-armv8",
};
const name = map[`${platform}-${arch}`];
@@ -58,7 +59,8 @@ async function resolveClashMeta() {
"win32-x64": "Clash.Meta-windows-amd64",
"darwin-x64": "Clash.Meta-darwin-amd64",
"darwin-arm64": "Clash.Meta-darwin-arm64",
"linux-x64": "Clash.Meta-linux-amd64",
"linux-x64": "Clash.Meta-linux-amd64-compatible",
"linux-arm64": "Clash.Meta-linux-arm64",
};
const name = map[`${platform}-${arch}`];
@@ -258,7 +260,7 @@ async function resolveService() {
*/
async function resolveMmdb() {
const url =
"https://github.com/Dreamacro/maxmind-geoip/releases/download/20220812/Country.mmdb";
"https://github.com/Dreamacro/maxmind-geoip/releases/download/20221012/Country.mmdb";
const resDir = path.join(cwd, "src-tauri", "resources");
const resPath = path.join(resDir, "Country.mmdb");

View File

@@ -57,8 +57,8 @@ fn use_dns_for_tun(mut config: Mapping) -> Mapping {
// 开启tun将同时开启dns
revise!(dns_val, "enable", true);
// 借鉴cfw的默认配置
append!(dns_val, "enhanced-mode", "fake-ip");
append!(dns_val, "fake-ip-range", "198.18.0.1/16");
append!(
dns_val,
"nameserver",

View File

@@ -1,7 +1,6 @@
use crate::data::*;
use super::tray::Tray;
use crate::log_if_err;
use anyhow::{bail, Result};
use serde_yaml::Value;
use tauri::{AppHandle, Manager, Window};
#[derive(Debug, Default, Clone)]
@@ -40,62 +39,28 @@ impl Handle {
}
}
// update system tray state (clash config)
pub fn update_systray_clash(&self) -> Result<()> {
if self.app_handle.is_none() {
bail!("update_systray_clash unhandle error");
pub fn notice_message(&self, status: String, msg: String) {
if let Some(window) = self.get_window() {
log_if_err!(window.emit("verge://notice-message", (status, msg)));
}
let app_handle = self.app_handle.as_ref().unwrap();
let global = Data::global();
let clash = global.clash.lock();
let mode = clash
.config
.get(&Value::from("mode"))
.map(|val| val.as_str().unwrap_or("rule"))
.unwrap_or("rule");
let tray = app_handle.tray_handle();
tray.get_item("rule_mode").set_selected(mode == "rule")?;
tray
.get_item("global_mode")
.set_selected(mode == "global")?;
tray
.get_item("direct_mode")
.set_selected(mode == "direct")?;
tray
.get_item("script_mode")
.set_selected(mode == "script")?;
Ok(())
}
/// update the system tray state (verge config)
pub fn update_systray(&self) -> Result<()> {
if self.app_handle.is_none() {
bail!("update_systray unhandle error");
}
let app_handle = self.app_handle.as_ref().unwrap();
let tray = app_handle.tray_handle();
let global = Data::global();
let verge = global.verge.lock();
let system_proxy = verge.enable_system_proxy.as_ref();
let tun_mode = verge.enable_tun_mode.as_ref();
tray
.get_item("system_proxy")
.set_selected(*system_proxy.unwrap_or(&false))?;
tray
.get_item("tun_mode")
.set_selected(*tun_mode.unwrap_or(&false))?;
// update verge config
self.refresh_verge();
Tray::update_systray(app_handle)?;
Ok(())
}
/// update the system tray state
pub fn update_systray_part(&self) -> Result<()> {
if self.app_handle.is_none() {
bail!("update_systray unhandle error");
}
let app_handle = self.app_handle.as_ref().unwrap();
Tray::update_part(app_handle)?;
Ok(())
}
}

View File

@@ -6,7 +6,7 @@ use crate::config::enhance_config;
use crate::data::*;
use crate::log_if_err;
use anyhow::{bail, Result};
use once_cell::sync::Lazy;
use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use serde_yaml::{Mapping, Value};
use std::sync::Arc;
@@ -16,18 +16,10 @@ mod hotkey;
mod service;
mod sysopt;
mod timer;
pub mod tray;
pub use self::service::*;
static CORE: Lazy<Core> = Lazy::new(|| Core {
service: Arc::new(Mutex::new(Service::new())),
sysopt: Arc::new(Mutex::new(Sysopt::new())),
timer: Arc::new(Mutex::new(Timer::new())),
hotkey: Arc::new(Mutex::new(Hotkey::new())),
runtime: Arc::new(Mutex::new(RuntimeResult::default())),
handle: Arc::new(Mutex::new(Handle::default())),
});
#[derive(Clone)]
pub struct Core {
pub service: Arc<Mutex<Service>>,
@@ -39,8 +31,17 @@ pub struct Core {
}
impl Core {
pub fn global() -> Core {
CORE.clone()
pub fn global() -> &'static Core {
static CORE: OnceCell<Core> = OnceCell::new();
CORE.get_or_init(|| Core {
service: Arc::new(Mutex::new(Service::new())),
sysopt: Arc::new(Mutex::new(Sysopt::new())),
timer: Arc::new(Mutex::new(Timer::new())),
hotkey: Arc::new(Mutex::new(Hotkey::new())),
runtime: Arc::new(Mutex::new(RuntimeResult::default())),
handle: Arc::new(Mutex::new(Handle::default())),
})
}
/// initialize the core state
@@ -64,8 +65,7 @@ impl Core {
drop(sysopt);
let handle = self.handle.lock();
log_if_err!(handle.update_systray());
log_if_err!(handle.update_systray_clash());
log_if_err!(handle.update_systray_part());
drop(handle);
let mut hotkey = self.hotkey.lock();
@@ -136,7 +136,7 @@ impl Core {
if has_mode {
let handle = self.handle.lock();
handle.update_systray_clash()?;
handle.update_systray_part()?;
}
Ok(())
@@ -155,6 +155,7 @@ impl Core {
let system_proxy = patch.enable_system_proxy;
let proxy_bypass = patch.system_proxy_bypass;
let proxy_guard = patch.enable_proxy_guard;
let language = patch.language;
#[cfg(target_os = "windows")]
{
@@ -197,9 +198,13 @@ impl Core {
sysopt.guard_proxy();
}
if system_proxy.is_some() || tun_mode.is_some() {
// 更新tray
if language.is_some() {
let handle = self.handle.lock();
handle.update_systray()?;
} else if system_proxy.is_some() || tun_mode.is_some() {
let handle = self.handle.lock();
handle.update_systray_part()?;
}
if patch.hotkeys.is_some() {
@@ -232,7 +237,7 @@ impl Core {
// update tray
let handle = handle.lock();
handle.refresh_clash();
log_if_err!(handle.update_systray_clash());
log_if_err!(handle.update_systray_part());
});
Ok(())
@@ -284,9 +289,14 @@ impl Core {
match Service::set_config(clash_info, config).await {
Ok(_) => {
let handle = handle.lock();
handle.refresh_clash()
handle.refresh_clash();
handle.notice_message("set_config::ok".into(), "ok".into());
}
Err(err) => {
let handle = handle.lock();
handle.notice_message("set_config::error".into(), format!("{err}"));
log::error!(target: "app", "last {err}")
}
Err(err) => log::error!(target: "app", "{err}"),
}
});

View File

@@ -22,6 +22,9 @@ pub struct Service {
sidecar: Option<CommandChild>,
logs: Arc<RwLock<VecDeque<String>>>,
#[allow(unused)]
use_service_mode: bool,
}
impl Service {
@@ -31,6 +34,7 @@ impl Service {
Service {
sidecar: None,
logs: Arc::new(RwLock::new(queue)),
use_service_mode: false,
}
}
@@ -46,6 +50,8 @@ impl Service {
verge.enable_service_mode.clone().unwrap_or(false)
};
self.use_service_mode = enable;
if !enable {
return self.start_clash_by_sidecar();
}
@@ -74,14 +80,8 @@ impl Service {
{
let _ = self.stop_clash_by_sidecar();
let enable = {
let data = Data::global();
let verge = data.verge.lock();
verge.enable_service_mode.clone().unwrap_or(false)
};
if enable {
tauri::async_runtime::spawn(async move {
if self.use_service_mode {
tauri::async_runtime::block_on(async move {
log_if_err!(Self::stop_clash_by_service().await);
});
}
@@ -129,8 +129,15 @@ impl Service {
let app_dir = dirs::app_home_dir();
let app_dir = app_dir.as_os_str().to_str().unwrap();
// fix #212
let args = match clash_core.as_str() {
"clash-meta" => vec!["-m", "-d", app_dir],
_ => vec!["-d", app_dir],
};
let cmd = Command::new_sidecar(clash_core)?;
let (mut rx, cmd_child) = cmd.args(["-d", app_dir]).spawn()?;
let (mut rx, cmd_child) = cmd.args(args).spawn()?;
// 将pid写入文件中
let pid = cmd_child.pid();
@@ -165,6 +172,8 @@ impl Service {
log::error!(target: "app" ,"[clash error]: {}", err);
write_log(err);
}
CommandEvent::Error(err) => log::error!(target: "app" ,"{err}"),
CommandEvent::Terminated(_) => break,
_ => {}
}
}
@@ -212,8 +221,17 @@ impl Service {
let mut data = HashMap::new();
data.insert("path", temp_path.as_os_str().to_str().unwrap());
macro_rules! report_err {
($i: expr, $e: expr) => {
match $i {
4 => bail!($e),
_ => log::error!(target: "app", $e),
}
};
}
// retry 5 times
for _ in 0..5 {
for i in 0..5 {
let headers = headers.clone();
match reqwest::ClientBuilder::new().no_proxy().build() {
Ok(client) => {
@@ -223,14 +241,12 @@ impl Service {
204 => break,
// 配置有问题不重试
400 => bail!("failed to update clash config with status 400"),
status @ _ => {
log::error!(target: "app", "failed to activate clash with status \"{status}\"");
}
status @ _ => report_err!(i, "failed to activate clash with status \"{status}\""),
},
Err(err) => log::error!(target: "app", "{err}"),
Err(err) => report_err!(i, "{err}"),
}
}
Err(err) => log::error!(target: "app", "{err}"),
Err(err) => report_err!(i, "{err}"),
}
sleep(Duration::from_millis(500)).await;
}

View File

@@ -68,14 +68,14 @@ impl Sysopt {
self.cur_sysproxy.as_ref().unwrap().set_system_proxy()?;
}
// launchs the system proxy guard
// run the system proxy guard
self.guard_proxy();
Ok(())
}
/// update the system proxy
pub fn update_sysproxy(&mut self) -> Result<()> {
if self.cur_sysproxy.is_none() {
if self.cur_sysproxy.is_none() || self.old_sysproxy.is_none() {
return self.init_sysproxy();
}
@@ -99,29 +99,28 @@ impl Sysopt {
/// reset the sysproxy
pub fn reset_sysproxy(&mut self) -> Result<()> {
if self.cur_sysproxy.is_none() {
return Ok(());
}
let cur = self.cur_sysproxy.take();
let mut cur = self.cur_sysproxy.take().unwrap();
if let Some(mut old) = self.old_sysproxy.take() {
// 如果原代理和当前代理 端口一致就disable关闭否则就恢复原代理设置
// 当前没有设置代理的时候,不确定旧设置是否和当前一致,全关了
let port_same = cur.map_or(true, |cur| old.port == cur.port);
match self.old_sysproxy.take() {
Some(old) => {
// 如果原代理设置和当前的设置是一样的,需要关闭
// 否则就恢复原代理设置
if old.enable && old.host == cur.host && old.port == cur.port {
cur.enable = false;
cur.set_system_proxy()?;
} else {
old.set_system_proxy()?;
}
}
None => {
if cur.enable {
cur.enable = false;
cur.set_system_proxy()?;
}
if old.enable && port_same {
old.enable = false;
log::info!(target: "app", "reset proxy by disabling the original proxy");
} else {
log::info!(target: "app", "reset proxy to the original proxy");
}
old.set_system_proxy()?;
} else if let Some(mut cur @ Sysproxy { enable: true, .. }) = cur {
// 没有原代理就按现在的代理设置disable即可
log::info!(target: "app", "reset proxy by disabling the current proxy");
cur.enable = false;
cur.set_system_proxy()?;
} else {
log::info!(target: "app", "reset proxy with no action");
}
Ok(())
@@ -170,6 +169,12 @@ impl Sysopt {
self.auto_launch = Some(auto);
// 避免在开发时将自启动关了
#[cfg(feature = "verge-dev")]
if !enable {
return Ok(());
}
let auto = self.auto_launch.as_ref().unwrap();
// macos每次启动都更新登录项避免重复设置登录项

124
src-tauri/src/core/tray.rs Normal file
View File

@@ -0,0 +1,124 @@
use crate::{data::Data, feat, utils::resolve};
use anyhow::{Ok, Result};
use tauri::{
api, AppHandle, CustomMenuItem, Manager, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem,
SystemTraySubmenu,
};
pub struct Tray {}
impl Tray {
pub fn tray_menu(app_handle: &AppHandle) -> SystemTrayMenu {
let data = Data::global();
let zh = {
let verge = data.verge.lock();
verge.language == Some("zh".into())
};
let version = app_handle.package_info().version.to_string();
if zh {
SystemTrayMenu::new()
.add_item(CustomMenuItem::new("open_window", "打开面板"))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("rule_mode", "规则模式"))
.add_item(CustomMenuItem::new("global_mode", "全局模式"))
.add_item(CustomMenuItem::new("direct_mode", "直连模式"))
.add_item(CustomMenuItem::new("script_mode", "脚本模式"))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("system_proxy", "系统代理"))
.add_item(CustomMenuItem::new("tun_mode", "TUN 模式"))
.add_submenu(SystemTraySubmenu::new(
"更多",
SystemTrayMenu::new()
.add_item(CustomMenuItem::new("restart_clash", "重启 Clash"))
.add_item(CustomMenuItem::new("restart_app", "重启应用"))
.add_item(CustomMenuItem::new("app_version", format!("Version {version}")).disabled()),
))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("quit", "退出").accelerator("CmdOrControl+Q"))
} else {
SystemTrayMenu::new()
.add_item(CustomMenuItem::new("open_window", "Dashboard"))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("rule_mode", "Rule Mode"))
.add_item(CustomMenuItem::new("global_mode", "Global Mode"))
.add_item(CustomMenuItem::new("direct_mode", "Direct Mode"))
.add_item(CustomMenuItem::new("script_mode", "Script Mode"))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("system_proxy", "System Proxy"))
.add_item(CustomMenuItem::new("tun_mode", "Tun Mode"))
.add_submenu(SystemTraySubmenu::new(
"More",
SystemTrayMenu::new()
.add_item(CustomMenuItem::new("restart_clash", "Restart Clash"))
.add_item(CustomMenuItem::new("restart_app", "Restart App"))
.add_item(CustomMenuItem::new("app_version", format!("Version {version}")).disabled()),
))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("quit", "Quit").accelerator("CmdOrControl+Q"))
}
}
pub fn update_systray(app_handle: &AppHandle) -> Result<()> {
app_handle
.tray_handle()
.set_menu(Tray::tray_menu(app_handle))?;
Tray::update_part(app_handle)?;
Ok(())
}
pub fn update_part(app_handle: &AppHandle) -> Result<()> {
let global = Data::global();
let clash = global.clash.lock();
let mode = clash
.config
.get(&serde_yaml::Value::from("mode"))
.map(|val| val.as_str().unwrap_or("rule"))
.unwrap_or("rule");
let tray = app_handle.tray_handle();
let _ = tray.get_item("rule_mode").set_selected(mode == "rule");
let _ = tray.get_item("global_mode").set_selected(mode == "global");
let _ = tray.get_item("direct_mode").set_selected(mode == "direct");
let _ = tray.get_item("script_mode").set_selected(mode == "script");
let verge = global.verge.lock();
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
let _ = tray.get_item("system_proxy").set_selected(*system_proxy);
let _ = tray.get_item("tun_mode").set_selected(*tun_mode);
Ok(())
}
pub fn on_system_tray_event(app_handle: &AppHandle, event: SystemTrayEvent) {
match event {
SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
mode @ ("rule_mode" | "global_mode" | "direct_mode" | "script_mode") => {
let mode = &mode[0..mode.len() - 5];
feat::change_clash_mode(mode);
}
"open_window" => resolve::create_window(app_handle),
"system_proxy" => feat::toggle_system_proxy(),
"tun_mode" => feat::toggle_tun_mode(),
"restart_clash" => feat::restart_clash_core(),
"restart_app" => api::process::restart(&app_handle.env()),
"quit" => {
resolve::resolve_reset();
api::process::kill_children();
app_handle.exit(0);
}
_ => {}
},
#[cfg(target_os = "windows")]
SystemTrayEvent::LeftClick { .. } => {
resolve::create_window(app_handle);
}
_ => {}
}
}
}

View File

@@ -8,16 +8,10 @@ pub use self::prfitem::*;
pub use self::profiles::*;
pub use self::verge::*;
use once_cell::sync::Lazy;
use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use std::sync::Arc;
static DATA: Lazy<Data> = Lazy::new(|| Data {
clash: Arc::new(Mutex::new(Clash::new())),
verge: Arc::new(Mutex::new(Verge::new())),
profiles: Arc::new(Mutex::new(Profiles::new())),
});
#[derive(Debug, Clone)]
pub struct Data {
pub clash: Arc<Mutex<Clash>>,
@@ -26,7 +20,13 @@ pub struct Data {
}
impl Data {
pub fn global() -> Data {
DATA.clone()
pub fn global() -> &'static Data {
static DATA: OnceCell<Data> = OnceCell::new();
DATA.get_or_init(|| Data {
clash: Arc::new(Mutex::new(Clash::new())),
verge: Arc::new(Mutex::new(Verge::new())),
profiles: Arc::new(Mutex::new(Profiles::new())),
})
}
}

View File

@@ -1,8 +1,10 @@
use crate::utils::{config, dirs, help, tmpl};
use anyhow::{bail, Context, Result};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use serde_yaml::Mapping;
use std::fs;
use sysproxy::Sysproxy;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PrfItem {
@@ -69,39 +71,31 @@ pub struct PrfOption {
pub user_agent: Option<String>,
/// for `remote` profile
/// use system proxy
#[serde(skip_serializing_if = "Option::is_none")]
pub with_proxy: Option<bool>,
/// for `remote` profile
/// use self proxy
#[serde(skip_serializing_if = "Option::is_none")]
pub self_proxy: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub update_interval: Option<u64>,
}
impl PrfOption {
pub fn merge(one: Option<Self>, other: Option<Self>) -> Option<Self> {
if one.is_none() {
return other;
match (one, other) {
(Some(mut a), Some(b)) => {
a.user_agent = b.user_agent.or(a.user_agent);
a.with_proxy = b.with_proxy.or(a.with_proxy);
a.self_proxy = b.self_proxy.or(a.self_proxy);
a.update_interval = b.update_interval.or(a.update_interval);
Some(a)
}
t @ _ => t.0.or(t.1),
}
if one.is_some() && other.is_some() {
let mut one = one.unwrap();
let other = other.unwrap();
if let Some(val) = other.user_agent {
one.user_agent = Some(val);
}
if let Some(val) = other.with_proxy {
one.with_proxy = Some(val);
}
if let Some(val) = other.update_interval {
one.update_interval = Some(val);
}
return Some(one);
}
return one;
}
}
@@ -189,27 +183,64 @@ impl PrfItem {
desc: Option<String>,
option: Option<PrfOption>,
) -> Result<PrfItem> {
let with_proxy = match option.as_ref() {
Some(opt) => opt.with_proxy.unwrap_or(false),
None => false,
};
let user_agent = match option.as_ref() {
Some(opt) => opt.user_agent.clone(),
None => None,
};
let opt_ref = option.as_ref();
let with_proxy = opt_ref.map_or(false, |o| o.with_proxy.unwrap_or(false));
let self_proxy = opt_ref.map_or(false, |o| o.self_proxy.unwrap_or(false));
let user_agent = opt_ref.map_or(None, |o| o.user_agent.clone());
let mut builder = reqwest::ClientBuilder::new();
let mut builder = reqwest::ClientBuilder::new().no_proxy();
if !with_proxy {
builder = builder.no_proxy();
// 使用软件自己的代理
if self_proxy {
let data = super::Data::global();
let port = data.clash.lock().info.port.clone();
let port = port.ok_or(anyhow::anyhow!("failed to get clash info port"))?;
let proxy_scheme = format!("http://127.0.0.1:{port}");
if let Ok(proxy) = reqwest::Proxy::http(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = reqwest::Proxy::https(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = reqwest::Proxy::all(&proxy_scheme) {
builder = builder.proxy(proxy);
}
}
// 使用系统代理
else if with_proxy {
match Sysproxy::get_system_proxy() {
Ok(p @ Sysproxy { enable: true, .. }) => {
let proxy_scheme = format!("http://{}:{}", p.host, p.port);
if let Ok(proxy) = reqwest::Proxy::http(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = reqwest::Proxy::https(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = reqwest::Proxy::all(&proxy_scheme) {
builder = builder.proxy(proxy);
}
}
_ => {}
};
}
builder = builder.user_agent(user_agent.unwrap_or("clash-verge/v1.0.0".into()));
let version = unsafe { dirs::APP_VERSION };
let version = format!("clash-verge/{version}");
builder = builder.user_agent(user_agent.unwrap_or(version));
let resp = builder.build()?.get(url).send().await?;
let status_code = resp.status();
if !StatusCode::is_success(&status_code) {
bail!("failed to fetch remote profile with status {status_code}")
}
let header = resp.headers();
// parse the Subscription Userinfo
// parse the Subscription UserInfo
let extra = match header.get("Subscription-Userinfo") {
Some(value) => {
let sub_info = value.to_str().unwrap_or("");
@@ -251,8 +282,11 @@ impl PrfItem {
let data = resp.text_with_charset("utf-8").await?;
// check the data whether the valid yaml format
if !serde_yaml::from_str::<Mapping>(&data).is_ok() {
bail!("the remote profile data is invalid yaml");
let yaml = serde_yaml::from_str::<Mapping>(&data) //
.context("the remote profile data is invalid yaml")?;
if !yaml.contains_key("proxies") && !yaml.contains_key("proxy-providers") {
bail!("profile does not contain `proxies` or `proxy-providers`");
}
Ok(PrfItem {

View File

@@ -2,6 +2,15 @@ use crate::core::*;
use crate::data::*;
use crate::log_if_err;
// 重启clash
pub fn restart_clash_core() {
let core = Core::global();
let mut service = core.service.lock();
log_if_err!(service.restart());
drop(service);
log_if_err!(core.activate());
}
// 切换模式
pub fn change_clash_mode(mode: &str) {
let core = Core::global();

View File

@@ -10,81 +10,29 @@ mod data;
mod feat;
mod utils;
use crate::{
data::Verge,
utils::{resolve, server},
};
use tauri::{
api, CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem,
};
use crate::utils::{init, resolve, server};
use tauri::{api, Manager, SystemTray};
fn main() -> std::io::Result<()> {
let mut context = tauri::generate_context!();
let verge = Verge::new();
if server::check_singleton(verge.app_singleton_port).is_err() {
// 单例检测
if server::check_singleton().is_err() {
println!("app exists");
return Ok(());
}
for win in context.config_mut().tauri.windows.iter_mut() {
if verge.enable_silent_start.unwrap_or(false) {
win.visible = false;
}
}
#[cfg(target_os = "windows")]
unsafe {
use crate::utils::dirs;
dirs::init_portable_flag();
}
let tray_menu = SystemTrayMenu::new()
.add_item(CustomMenuItem::new("open_window", "Show"))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("rule_mode", "Rule Mode"))
.add_item(CustomMenuItem::new("global_mode", "Global Mode"))
.add_item(CustomMenuItem::new("direct_mode", "Direct Mode"))
.add_item(CustomMenuItem::new("script_mode", "Script Mode"))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("system_proxy", "System Proxy"))
.add_item(CustomMenuItem::new("tun_mode", "Tun Mode"))
.add_item(CustomMenuItem::new("restart_clash", "Restart Clash"))
.add_item(CustomMenuItem::new("restart_app", "Restart App"))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("quit", "Quit").accelerator("CmdOrControl+Q"));
crate::log_if_err!(init::init_config());
#[allow(unused_mut)]
let mut builder = tauri::Builder::default()
.setup(|app| Ok(resolve::resolve_setup(app)))
.system_tray(SystemTray::new().with_menu(tray_menu))
.on_system_tray_event(move |app_handle, event| match event {
SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
"open_window" => {
resolve::create_window(app_handle);
}
mode @ ("rule_mode" | "global_mode" | "direct_mode" | "script_mode") => {
let mode = &mode[0..mode.len() - 5];
feat::change_clash_mode(mode);
}
"system_proxy" => feat::toggle_system_proxy(),
"tun_mode" => feat::toggle_tun_mode(),
"restart_app" => {
api::process::restart(&app_handle.env());
}
"quit" => {
resolve::resolve_reset();
app_handle.exit(0);
}
_ => {}
},
#[cfg(target_os = "windows")]
SystemTrayEvent::LeftClick { .. } => {
resolve::create_window(app_handle);
}
_ => {}
})
.system_tray(SystemTray::new())
.on_system_tray_event(core::tray::Tray::on_system_tray_event)
.invoke_handler(tauri::generate_handler![
// common
cmds::get_sys_proxy,
@@ -132,23 +80,28 @@ fn main() -> std::io::Result<()> {
{
use tauri::{Menu, MenuItem, Submenu};
let submenu_file = Submenu::new(
"File",
Menu::new()
.add_native_item(MenuItem::Undo)
.add_native_item(MenuItem::Redo)
.add_native_item(MenuItem::Copy)
.add_native_item(MenuItem::Paste)
.add_native_item(MenuItem::Cut)
.add_native_item(MenuItem::SelectAll),
builder = builder.menu(
Menu::new().add_submenu(Submenu::new(
"File",
Menu::new()
.add_native_item(MenuItem::Undo)
.add_native_item(MenuItem::Redo)
.add_native_item(MenuItem::Copy)
.add_native_item(MenuItem::Paste)
.add_native_item(MenuItem::Cut)
.add_native_item(MenuItem::SelectAll),
)),
);
builder = builder.menu(Menu::new().add_submenu(submenu_file));
}
let app = builder
.build(context)
#[allow(unused_mut)]
let mut app = builder
.build(tauri::generate_context!())
.expect("error while running tauri application");
#[cfg(target_os = "macos")]
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
let app_handle = app.app_handle();
ctrlc::set_handler(move || {
resolve::resolve_reset();
@@ -164,6 +117,7 @@ fn main() -> std::io::Result<()> {
tauri::RunEvent::Exit => {
resolve::resolve_reset();
api::process::kill_children();
app_handle.exit(0);
}
#[cfg(target_os = "macos")]
tauri::RunEvent::WindowEvent { label, event, .. } => {

View File

@@ -21,6 +21,8 @@ static mut RESOURCE_DIR: Option<PathBuf> = None;
#[allow(unused)]
static mut PORTABLE_FLAG: bool = false;
pub static mut APP_VERSION: &str = "v1.1.1";
/// initialize portable flag
#[allow(unused)]
pub unsafe fn init_portable_flag() {
@@ -66,6 +68,10 @@ pub fn app_resources_dir(package_info: &PackageInfo) -> PathBuf {
unsafe {
RESOURCE_DIR = Some(res_dir.clone());
let ver = package_info.version.to_string();
let ver_str = format!("v{ver}");
APP_VERSION = Box::leak(Box::new(ver_str));
}
res_dir

View File

@@ -1,4 +1,4 @@
use anyhow::{bail, Result};
use anyhow::Result;
use nanoid::nanoid;
use std::path::PathBuf;
use std::process::Command;
@@ -28,20 +28,16 @@ pub fn get_uid(prefix: &str) -> String {
/// parse the string
/// xxx=123123; => 123123
pub fn parse_str<T: FromStr>(target: &str, key: &str) -> Option<T> {
match target.find(key) {
Some(idx) => {
let idx = idx + key.len();
let value = &target[idx..];
match match value.split(';').nth(0) {
Some(value) => value.trim().parse(),
None => value.trim().parse(),
} {
Ok(r) => Some(r),
Err(_) => None,
}
target.find(key).and_then(|idx| {
let idx = idx + key.len();
let value = &target[idx..];
match value.split(';').nth(0) {
Some(value) => value.trim().parse(),
None => value.trim().parse(),
}
None => None,
}
.ok()
})
}
/// open file
@@ -49,22 +45,21 @@ pub fn parse_str<T: FromStr>(target: &str, key: &str) -> Option<T> {
pub fn open_file(path: PathBuf) -> Result<()> {
// use vscode first
if let Ok(code) = which::which("code") {
let mut command = Command::new(&code);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
if let Err(err) = Command::new(code)
.creation_flags(0x08000000)
.arg(path)
.spawn()
{
bail!("failed to open file by VScode for `{err}`");
if let Err(err) = command.creation_flags(0x08000000).arg(&path).spawn() {
log::error!(target: "app", "failed to open with VScode `{err}`");
open::that(path)?;
}
}
#[cfg(not(target_os = "windows"))]
if let Err(err) = Command::new(code).arg(path).spawn() {
bail!("failed to open file by VScode for `{err}`");
if let Err(err) = command.arg(&path).spawn() {
log::error!(target: "app", "failed to open with VScode `{err}`");
open::that(path)?;
}
return Ok(());

View File

@@ -8,11 +8,15 @@ use log4rs::config::{Appender, Config, Logger, Root};
use log4rs::encode::pattern::PatternEncoder;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use tauri::PackageInfo;
/// initialize this instance's log file
fn init_log(log_dir: &PathBuf) -> Result<()> {
fn init_log() -> Result<()> {
let log_dir = dirs::app_logs_dir();
if !log_dir.exists() {
let _ = fs::create_dir_all(&log_dir);
}
let local_time = Local::now().format("%Y-%m-%d-%H%M%S").to_string();
let log_file = format!("{}.log", local_time);
let log_file = log_dir.join(log_file);
@@ -42,7 +46,19 @@ fn init_log(log_dir: &PathBuf) -> Result<()> {
}
/// Initialize all the files from resources
fn init_config(app_dir: &PathBuf) -> Result<()> {
pub fn init_config() -> Result<()> {
let _ = init_log();
let app_dir = dirs::app_home_dir();
let profiles_dir = dirs::app_profiles_dir();
if !app_dir.exists() {
let _ = fs::create_dir_all(&app_dir);
}
if !profiles_dir.exists() {
let _ = fs::create_dir_all(&profiles_dir);
}
// target path
let clash_path = app_dir.join("config.yaml");
let verge_path = app_dir.join("verge.yaml");
@@ -61,27 +77,14 @@ fn init_config(app_dir: &PathBuf) -> Result<()> {
}
/// initialize app
pub fn init_app(package_info: &PackageInfo) {
pub fn init_resources(package_info: &PackageInfo) {
// create app dir
let app_dir = dirs::app_home_dir();
let log_dir = dirs::app_logs_dir();
let profiles_dir = dirs::app_profiles_dir();
let res_dir = dirs::app_resources_dir(package_info);
if !app_dir.exists() {
let _ = fs::create_dir_all(&app_dir);
}
if !log_dir.exists() {
let _ = fs::create_dir_all(&log_dir);
}
if !profiles_dir.exists() {
let _ = fs::create_dir_all(&profiles_dir);
}
crate::log_if_err!(init_log(&log_dir));
crate::log_if_err!(init_config(&app_dir));
// copy the resource file
let mmdb_path = app_dir.join("Country.mmdb");

View File

@@ -1,25 +1,37 @@
use crate::{core::Core, data::Data, utils::init, utils::server};
use crate::{
core::{tray, Core},
data::Data,
utils::init,
utils::server,
};
use tauri::{App, AppHandle, Manager};
/// handle something when start app
pub fn resolve_setup(app: &App) {
// init app config
init::init_app(app.package_info());
let _ = app
.tray_handle()
.set_menu(tray::Tray::tray_menu(&app.app_handle()));
{
init::init_resources(app.package_info());
let silent_start = {
let global = Data::global();
let verge = global.verge.lock();
let singleton = verge.app_singleton_port.clone();
// setup a simple http server for singleton
server::embed_server(&app.handle(), singleton);
}
server::embed_server(&app.app_handle(), singleton);
verge.enable_silent_start.clone().unwrap_or(false)
};
// core should be initialized after init_app fix #122
let core = Core::global();
core.init(app.app_handle());
resolve_window(app);
if !silent_start {
create_window(&app.app_handle());
}
}
/// reset system proxy
@@ -33,39 +45,6 @@ pub fn resolve_reset() {
crate::log_if_err!(service.stop());
}
/// customize the window theme
fn resolve_window(app: &App) {
let window = app.get_window("main").unwrap();
#[cfg(target_os = "windows")]
{
use crate::utils::winhelp;
use window_shadows::set_shadow;
use window_vibrancy::apply_blur;
let _ = window.set_decorations(false);
let _ = set_shadow(&window, true);
// todo
// win11 disable this feature temporarily due to lag
if !winhelp::is_win11() {
let _ = apply_blur(&window, None);
}
}
#[cfg(target_os = "macos")]
{
use tauri::LogicalSize;
use tauri::Size::Logical;
let _ = window.set_decorations(true);
let _ = window.set_size(Logical(LogicalSize {
width: 800.0,
height: 620.0,
}));
}
}
/// create main window
pub fn create_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_window("main") {
@@ -120,7 +99,7 @@ pub fn create_window(app_handle: &AppHandle) {
}
#[cfg(target_os = "macos")]
crate::log_if_err!(builder.decorations(true).inner_size(800.0, 620.0).build());
crate::log_if_err!(builder.decorations(true).inner_size(800.0, 642.0).build());
#[cfg(target_os = "linux")]
crate::log_if_err!(builder

View File

@@ -1,6 +1,7 @@
extern crate warp;
use super::resolve;
use crate::data::Verge;
use port_scanner::local_port_available;
use tauri::AppHandle;
use warp::Filter;
@@ -11,8 +12,9 @@ const SERVER_PORT: u16 = 33331;
const SERVER_PORT: u16 = 11233;
/// check whether there is already exists
pub fn check_singleton(port: Option<u16>) -> Result<(), ()> {
let port = port.unwrap_or(SERVER_PORT);
pub fn check_singleton() -> Result<(), ()> {
let verge = Verge::new();
let port = verge.app_singleton_port.unwrap_or(SERVER_PORT);
if !local_port_available(port) {
tauri::async_runtime::block_on(async {

View File

@@ -1,7 +1,7 @@
{
"package": {
"productName": "Clash Verge",
"version": "1.1.0"
"version": "1.1.2"
},
"build": {
"distDir": "../dist",
@@ -81,20 +81,7 @@
"all": true
}
},
"windows": [
{
"title": "Clash Verge",
"width": 800,
"height": 636,
"center": true,
"resizable": true,
"fullscreen": false,
"decorations": false,
"transparent": true,
"minWidth": 600,
"minHeight": 520
}
],
"windows": [],
"security": {
"csp": "script-src 'unsafe-eval' 'self'; default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self'; img-src data: 'self';"
}

View File

@@ -31,3 +31,9 @@ body {
@import "./layout.scss";
@import "./page.scss";
@media (prefers-color-scheme: dark) {
:root {
background-color: rgba(18, 18, 18, 1);
}
}

View File

@@ -7,7 +7,7 @@
> header {
flex: 0 0 58px;
width: 90%;
max-width: 850px;
// max-width: 850px;
margin: 0 auto;
padding-right: 4px;
box-sizing: border-box;
@@ -28,7 +28,7 @@
.base-content {
width: 90%;
max-width: 850px;
// max-width: 850px;
margin: 0 auto;
}
}

View File

@@ -67,7 +67,6 @@ export default function useCustomTheme() {
const scrollColor = mode === "light" ? "#90939980" : "#54545480";
const rootEle = document.documentElement;
rootEle.style.background = "transparent";
rootEle.style.setProperty("--selection-color", selectColor);
rootEle.style.setProperty("--scroller-color", scrollColor);
rootEle.style.setProperty("--primary-main", theme.palette.primary.main);

View File

@@ -33,7 +33,7 @@ const EnhancedMode = (props: Props) => {
try {
await enhanceProfiles();
mutateLogs();
Notice.success("Refresh clash config", 1000);
// Notice.success("Refresh clash config", 1000);
} catch (err: any) {
Notice.error(err.message || err.toString());
}

View File

@@ -4,15 +4,19 @@ import { useLockFn, useSetState } from "ahooks";
import { useTranslation } from "react-i18next";
import {
Button,
Collapse,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
Switch,
TextField,
} from "@mui/material";
import { Settings } from "@mui/icons-material";
import { patchProfile } from "@/services/cmds";
import { version } from "@root/package.json";
import Notice from "../base/base-notice";
interface Props {
@@ -33,11 +37,15 @@ const InfoEditor = (props: Props) => {
useEffect(() => {
if (itemData) {
const { option } = itemData;
setForm({ ...itemData });
setOption(itemData.option ?? {});
setOption(option ?? {});
setShowOpt(
itemData.type === "remote" &&
(!!itemData.option?.user_agent || !!itemData.option?.update_interval)
(!!option?.user_agent ||
!!option?.update_interval ||
!!option?.self_proxy ||
!!option?.with_proxy)
);
}
}, [itemData]);
@@ -114,18 +122,7 @@ const InfoEditor = (props: Props) => {
/>
)}
{showOpt && (
<TextField
{...textFieldProps}
label="User Agent"
value={option.user_agent}
placeholder="clash-verge/v1.0.0"
onChange={(e) => setOption({ user_agent: e.target.value })}
onKeyDown={(e) => e.key === "Enter" && onUpdate()}
/>
)}
{((type === "remote" && showOpt) || type === "local") && (
{(type === "remote" || type === "local") && (
<TextField
{...textFieldProps}
label={t("Update Interval(mins)")}
@@ -137,6 +134,57 @@ const InfoEditor = (props: Props) => {
onKeyDown={(e) => e.key === "Enter" && onUpdate()}
/>
)}
<Collapse
in={type === "remote" && showOpt}
timeout="auto"
unmountOnExit
>
<TextField
{...textFieldProps}
label="User Agent"
value={option.user_agent}
placeholder={`clash-verge/v${version}`}
onChange={(e) => setOption({ user_agent: e.target.value })}
onKeyDown={(e) => e.key === "Enter" && onUpdate()}
/>
<FormControlLabel
label={t("Use System Proxy")}
labelPlacement="start"
sx={{ ml: 0, my: 1 }}
control={
<Switch
color="primary"
checked={option.with_proxy ?? false}
onChange={(_e, c) =>
setOption((o) => ({
self_proxy: c ? false : o.self_proxy ?? false,
with_proxy: c,
}))
}
/>
}
/>
<FormControlLabel
label={t("Use Clash Proxy")}
labelPlacement="start"
sx={{ ml: 0, my: 1 }}
control={
<Switch
color="primary"
checked={option.self_proxy ?? false}
onChange={(_e, c) =>
setOption((o) => ({
with_proxy: c ? false : o.with_proxy ?? false,
self_proxy: c,
}))
}
/>
}
/>
</Collapse>
</DialogContent>
<DialogActions sx={{ px: 2, pb: 2, position: "relative" }}>

View File

@@ -110,12 +110,32 @@ const ProfileItem = (props: Props) => {
}
});
const onUpdate = useLockFn(async (withProxy: boolean) => {
/// 0 不使用任何代理
/// 1 使用配置好的代理
/// 2 至少使用一个代理,根据配置,如果没配置,默认使用系统代理
const onUpdate = useLockFn(async (type: 0 | 1 | 2) => {
setAnchorEl(null);
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }));
const option: Partial<CmdType.ProfileOption> = {};
if (type === 0) {
option.with_proxy = false;
option.self_proxy = false;
} else if (type === 1) {
// nothing
} else if (type === 2) {
if (itemData.option?.self_proxy) {
option.with_proxy = false;
option.self_proxy = true;
} else {
option.with_proxy = true;
option.self_proxy = false;
}
}
try {
await updateProfile(itemData.uid, { with_proxy: withProxy });
await updateProfile(itemData.uid, option);
mutate("getProfiles");
} catch (err: any) {
const errmsg = err?.message || err.toString();
@@ -142,8 +162,8 @@ const ProfileItem = (props: Props) => {
{ label: "Edit Info", handler: onEditInfo },
{ label: "Edit File", handler: onEditFile },
{ label: "Open File", handler: onOpenFile },
{ label: "Update", handler: () => onUpdate(false) },
{ label: "Update(Proxy)", handler: () => onUpdate(true) },
{ label: "Update", handler: () => onUpdate(0) },
{ label: "Update(Proxy)", handler: () => onUpdate(2) },
{ label: "Delete", handler: onDelete },
];
const fileModeMenu = [
@@ -199,7 +219,7 @@ const ProfileItem = (props: Props) => {
disabled={loading}
onClick={(e) => {
e.stopPropagation();
onUpdate(false);
onUpdate(1);
}}
>
<RefreshRounded color="inherit" />

View File

@@ -4,15 +4,18 @@ import { useTranslation } from "react-i18next";
import { useLockFn, useSetState } from "ahooks";
import {
Button,
Collapse,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
IconButton,
InputLabel,
MenuItem,
Select,
Switch,
TextField,
} from "@mui/material";
import { Settings } from "@mui/icons-material";
@@ -40,7 +43,11 @@ const ProfileNew = (props: Props) => {
const [showOpt, setShowOpt] = useState(false);
// can add more option
const [option, setOption] = useSetState({ user_agent: "" });
const [option, setOption] = useSetState({
user_agent: "",
with_proxy: false,
self_proxy: false,
});
// file input
const fileDataRef = useRef<string | null>(null);
@@ -132,7 +139,11 @@ const ProfileNew = (props: Props) => {
<FileInput onChange={(val) => (fileDataRef.current = val)} />
)}
{showOpt && (
<Collapse
in={form.type === "remote" && showOpt}
timeout="auto"
unmountOnExit
>
<TextField
{...textFieldProps}
label="User Agent"
@@ -140,7 +151,41 @@ const ProfileNew = (props: Props) => {
value={option.user_agent}
onChange={(e) => setOption({ user_agent: e.target.value })}
/>
)}
<FormControlLabel
label={t("Use System Proxy")}
labelPlacement="start"
sx={{ ml: 0, my: 1 }}
control={
<Switch
color="primary"
checked={option.with_proxy}
onChange={(_e, c) =>
setOption((o) => ({
self_proxy: c ? false : o.self_proxy,
with_proxy: c,
}))
}
/>
}
/>
<FormControlLabel
label={t("Use Clash Proxy")}
labelPlacement="start"
sx={{ ml: 0, my: 1 }}
control={
<Switch
color="primary"
checked={option.self_proxy}
onChange={(_e, c) =>
setOption((o) => ({
with_proxy: c ? false : o.with_proxy,
self_proxy: c,
}))
}
/>
}
/>
</Collapse>
</DialogContent>
<DialogActions sx={{ px: 2, pb: 2, position: "relative" }}>

View File

@@ -5,9 +5,8 @@ import { Virtuoso } from "react-virtuoso";
import { providerHealthCheck, updateProxy } from "@/services/api";
import { getProfiles, patchProfile } from "@/services/cmds";
import delayManager from "@/services/delay";
import useSortProxy from "./use-sort-proxy";
import useHeadState from "./use-head-state";
import useFilterProxy from "./use-filter-proxy";
import useFilterSort from "./use-filter-sort";
import ProxyHead from "./proxy-head";
import ProxyItem from "./proxy-item";
@@ -27,14 +26,10 @@ const ProxyGlobal = (props: Props) => {
const [headState, setHeadState] = useHeadState(groupName);
const virtuosoRef = useRef<any>();
const filterProxies = useFilterProxy(
const sortedProxies = useFilterSort(
proxies,
groupName,
headState.filterText
);
const sortedProxies = useSortProxy(
filterProxies,
groupName,
headState.filterText,
headState.sortType
);
@@ -85,13 +80,12 @@ const ProxyGlobal = (props: Props) => {
}
await delayManager.checkListDelay(
{
names: sortedProxies.filter((p) => !p.provider).map((p) => p.name),
groupName,
skipNum: 16,
},
() => mutate("getProxies")
sortedProxies.filter((p) => !p.provider).map((p) => p.name),
groupName,
16
);
mutate("getProxies");
});
useEffect(() => onLocation(false), [groupName]);

View File

@@ -18,9 +18,8 @@ import {
import { providerHealthCheck, updateProxy } from "@/services/api";
import { getProfiles, patchProfile } from "@/services/cmds";
import delayManager from "@/services/delay";
import useSortProxy from "./use-sort-proxy";
import useHeadState from "./use-head-state";
import useFilterProxy from "./use-filter-proxy";
import useFilterSort from "./use-filter-sort";
import ProxyHead from "./proxy-head";
import ProxyItem from "./proxy-item";
@@ -35,14 +34,10 @@ const ProxyGroup = ({ group }: Props) => {
const [headState, setHeadState] = useHeadState(group.name);
const virtuosoRef = useRef<any>();
const filterProxies = useFilterProxy(
const sortedProxies = useFilterSort(
group.all,
group.name,
headState.filterText
);
const sortedProxies = useSortProxy(
filterProxies,
group.name,
headState.filterText,
headState.sortType
);
@@ -105,21 +100,29 @@ const ProxyGroup = ({ group }: Props) => {
}
await delayManager.checkListDelay(
{
names: sortedProxies.filter((p) => !p.provider).map((p) => p.name),
groupName: group.name,
skipNum: 16,
},
() => mutate("getProxies")
sortedProxies.filter((p) => !p.provider).map((p) => p.name),
group.name,
16
);
mutate("getProxies");
});
// auto scroll to current index
useEffect(() => {
if (headState.open) {
setTimeout(() => onLocation(false), 5);
setTimeout(() => onLocation(false), 10);
}
}, [headState.open]);
}, [headState.open, sortedProxies]);
// auto scroll when sorted changed
const timerRef = useRef<any>();
useEffect(() => {
if (headState.open) {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => onLocation(false), 500);
}
}, [headState.open, sortedProxies]);
return (
<>

View File

@@ -16,7 +16,7 @@ import {
} from "@mui/icons-material";
import delayManager from "@/services/delay";
import type { HeadState } from "./use-head-state";
import type { ProxySortType } from "./use-sort-proxy";
import type { ProxySortType } from "./use-filter-sort";
interface Props {
sx?: SxProps;

View File

@@ -49,6 +49,14 @@ const ProxyItem = (props: Props) => {
// -2 为 loading
const [delay, setDelay] = useState(-1);
useEffect(() => {
delayManager.setListener(proxy.name, groupName, setDelay);
return () => {
delayManager.removeListener(proxy.name, groupName);
};
}, [proxy.name, groupName]);
useEffect(() => {
if (!proxy) return;
@@ -66,10 +74,7 @@ const ProxyItem = (props: Props) => {
const onDelay = useLockFn(async () => {
setDelay(-2);
return delayManager
.checkDelay(proxy.name, groupName)
.then((result) => setDelay(result))
.catch(() => setDelay(1e6));
setDelay(await delayManager.checkDelay(proxy.name, groupName));
});
return (

View File

@@ -1,49 +0,0 @@
import { useMemo } from "react";
import delayManager from "@/services/delay";
const regex1 = /delay([=<>])(\d+|timeout|error)/i;
const regex2 = /type=(.*)/i;
/**
* filter the proxy
* according to the regular conditions
*/
export default function useFilterProxy(
proxies: ApiType.ProxyItem[],
groupName: string,
filterText: string
) {
return useMemo(() => {
if (!proxies) return [];
if (!filterText) return proxies;
const res1 = regex1.exec(filterText);
if (res1) {
const symbol = res1[1];
const symbol2 = res1[2].toLowerCase();
const value =
symbol2 === "error" ? 1e5 : symbol2 === "timeout" ? 3000 : +symbol2;
return proxies.filter((p) => {
const delay = delayManager.getDelay(p.name, groupName);
if (delay < 0) return false;
if (symbol === "=" && symbol2 === "error") return delay >= 1e5;
if (symbol === "=" && symbol2 === "timeout")
return delay < 1e5 && delay >= 3000;
if (symbol === "=") return delay == value;
if (symbol === "<") return delay <= value;
if (symbol === ">") return delay >= value;
return false;
});
}
const res2 = regex2.exec(filterText);
if (res2) {
const type = res2[1].toLowerCase();
return proxies.filter((p) => p.type.toLowerCase().includes(type));
}
return proxies.filter((p) => p.name.includes(filterText.trim()));
}, [proxies, groupName, filterText]);
}

View File

@@ -0,0 +1,114 @@
import { useEffect, useMemo, useState } from "react";
import delayManager from "@/services/delay";
// default | delay | alphabet
export type ProxySortType = 0 | 1 | 2;
export default function useFilterSort(
proxies: ApiType.ProxyItem[],
groupName: string,
filterText: string,
sortType: ProxySortType
) {
const [refresh, setRefresh] = useState({});
useEffect(() => {
let last = 0;
delayManager.setGroupListener(groupName, () => {
// 简单节流
const now = Date.now();
if (now - last > 666) {
last = now;
setRefresh({});
}
});
return () => {
delayManager.removeGroupListener(groupName);
};
}, [groupName]);
return useMemo(() => {
const fp = filterProxies(proxies, groupName, filterText);
const sp = sortProxies(fp, groupName, sortType);
return sp;
}, [proxies, groupName, filterText, sortType, refresh]);
}
/**
* 可以通过延迟数/节点类型 过滤
*/
const regex1 = /delay([=<>])(\d+|timeout|error)/i;
const regex2 = /type=(.*)/i;
/**
* filter the proxy
* according to the regular conditions
*/
function filterProxies(
proxies: ApiType.ProxyItem[],
groupName: string,
filterText: string
) {
if (!filterText) return proxies;
const res1 = regex1.exec(filterText);
if (res1) {
const symbol = res1[1];
const symbol2 = res1[2].toLowerCase();
const value =
symbol2 === "error" ? 1e5 : symbol2 === "timeout" ? 3000 : +symbol2;
return proxies.filter((p) => {
const delay = delayManager.getDelay(p.name, groupName);
if (delay < 0) return false;
if (symbol === "=" && symbol2 === "error") return delay >= 1e5;
if (symbol === "=" && symbol2 === "timeout")
return delay < 1e5 && delay >= 3000;
if (symbol === "=") return delay == value;
if (symbol === "<") return delay <= value;
if (symbol === ">") return delay >= value;
return false;
});
}
const res2 = regex2.exec(filterText);
if (res2) {
const type = res2[1].toLowerCase();
return proxies.filter((p) => p.type.toLowerCase().includes(type));
}
return proxies.filter((p) => p.name.includes(filterText.trim()));
}
/**
* sort the proxy
*/
function sortProxies(
proxies: ApiType.ProxyItem[],
groupName: string,
sortType: ProxySortType
) {
if (!proxies) return [];
if (sortType === 0) return proxies;
const list = proxies.slice();
if (sortType === 1) {
list.sort((a, b) => {
const ad = delayManager.getDelay(a.name, groupName);
const bd = delayManager.getDelay(b.name, groupName);
if (ad === -1 || ad === -2) return 1;
if (bd === -1 || bd === -2) return -1;
return ad - bd;
});
} else {
list.sort((a, b) => a.name.localeCompare(b.name));
}
return list;
}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { useRecoilValue } from "recoil";
import { atomCurrentProfile } from "@/services/states";
import { ProxySortType } from "./use-sort-proxy";
import { ProxySortType } from "./use-filter-sort";
export interface HeadState {
open?: boolean;

View File

@@ -1,37 +0,0 @@
import { useMemo } from "react";
import delayManager from "@/services/delay";
// default | delay | alpha
export type ProxySortType = 0 | 1 | 2;
/**
* sort the proxy
*/
export default function useSortProxy(
proxies: ApiType.ProxyItem[],
groupName: string,
sortType: ProxySortType
) {
return useMemo(() => {
if (!proxies) return [];
if (sortType === 0) return proxies;
const list = proxies.slice();
if (sortType === 1) {
list.sort((a, b) => {
const ad = delayManager.getDelay(a.name, groupName);
const bd = delayManager.getDelay(b.name, groupName);
if (ad === -1) return 1;
if (bd === -1) return -1;
return ad - bd;
});
} else {
list.sort((a, b) => a.name.localeCompare(b.name));
}
return list;
}, [proxies, groupName, sortType]);
}

View File

@@ -93,7 +93,7 @@ const ClashFieldViewer = ({ handler }: Props) => {
try {
await changeProfileValid([...curSet]);
mutateProfile();
Notice.success("Refresh clash config", 1000);
// Notice.success("Refresh clash config", 1000);
} catch (err: any) {
Notice.error(err?.message || err.toString());
}

View File

@@ -14,6 +14,7 @@ import { getAxios } from "@/services/api";
import { atomCurrentProfile } from "@/services/states";
import { getVergeConfig, getProfiles } from "@/services/cmds";
import { ReactComponent as LogoSvg } from "@/assets/image/logo.svg";
import Notice from "@/components/base/base-notice";
import LayoutItem from "@/components/layout/layout-item";
import LayoutControl from "@/components/layout/layout-control";
import LayoutTraffic from "@/components/layout/layout-traffic";
@@ -59,6 +60,21 @@ const Layout = () => {
// update the verge config
listen("verge://refresh-verge-config", () => mutate("getVergeConfig"));
// 设置提示监听
listen("verge://notice-message", ({ payload }) => {
const [status, msg] = payload as [string, string];
switch (status) {
case "set_config::ok":
Notice.success("Refresh clash config");
break;
case "set_config::error":
Notice.error(msg);
break;
default:
break;
}
});
// set current profile uid
getProfiles().then((data) => setCurrentProfile(data.current ?? ""));
}, []);

View File

@@ -44,12 +44,12 @@ const ConnectionsPage = () => {
const filterConn = useMemo(() => {
const orderFunc = orderOpts[curOrderOpt];
const connetions = connData.connections.filter((conn) =>
const connections = connData.connections.filter((conn) =>
(conn.metadata.host || conn.metadata.destinationIP)?.includes(filterText)
);
if (orderFunc) return orderFunc(connetions);
return connetions;
if (orderFunc) return orderFunc(connections);
return connections;
}, [connData, filterText, curOrderOpt]);
useEffect(() => {
@@ -108,6 +108,7 @@ const ConnectionsPage = () => {
header={
<Box sx={{ mt: 1, display: "flex", alignItems: "center" }}>
<IconButton
color="inherit"
size="small"
sx={{ mr: 2 }}
onClick={() =>
@@ -167,6 +168,7 @@ const ConnectionsPage = () => {
fullWidth
size="small"
autoComplete="off"
spellCheck="false"
variant="outlined"
placeholder={t("Filter conditions")}
value={filterText}

View File

@@ -45,6 +45,7 @@ const LogPage = () => {
<Box sx={{ mt: 1, display: "flex", alignItems: "center" }}>
<IconButton
size="small"
color="inherit"
sx={{ mr: 2 }}
onClick={() => setEnableLog((e) => !e)}
>
@@ -93,6 +94,7 @@ const LogPage = () => {
fullWidth
size="small"
autoComplete="off"
spellCheck="false"
variant="outlined"
placeholder={t("Filter conditions")}
value={filterText}

View File

@@ -133,7 +133,7 @@ const ProfilePage = () => {
setCurrentProfile(uid);
mutate("getProfiles", { ...profiles, current: uid }, true);
mutate("getRuntimeLogs");
if (force) Notice.success("Refresh clash config", 1000);
// if (force) Notice.success("Refresh clash config", 1000);
} catch (err: any) {
Notice.error(err?.message || err.toString());
}
@@ -149,6 +149,7 @@ const ProfilePage = () => {
value={url}
variant="outlined"
autoComplete="off"
spellCheck="false"
onChange={(e) => setUrl(e.target.value)}
sx={{ input: { py: 0.65, px: 1.25 } }}
placeholder={t("Profile URL")}

View File

@@ -1,5 +1,5 @@
import useSWR, { useSWRConfig } from "swr";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { Button, ButtonGroup, List, Paper } from "@mui/material";
@@ -7,18 +7,20 @@ import { getClashConfig, updateConfigs } from "@/services/api";
import { patchClashConfig } from "@/services/cmds";
import { getProxies } from "@/services/api";
import BasePage from "@/components/base/base-page";
import BaseEmpty from "@/components/base/base-empty";
import ProxyGroup from "@/components/proxy/proxy-group";
import ProxyGlobal from "@/components/proxy/proxy-global";
const ProxyPage = () => {
const { t } = useTranslation();
const { mutate } = useSWRConfig();
const { data: proxiesData } = useSWR("getProxies", getProxies);
const { data: proxiesData } = useSWR("getProxies", getProxies, {
refreshInterval: 45000, // 45s
});
const { data: clashConfig } = useSWR("getClashConfig", getClashConfig);
const modeList = ["rule", "global", "direct", "script"];
const curMode = clashConfig?.mode.toLowerCase();
const { groups = [], proxies = [] } = proxiesData ?? {};
const { global, groups = [], proxies = [] } = proxiesData ?? {};
// make sure that fetch the proxies successfully
useEffect(() => {
@@ -37,9 +39,16 @@ const ProxyPage = () => {
mutate("getClashConfig");
});
// 仅mode为全局和直连的时候展示global分组
const displayGroups = useMemo(() => {
if (!global) return groups;
if (curMode === "global" || curMode === "direct")
return [global, ...groups];
return groups;
}, [global, groups, curMode]);
// difference style
const showGroup =
(curMode === "rule" || curMode === "script") && !!groups.length;
const showGroup = displayGroups.length > 0;
const pageStyle = showGroup ? {} : { height: "100%" };
const paperStyle: any = showGroup
? { mb: 0.5 }
@@ -48,7 +57,7 @@ const ProxyPage = () => {
return (
<BasePage
contentStyle={pageStyle}
title={showGroup ? t("Proxy Groups") : t("Proxies")}
title={t("Proxy Groups")}
header={
<ButtonGroup size="small">
{modeList.map((mode) => (
@@ -65,26 +74,14 @@ const ProxyPage = () => {
}
>
<Paper sx={{ borderRadius: 1, boxShadow: 2, ...paperStyle }}>
{(curMode === "rule" || curMode === "script") && !!groups.length && (
{displayGroups.length > 0 ? (
<List>
{groups.map((group) => (
{displayGroups.map((group) => (
<ProxyGroup key={group.name} group={group} />
))}
</List>
)}
{((curMode === "rule" && !groups.length) || curMode === "global") && (
<ProxyGlobal
groupName="GLOBAL"
curProxy={proxiesData?.global?.now}
proxies={proxies}
/>
)}
{curMode === "direct" && (
<ProxyGlobal
groupName="DIRECT"
curProxy="DIRECT"
proxies={[proxiesData?.direct!].filter(Boolean)}
/>
) : (
<BaseEmpty />
)}
</Paper>
</BasePage>

View File

@@ -37,6 +37,7 @@ const RulesPage = () => {
size="small"
autoComplete="off"
variant="outlined"
spellCheck="false"
placeholder={t("Filter conditions")}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}

View File

@@ -88,13 +88,9 @@ export async function updateProxy(group: string, proxy: string) {
// get proxy
async function getProxiesInner() {
try {
const instance = await getAxios();
const response = await instance.get<any, any>("/proxies");
return (response?.proxies || {}) as Record<string, ApiType.ProxyItem>;
} catch {
return {};
}
const instance = await getAxios();
const response = await instance.get<any, any>("/proxies");
return (response?.proxies || {}) as Record<string, ApiType.ProxyItem>;
}
/// Get the Proxy information
@@ -146,29 +142,30 @@ export async function getProxies() {
)
);
return { global, direct, groups, records: proxyRecord, proxies };
const _global: ApiType.ProxyGroupItem = {
...global,
all: global?.all?.map((item) => generateItem(item)) || [],
};
return { global: _global, direct, groups, records: proxyRecord, proxies };
}
// get proxy providers
export async function getProviders() {
try {
const instance = await getAxios();
const response = await instance.get<any, any>("/providers/proxies");
const instance = await getAxios();
const response = await instance.get<any, any>("/providers/proxies");
const providers = (response.providers || {}) as Record<
string,
ApiType.ProviderItem
>;
const providers = (response.providers || {}) as Record<
string,
ApiType.ProviderItem
>;
return Object.fromEntries(
Object.entries(providers).filter(([key, item]) => {
const type = item.vehicleType.toLowerCase();
return type === "http" || type === "file";
})
);
} catch {
return {};
}
return Object.fromEntries(
Object.entries(providers).filter(([key, item]) => {
const type = item.vehicleType.toLowerCase();
return type === "http" || type === "file";
})
);
}
// proxy providers health check

View File

@@ -6,6 +6,12 @@ class DelayManager {
private cache = new Map<string, [number, number]>();
private urlMap = new Map<string, string>();
// 每个item的监听
private listenerMap = new Map<string, (time: number) => void>();
// 每个分组的监听
private groupListenerMap = new Map<string, () => void>();
setUrl(group: string, url: string) {
this.urlMap.set(group, url);
}
@@ -14,8 +20,29 @@ class DelayManager {
return this.urlMap.get(group);
}
setListener(name: string, group: string, listener: (time: number) => void) {
const key = hashKey(name, group);
this.listenerMap.set(key, listener);
}
removeListener(name: string, group: string) {
const key = hashKey(name, group);
this.listenerMap.delete(key);
}
setGroupListener(group: string, listener: () => void) {
this.groupListenerMap.set(group, listener);
}
removeGroupListener(group: string) {
this.groupListenerMap.delete(group);
}
setDelay(name: string, group: string, delay: number) {
this.cache.set(hashKey(name, group), [Date.now(), delay]);
const key = hashKey(name, group);
this.cache.set(key, [Date.now(), delay]);
this.listenerMap.get(key)?.(delay);
this.groupListenerMap.get(group)?.();
}
getDelay(name: string, group: string) {
@@ -44,19 +71,13 @@ class DelayManager {
}
async checkListDelay(
options: {
names: readonly string[];
groupName: string;
skipNum: number;
},
callback: Function
nameList: readonly string[],
groupName: string,
concurrency: number
) {
const { groupName, skipNum } = options;
const names = [...nameList];
const names = [...options.names];
const total = names.length;
let count = 0;
let total = names.length;
let current = 0;
// 设置正在延迟测试中
@@ -64,7 +85,7 @@ class DelayManager {
return new Promise((resolve) => {
const help = async (): Promise<void> => {
if (current >= skipNum) return;
if (current >= concurrency) return;
const task = names.shift();
if (!task) return;
@@ -72,14 +93,13 @@ class DelayManager {
current += 1;
await this.checkDelay(task, groupName);
current -= 1;
total -= 1;
if (count++ % skipNum === 0 || count === total) callback();
if (count === total) resolve(null);
return help();
if (total <= 0) resolve(null);
else return help();
};
for (let i = 0; i < skipNum; ++i) help();
for (let i = 0; i < concurrency; ++i) help();
});
}
}

View File

@@ -124,6 +124,7 @@ declare namespace CmdType {
interface ProfileOption {
user_agent?: string;
with_proxy?: boolean;
self_proxy?: boolean;
update_interval?: number;
}

View File

@@ -1992,10 +1992,10 @@ react-transition-group@^4.4.5:
loose-envify "^1.4.0"
prop-types "^15.6.2"
react-virtuoso@^2.17.2:
version "2.17.2"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-2.17.2.tgz#cc7323f300d5d80311bf828efcda95f6cc1edd51"
integrity sha512-5xl0mCCVEANzY0SbVbUjQgRrc2it3vdGCn72kUQo0wy4s3QGVR8S3QcLZj3lBrHocGIf/ONEJ3g/uqs0KEl9eA==
react-virtuoso@^3.1.0:
version "3.1.0"
resolved "https://registry.npmmirror.com/react-virtuoso/-/react-virtuoso-3.1.0.tgz#e358a39b9fd99895bcc72671a585091b756a1c92"
integrity sha512-Rur0d7xiZthRxy3f7Z217Q+U6k1sTsPZZU/kKT73GPB3ROtJCr4Y0Qehg/WxeKhochQPnSuT8VfcsAasdpX2ig==
dependencies:
"@virtuoso.dev/react-urx" "^0.2.12"
"@virtuoso.dev/urx" "^0.2.12"