refactor: invock mihomo api by use tauri-plugin-mihomo (#4926)

* feat: add tauri-plugin-mihomo

* refactor: invock mihomo api by use tauri-plugin-mihomo

* chore: todo

* chore: update

* chore: update

* chore: update

* chore: update

* fix: incorrect delay status and update pretty config

* chore: update

* chore: remove cache

* chore: update

* chore: update

* fix: app freezed when change group proxy

* chore: update

* chore: update

* chore: add rustfmt.toml to tauri-plugin-mihomo

* chore: happy clippy

* refactor: connect mihomo websocket

* chore: update

* chore: update

* fix: parse bigint to number

* chore: update

* Revert "fix: parse bigint to number"

This reverts commit 74c006522e23aa52cf8979a8fb47d2b1ae0bb043.

* chore: use number instead of bigint

* chore: cleanup

* fix: rule data not refresh when switch profile

* chore: update

* chore: cleanup

* chore: update

* fix: traffic graph data display

* feat: add ipc connection pool

* chore: update

* chore: clippy

* fix: incorrect delay status

* fix: typo

* fix: empty proxies tray menu

* chore: clippy

* chore: import tauri-plugin-mihomo by using git repo

* chore: cleanup

* fix: mihomo api

* fix: incorrect delay status

* chore: update tauri-plugin-mihomo dep

chore: update
This commit is contained in:
oomeow
2025-10-08 12:32:40 +08:00
committed by GitHub
parent 72aa56007c
commit 7fc238c27b
85 changed files with 1780 additions and 3344 deletions

View File

@@ -6,3 +6,5 @@ pnpm-lock.yaml
src-tauri/target/ src-tauri/target/
src-tauri/gen/ src-tauri/gen/
target

View File

@@ -11,6 +11,6 @@
"arrowParens": "always", "arrowParens": "always",
"proseWrap": "preserve", "proseWrap": "preserve",
"htmlWhitespaceSensitivity": "css", "htmlWhitespaceSensitivity": "css",
"endOfLine": "lf", "endOfLine": "auto",
"embeddedLanguageFormatting": "auto" "embeddedLanguageFormatting": "auto"
} }

View File

@@ -76,7 +76,8 @@
"react-virtuoso": "^4.14.1", "react-virtuoso": "^4.14.1",
"swr": "^2.3.6", "swr": "^2.3.6",
"types-pac": "^1.0.3", "types-pac": "^1.0.3",
"zustand": "^5.0.8" "zustand": "^5.0.8",
"tauri-plugin-mihomo-api": "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo"
}, },
"devDependencies": { "devDependencies": {
"@actions/github": "^6.0.1", "@actions/github": "^6.0.1",

11
pnpm-lock.yaml generated
View File

@@ -128,6 +128,9 @@ importers:
swr: swr:
specifier: ^2.3.6 specifier: ^2.3.6
version: 2.3.6(react@19.2.0) version: 2.3.6(react@19.2.0)
tauri-plugin-mihomo-api:
specifier: git+https://github.com/clash-verge-rev/tauri-plugin-mihomo
version: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/9553c69b0063a3be04e39b5539cc5d6f0e9529c1
types-pac: types-pac:
specifier: ^1.0.3 specifier: ^1.0.3
version: 1.0.3 version: 1.0.3
@@ -3856,6 +3859,10 @@ packages:
resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==}
engines: {node: '>=18'} engines: {node: '>=18'}
tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/9553c69b0063a3be04e39b5539cc5d6f0e9529c1:
resolution: {tarball: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/9553c69b0063a3be04e39b5539cc5d6f0e9529c1}
version: 0.1.0
terser@5.44.0: terser@5.44.0:
resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -8412,6 +8419,10 @@ snapshots:
minizlib: 3.1.0 minizlib: 3.1.0
yallist: 5.0.0 yallist: 5.0.0
tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/9553c69b0063a3be04e39b5539cc5d6f0e9529c1:
dependencies:
'@tauri-apps/api': 2.8.0
terser@5.44.0: terser@5.44.0:
dependencies: dependencies:
'@jridgewell/source-map': 0.3.6 '@jridgewell/source-map': 0.3.6

141
src-tauri/Cargo.lock generated
View File

@@ -1147,6 +1147,7 @@ dependencies = [
"tauri-plugin-fs", "tauri-plugin-fs",
"tauri-plugin-global-shortcut", "tauri-plugin-global-shortcut",
"tauri-plugin-http", "tauri-plugin-http",
"tauri-plugin-mihomo",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-process", "tauri-plugin-process",
"tauri-plugin-shell", "tauri-plugin-shell",
@@ -1654,6 +1655,12 @@ dependencies = [
"parking_lot_core 0.9.12", "parking_lot_core 0.9.12",
] ]
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]] [[package]]
name = "data-url" name = "data-url"
version = "0.3.2" version = "0.3.2"
@@ -2857,12 +2864,6 @@ dependencies = [
"foldhash", "foldhash",
] ]
[[package]]
name = "hashbrown"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
[[package]] [[package]]
name = "hdrhistogram" name = "hdrhistogram"
version = "7.5.4" version = "7.5.4"
@@ -3063,7 +3064,7 @@ dependencies = [
"httpdate", "httpdate",
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
"socket2 0.5.10", "socket2 0.4.10",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -3189,7 +3190,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"wasm-bindgen", "wasm-bindgen",
"windows-core 0.62.0", "windows-core 0.61.2",
] ]
[[package]] [[package]]
@@ -3474,7 +3475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.16.0", "hashbrown 0.15.5",
"serde", "serde",
"serde_core", "serde_core",
] ]
@@ -7305,6 +7306,31 @@ dependencies = [
"urlpattern", "urlpattern",
] ]
[[package]]
name = "tauri-plugin-mihomo"
version = "0.1.0"
source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo#0b89eaeb4d592cfeb44fd33ad07b1ef40b6f0671"
dependencies = [
"base64 0.22.1",
"futures-util",
"http 1.3.1",
"httparse",
"log",
"pin-project",
"rand 0.9.2",
"reqwest",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.16",
"tokio",
"tokio-tungstenite",
"ts-rs",
"urlencoding",
"windows-sys 0.61.0",
]
[[package]] [[package]]
name = "tauri-plugin-notification" name = "tauri-plugin-notification"
version = "2.3.1" version = "2.3.1"
@@ -7539,6 +7565,15 @@ dependencies = [
"utf-8", "utf-8",
] ]
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "terminfo" name = "terminfo"
version = "0.7.5" version = "0.7.5"
@@ -7823,6 +7858,18 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.16" version = "0.7.16"
@@ -8217,6 +8264,45 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ts-rs"
version = "11.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ef1b7a6d914a34127ed8e1fa927eb7088903787bcded4fa3eef8f85ee1568be"
dependencies = [
"thiserror 2.0.16",
"ts-rs-macros",
]
[[package]]
name = "ts-rs-macros"
version = "11.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9d4ed7b4c18cc150a6a0a1e9ea1ecfa688791220781af6e119f9599a8502a0a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"termcolor",
]
[[package]]
name = "tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
"http 1.3.1",
"httparse",
"log",
"rand 0.9.2",
"sha1",
"thiserror 2.0.16",
"utf-8",
]
[[package]] [[package]]
name = "typeid" name = "typeid"
version = "1.0.3" version = "1.0.3"
@@ -8339,6 +8425,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "urlpattern" name = "urlpattern"
version = "0.3.0" version = "0.3.0"
@@ -8929,19 +9021,6 @@ dependencies = [
"windows-strings 0.4.2", "windows-strings 0.4.2",
] ]
[[package]]
name = "windows-core"
version = "0.62.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c"
dependencies = [
"windows-implement 0.60.0",
"windows-interface 0.59.1",
"windows-link 0.2.0",
"windows-result 0.4.0",
"windows-strings 0.5.0",
]
[[package]] [[package]]
name = "windows-future" name = "windows-future"
version = "0.2.1" version = "0.2.1"
@@ -9048,15 +9127,6 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-result"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f"
dependencies = [
"windows-link 0.2.0",
]
[[package]] [[package]]
name = "windows-strings" name = "windows-strings"
version = "0.1.0" version = "0.1.0"
@@ -9076,15 +9146,6 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-strings"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda"
dependencies = [
"windows-link 0.2.0",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.45.0" version = "0.45.0"

View File

@@ -86,6 +86,7 @@ cfg-if = "1.0.3"
nu-ansi-term = { version = "0.50.1", optional = true } nu-ansi-term = { version = "0.50.1", optional = true }
console-subscriber = { version = "0.4.1", optional = true } console-subscriber = { version = "0.4.1", optional = true }
tauri-plugin-devtools = { version = "2.0.1" } tauri-plugin-devtools = { version = "2.0.1" }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo" }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]

View File

@@ -24,6 +24,7 @@
{ {
"identifier": "http:default", "identifier": "http:default",
"allow": [{ "url": "https://*/*" }, { "url": "http://*/*" }] "allow": [{ "url": "https://*/*" }, { "url": "http://*/*" }]
} },
"mihomo:default"
] ]
} }

View File

@@ -1,111 +0,0 @@
use crate::singleton;
use anyhow::Result;
use dashmap::DashMap;
use serde_json::Value;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::OnceCell;
pub const SHORT_TERM_TTL: Duration = Duration::from_millis(4_250);
pub struct CacheEntry<T> {
pub value: Arc<T>,
pub expires_at: Instant,
}
pub struct Cache<T> {
pub map: DashMap<String, Arc<OnceCell<Box<CacheEntry<T>>>>>,
}
impl<T> Cache<T> {
fn new() -> Self {
Cache {
map: DashMap::new(),
}
}
pub fn make_key(prefix: &str, id: &str) -> String {
format!("{prefix}:{id}")
}
pub async fn get_or_fetch<F, Fut>(&self, key: String, ttl: Duration, fetch_fn: F) -> Arc<T>
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = T> + Send + 'static,
T: Send + Sync + 'static,
{
loop {
let now = Instant::now();
let key_cloned = key.clone();
// Get or create the cell
let cell = self
.map
.entry(key_cloned.clone())
.or_insert_with(|| Arc::new(OnceCell::new()))
.clone();
// Check if we have a valid cached entry
if let Some(entry) = cell.get() {
if entry.expires_at > now {
return Arc::clone(&entry.value);
}
// Entry is expired, remove it
self.map
.remove_if(&key_cloned, |_, v| Arc::ptr_eq(v, &cell));
continue; // Retry with fresh cell
}
// Try to set a new value
let value = fetch_fn().await;
let entry = Box::new(CacheEntry {
value: Arc::new(value),
expires_at: Instant::now() + ttl,
});
match cell.set(entry) {
Ok(_) => {
// Successfully set the value, it must exist now
if let Some(set_entry) = cell.get() {
return Arc::clone(&set_entry.value);
}
}
Err(_) => {
if let Some(existing_entry) = cell.get() {
if existing_entry.expires_at > Instant::now() {
return Arc::clone(&existing_entry.value);
}
self.map
.remove_if(&key_cloned, |_, v| Arc::ptr_eq(v, &cell));
}
}
}
}
}
// pub fn clean_key(&self, key: &str) {
// self.map.remove(key);
// }
// TODO
pub fn clean_default_keys(&self) {
// logging!(info, Type::Cache, "Cleaning proxies keys");
// let proxies_key = Self::make_key("proxies", "default");
// self.map.remove(&proxies_key);
// logging!(info, Type::Cache, "Cleaning providers keys");
// let providers_key = Self::make_key("providers", "default");
// self.map.remove(&providers_key);
// !The frontend goes crash if we clean the clash_config cache
// logging!(info, Type::Cache, "Cleaning clash config keys");
// let clash_config_key = Self::make_key("clash_config", "default");
// self.map.remove(&clash_config_key);
}
}
pub type CacheService = Cache<Result<String>>;
pub type CacheProxy = Cache<Value>;
singleton!(Cache<Value>, PROXY_INSTANCE);
singleton!(Cache<Result<String>>, SERVICE_INSTANCE);

View File

@@ -1,21 +1,15 @@
use std::collections::VecDeque;
use super::CmdResult; use super::CmdResult;
use crate::{ use crate::{
cache::CacheProxy,
config::Config, config::Config,
core::{CoreManager, handle}, core::{self, CoreManager, RunningMode, handle, logger},
};
use crate::{
config::*,
feat,
ipc::{self, IpcManager},
logging,
utils::logging::Type,
wrap_err,
}; };
use crate::{config::*, feat, logging, utils::logging::Type, wrap_err};
use serde_yaml_ng::Mapping; use serde_yaml_ng::Mapping;
use std::time::Duration; // use std::time::Duration;
const CONFIG_REFRESH_INTERVAL: Duration = Duration::from_secs(60); // const CONFIG_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
/// 复制Clash环境变量 /// 复制Clash环境变量
#[tauri::command] #[tauri::command]
@@ -112,20 +106,6 @@ pub async fn restart_core() -> CmdResult {
result result
} }
/// 获取代理延迟
#[tauri::command]
pub async fn clash_api_get_proxy_delay(
name: String,
url: Option<String>,
timeout: i32,
) -> CmdResult<serde_json::Value> {
wrap_err!(
IpcManager::global()
.test_proxy_delay(&name, url, timeout)
.await
)
}
/// 测试URL延迟 /// 测试URL延迟
#[tauri::command] #[tauri::command]
pub async fn test_delay(url: String) -> CmdResult<u32> { pub async fn test_delay(url: String) -> CmdResult<u32> {
@@ -307,317 +287,13 @@ pub async fn validate_dns_config() -> CmdResult<(bool, String)> {
} }
} }
/// 获取Clash版本信息
#[tauri::command] #[tauri::command]
pub async fn get_clash_version() -> CmdResult<serde_json::Value> { pub async fn get_clash_logs() -> CmdResult<VecDeque<String>> {
wrap_err!(IpcManager::global().get_version().await) let logs = match core::CoreManager::global().get_running_mode() {
} // TODO: 服务模式下日志获取接口
RunningMode::Service => VecDeque::new(),
/// 获取Clash配置 RunningMode::Sidecar => logger::Logger::global().get_logs().clone(),
#[tauri::command] _ => VecDeque::new(),
pub async fn get_clash_config() -> CmdResult<serde_json::Value> {
let manager = IpcManager::global();
let cache = CacheProxy::global();
let key = CacheProxy::make_key("clash_config", "default");
let value = cache
.get_or_fetch(key, CONFIG_REFRESH_INTERVAL, || async {
manager.get_config().await.unwrap_or_else(|e| {
logging!(error, Type::Cmd, "Failed to fetch clash config: {e}");
serde_json::Value::Object(serde_json::Map::new())
})
})
.await;
Ok((*value).clone())
}
/// 强制刷新Clash配置缓存
#[tauri::command]
pub async fn force_refresh_clash_config() -> CmdResult<serde_json::Value> {
let cache = CacheProxy::global();
let key = CacheProxy::make_key("clash_config", "default");
cache.map.remove(&key);
get_clash_config().await
}
/// 更新地理数据
#[tauri::command]
pub async fn update_geo_data() -> CmdResult {
wrap_err!(IpcManager::global().update_geo_data().await)
}
/// 升级Clash核心
#[tauri::command]
pub async fn upgrade_clash_core() -> CmdResult {
wrap_err!(IpcManager::global().upgrade_core().await)
}
/// 获取规则
#[tauri::command]
pub async fn get_clash_rules() -> CmdResult<serde_json::Value> {
wrap_err!(IpcManager::global().get_rules().await)
}
/// 更新代理选择
#[tauri::command]
pub async fn update_proxy_choice(group: String, proxy: String) -> CmdResult {
wrap_err!(IpcManager::global().update_proxy(&group, &proxy).await)
}
/// 获取代理提供者
#[tauri::command]
pub async fn get_proxy_providers() -> CmdResult<serde_json::Value> {
wrap_err!(IpcManager::global().get_providers_proxies().await)
}
/// 获取规则提供者
#[tauri::command]
pub async fn get_rule_providers() -> CmdResult<serde_json::Value> {
wrap_err!(IpcManager::global().get_rule_providers().await)
}
/// 代理提供者健康检查
#[tauri::command]
pub async fn proxy_provider_health_check(name: String) -> CmdResult {
wrap_err!(
IpcManager::global()
.proxy_provider_health_check(&name)
.await
)
}
/// 更新代理提供者
#[tauri::command]
pub async fn update_proxy_provider(name: String) -> CmdResult {
wrap_err!(IpcManager::global().update_proxy_provider(&name).await)
}
/// 更新规则提供者
#[tauri::command]
pub async fn update_rule_provider(name: String) -> CmdResult {
wrap_err!(IpcManager::global().update_rule_provider(&name).await)
}
/// 获取连接
#[tauri::command]
pub async fn get_clash_connections() -> CmdResult<serde_json::Value> {
wrap_err!(IpcManager::global().get_connections().await)
}
/// 删除连接
#[tauri::command]
pub async fn delete_clash_connection(id: String) -> CmdResult {
wrap_err!(IpcManager::global().delete_connection(&id).await)
}
/// 关闭所有连接
#[tauri::command]
pub async fn close_all_clash_connections() -> CmdResult {
wrap_err!(IpcManager::global().close_all_connections().await)
}
/// 获取流量数据 (使用新的IPC流式监控)
#[tauri::command]
pub async fn get_traffic_data() -> CmdResult<serde_json::Value> {
let traffic = crate::ipc::get_current_traffic().await;
let result = serde_json::json!({
"up": traffic.total_up,
"down": traffic.total_down,
"up_rate": traffic.up_rate,
"down_rate": traffic.down_rate,
"last_updated": traffic.last_updated.elapsed().as_secs()
});
Ok(result)
}
/// 获取内存数据 (使用新的IPC流式监控)
#[tauri::command]
pub async fn get_memory_data() -> CmdResult<serde_json::Value> {
let memory = crate::ipc::get_current_memory().await;
let usage_percent = if memory.oslimit > 0 {
(memory.inuse as f64 / memory.oslimit as f64) * 100.0
} else {
0.0
}; };
let result = serde_json::json!({ Ok(logs)
"inuse": memory.inuse,
"oslimit": memory.oslimit,
"usage_percent": usage_percent,
"last_updated": memory.last_updated.elapsed().as_secs()
});
Ok(result)
}
/// 启动流量监控服务 (IPC流式监控自动启动此函数为兼容性保留)
#[tauri::command]
pub async fn start_traffic_service() -> CmdResult {
logging!(trace, Type::Ipc, "启动流量监控服务 (IPC流式监控)");
// 新的IPC监控在首次访问时自动启动
// 触发一次访问以确保监控器已初始化
let _ = crate::ipc::get_current_traffic().await;
let _ = crate::ipc::get_current_memory().await;
logging!(info, Type::Ipc, "IPC流式监控已激活");
Ok(())
}
/// 停止流量监控服务 (IPC流式监控无需显式停止此函数为兼容性保留)
#[tauri::command]
pub async fn stop_traffic_service() -> CmdResult {
logging!(trace, Type::Ipc, "停止流量监控服务请求 (IPC流式监控)");
// 新的IPC监控是持久的无需显式停止
logging!(info, Type::Ipc, "IPC流式监控继续运行");
Ok(())
}
/// 获取格式化的流量数据 (包含单位,便于前端显示)
#[tauri::command]
pub async fn get_formatted_traffic_data() -> CmdResult<serde_json::Value> {
logging!(trace, Type::Ipc, "获取格式化流量数据");
let (up_rate, down_rate, total_up, total_down, is_fresh) =
crate::ipc::get_formatted_traffic().await;
let result = serde_json::json!({
"up_rate_formatted": up_rate,
"down_rate_formatted": down_rate,
"total_up_formatted": total_up,
"total_down_formatted": total_down,
"is_fresh": is_fresh
});
logging!(
debug,
Type::Ipc,
"格式化流量数据: ↑{up_rate}/s ↓{down_rate}/s (总计: ↑{total_up} ↓{total_down})"
);
Ok(result)
}
/// 获取格式化的内存数据 (包含单位,便于前端显示)
#[tauri::command]
pub async fn get_formatted_memory_data() -> CmdResult<serde_json::Value> {
logging!(info, Type::Ipc, "获取格式化内存数据");
let (inuse, oslimit, usage_percent, is_fresh) = crate::ipc::get_formatted_memory().await;
let result = serde_json::json!({
"inuse_formatted": inuse,
"oslimit_formatted": oslimit,
"usage_percent": usage_percent,
"is_fresh": is_fresh
});
logging!(
debug,
Type::Ipc,
"格式化内存数据: {inuse} / {oslimit} ({usage_percent:.1}%)"
);
Ok(result)
}
/// 获取系统监控概览 (流量+内存,便于前端一次性获取所有状态)
#[tauri::command]
pub async fn get_system_monitor_overview() -> CmdResult<serde_json::Value> {
logging!(debug, Type::Ipc, "获取系统监控概览");
// 并发获取流量和内存数据
let (traffic, memory) = tokio::join!(
crate::ipc::get_current_traffic(),
crate::ipc::get_current_memory()
);
let (traffic_formatted, memory_formatted) = tokio::join!(
crate::ipc::get_formatted_traffic(),
crate::ipc::get_formatted_memory()
);
let traffic_is_fresh = traffic.last_updated.elapsed().as_secs() < 5;
let memory_is_fresh = memory.last_updated.elapsed().as_secs() < 10;
let result = serde_json::json!({
"traffic": {
"raw": {
"up": traffic.total_up,
"down": traffic.total_down,
"up_rate": traffic.up_rate,
"down_rate": traffic.down_rate
},
"formatted": {
"up_rate": traffic_formatted.0,
"down_rate": traffic_formatted.1,
"total_up": traffic_formatted.2,
"total_down": traffic_formatted.3
},
"is_fresh": traffic_is_fresh
},
"memory": {
"raw": {
"inuse": memory.inuse,
"oslimit": memory.oslimit,
"usage_percent": if memory.oslimit > 0 {
(memory.inuse as f64 / memory.oslimit as f64) * 100.0
} else {
0.0
}
},
"formatted": {
"inuse": memory_formatted.0,
"oslimit": memory_formatted.1,
"usage_percent": memory_formatted.2
},
"is_fresh": memory_is_fresh
},
"overall_status": if traffic_is_fresh && memory_is_fresh { "healthy" } else { "stale" }
});
Ok(result)
}
/// 获取代理组延迟
#[tauri::command]
pub async fn get_group_proxy_delays(
group_name: String,
url: Option<String>,
timeout: Option<i32>,
) -> CmdResult<serde_json::Value> {
wrap_err!(
IpcManager::global()
.get_group_proxy_delays(&group_name, url, timeout.unwrap_or(10000))
.await
)
}
/// 检查调试是否启用
#[tauri::command]
pub async fn is_clash_debug_enabled() -> CmdResult<bool> {
match IpcManager::global().is_debug_enabled().await {
Ok(enabled) => Ok(enabled),
Err(_) => Ok(false),
}
}
/// 垃圾回收
#[tauri::command]
pub async fn clash_gc() -> CmdResult {
wrap_err!(IpcManager::global().gc().await)
}
/// 获取日志 (使用新的流式实现)
#[tauri::command]
pub async fn get_clash_logs() -> CmdResult<serde_json::Value> {
Ok(ipc::get_logs_json().await)
}
/// 启动日志监控
#[tauri::command]
pub async fn start_logs_monitoring(level: Option<String>) -> CmdResult {
ipc::start_logs_monitoring(level).await;
Ok(())
}
/// 停止日志监控
#[tauri::command]
pub async fn stop_logs_monitoring() -> CmdResult {
ipc::stop_logs_monitoring().await;
Ok(())
}
/// 清除日志
#[tauri::command]
pub async fn clear_logs() -> CmdResult {
ipc::clear_logs().await;
Ok(())
} }

View File

@@ -503,11 +503,11 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
handle::Handle::refresh_clash(); handle::Handle::refresh_clash();
// 强制刷新代理缓存确保profile切换后立即获取最新节点数据 // 强制刷新代理缓存确保profile切换后立即获取最新节点数据
crate::process::AsyncHandler::spawn(|| async move { // crate::process::AsyncHandler::spawn(|| async move {
if let Err(e) = super::proxy::force_refresh_proxies().await { // if let Err(e) = super::proxy::force_refresh_proxies().await {
log::warn!(target: "app", "强制刷新代理缓存失败: {e}"); // log::warn!(target: "app", "强制刷新代理缓存失败: {e}");
} // }
}); // });
if let Err(e) = Tray::global().update_tooltip().await { if let Err(e) = Tray::global().update_tooltip().await {
log::warn!(target: "app", "异步更新托盘提示失败: {e}"); log::warn!(target: "app", "异步更新托盘提示失败: {e}");

View File

@@ -1,59 +1,7 @@
use tauri::Emitter;
use super::CmdResult; use super::CmdResult;
use crate::{ use crate::{logging, utils::logging::Type};
cache::CacheProxy,
core::{handle::Handle, tray::Tray},
ipc::IpcManager,
logging,
utils::logging::Type,
};
use std::time::Duration;
const PROXIES_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
const PROVIDERS_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
#[tauri::command]
pub async fn get_proxies() -> CmdResult<serde_json::Value> {
let cache = CacheProxy::global();
let key = CacheProxy::make_key("proxies", "default");
let value = cache
.get_or_fetch(key, PROXIES_REFRESH_INTERVAL, || async {
let manager = IpcManager::global();
manager.get_proxies().await.unwrap_or_else(|e| {
logging!(error, Type::Cmd, "Failed to fetch proxies: {e}");
serde_json::Value::Object(serde_json::Map::new())
})
})
.await;
Ok((*value).clone())
}
/// 强制刷新代理缓存用于profile切换
#[tauri::command]
pub async fn force_refresh_proxies() -> CmdResult<serde_json::Value> {
let cache = CacheProxy::global();
let key = CacheProxy::make_key("proxies", "default");
cache.map.remove(&key);
get_proxies().await
}
#[tauri::command]
pub async fn get_providers_proxies() -> CmdResult<serde_json::Value> {
let cache = CacheProxy::global();
let key = CacheProxy::make_key("providers", "default");
let value = cache
.get_or_fetch(key, PROVIDERS_REFRESH_INTERVAL, || async {
let manager = IpcManager::global();
manager.get_providers_proxies().await.unwrap_or_else(|e| {
logging!(error, Type::Cmd, "Failed to fetch provider proxies: {e}");
serde_json::Value::Object(serde_json::Map::new())
})
})
.await;
Ok((*value).clone())
}
// TODO: 前端通过 emit 发送更新事件, tray 监听更新事件
/// 同步托盘和GUI的代理选择状态 /// 同步托盘和GUI的代理选择状态
#[tauri::command] #[tauri::command]
pub async fn sync_tray_proxy_selection() -> CmdResult<()> { pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
@@ -70,54 +18,3 @@ pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
} }
} }
} }
/// 更新代理选择并同步托盘和GUI状态
#[tauri::command]
pub async fn update_proxy_and_sync(group: String, proxy: String) -> CmdResult<()> {
match IpcManager::global().update_proxy(&group, &proxy).await {
Ok(_) => {
// println!("Proxy updated successfully: {} -> {}", group,proxy);
logging!(
info,
Type::Cmd,
"Proxy updated successfully: {} -> {}",
group,
proxy
);
let cache = CacheProxy::global();
let key = CacheProxy::make_key("proxies", "default");
cache.map.remove(&key);
if let Err(e) = Tray::global().update_menu().await {
logging!(error, Type::Cmd, "Failed to sync tray menu: {}", e);
}
if let Some(app_handle) = Handle::global().app_handle() {
let _ = app_handle.emit("verge://force-refresh-proxies", ());
let _ = app_handle.emit("verge://refresh-proxy-config", ());
}
logging!(
info,
Type::Cmd,
"Proxy and sync completed successfully: {} -> {}",
group,
proxy
);
Ok(())
}
Err(e) => {
println!("1111111111111111");
logging!(
error,
Type::Cmd,
"Failed to update proxy: {} -> {}, error: {}",
group,
proxy,
e
);
Err(e.to_string())
}
}
}

View File

@@ -28,9 +28,7 @@ pub async fn export_diagnostic_info() -> CmdResult<()> {
let sysinfo = PlatformSpecification::new_sync(); let sysinfo = PlatformSpecification::new_sync();
let info = format!("{sysinfo:?}"); let info = format!("{sysinfo:?}");
let app_handle = handle::Handle::global() let app_handle = handle::Handle::app_handle();
.app_handle()
.ok_or("Failed to get app handle")?;
let cliboard = app_handle.clipboard(); let cliboard = app_handle.clipboard();
if cliboard.write_text(info).is_err() { if cliboard.write_text(info).is_err() {
logging!(error, Type::System, "Failed to write to clipboard"); logging!(error, Type::System, "Failed to write to clipboard");

View File

@@ -1,11 +1,11 @@
use crate::AsyncHandler; use crate::AsyncHandler;
use crate::core::logger::Logger;
use crate::{ use crate::{
config::*, config::*,
core::{ core::{
handle, handle,
service::{self, SERVICE_MANAGER, ServiceStatus}, service::{self, SERVICE_MANAGER, ServiceStatus},
}, },
ipc::IpcManager,
logging, logging_error, singleton_lazy, logging, logging_error, singleton_lazy,
utils::{ utils::{
dirs, dirs,
@@ -25,6 +25,10 @@ use std::{
}; };
use tauri_plugin_shell::{ShellExt, process::CommandChild}; use tauri_plugin_shell::{ShellExt, process::CommandChild};
// TODO:
// - 重构,提升模式切换速度
// - 内核启动添加启动 IPC 启动参数, `-ext-ctl-unix` / `-ext-ctl-pipe`, 运行时配置需要删除相关配置项
#[derive(Debug)] #[derive(Debug)]
pub struct CoreManager { pub struct CoreManager {
running: Arc<Mutex<RunningMode>>, running: Arc<Mutex<RunningMode>>,
@@ -250,11 +254,7 @@ impl CoreManager {
let clash_core = Config::verge().await.latest_ref().get_valid_clash_core(); let clash_core = Config::verge().await.latest_ref().get_valid_clash_core();
logging!(info, Type::Config, true, "使用内核: {}", clash_core); logging!(info, Type::Config, true, "使用内核: {}", clash_core);
let app_handle = handle::Handle::global().app_handle().ok_or_else(|| { let app_handle = handle::Handle::app_handle();
let msg = "Failed to get app handle";
logging!(error, Type::Core, true, "{}", msg);
anyhow::anyhow!(msg)
})?;
let app_dir = dirs::app_home_dir()?; let app_dir = dirs::app_home_dir()?;
let app_dir_str = dirs::path_to_str(&app_dir)?; let app_dir_str = dirs::path_to_str(&app_dir)?;
logging!(info, Type::Config, true, "验证目录: {}", app_dir_str); logging!(info, Type::Config, true, "验证目录: {}", app_dir_str);
@@ -414,7 +414,11 @@ impl CoreManager {
logging_error!(Type::Core, true, "{}", msg); logging_error!(Type::Core, true, "{}", msg);
msg msg
}); });
match IpcManager::global().put_configs_force(run_path_str?).await { match handle::Handle::mihomo()
.await
.reload_config(true, run_path_str?)
.await
{
Ok(_) => { Ok(_) => {
Config::runtime().await.apply(); Config::runtime().await.apply();
logging!(info, Type::Core, true, "Configuration updated successfully"); logging!(info, Type::Core, true, "Configuration updated successfully");
@@ -733,9 +737,7 @@ impl CoreManager {
logging!(info, Type::Core, true, "Running core by sidecar"); logging!(info, Type::Core, true, "Running core by sidecar");
let config_file = &Config::generate_file(ConfigType::Run).await?; let config_file = &Config::generate_file(ConfigType::Run).await?;
let app_handle = handle::Handle::global() let app_handle = handle::Handle::app_handle();
.app_handle()
.ok_or(anyhow::anyhow!("failed to get app handle"))?;
let clash_core = Config::verge().await.latest_ref().get_valid_clash_core(); let clash_core = Config::verge().await.latest_ref().get_valid_clash_core();
let config_dir = dirs::app_home_dir()?; let config_dir = dirs::app_home_dir()?;
@@ -774,12 +776,16 @@ impl CoreManager {
while let Some(event) = rx.recv().await { while let Some(event) = rx.recv().await {
match event { match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line) => { tauri_plugin_shell::process::CommandEvent::Stdout(line) => {
if let Err(e) = writeln!(log_file, "{}", String::from_utf8_lossy(&line)) { let line = String::from_utf8_lossy(&line);
Logger::global().append_log(line.to_string());
if let Err(e) = writeln!(log_file, "{}", line) {
eprintln!("[Sidecar] write stdout failed: {e}"); eprintln!("[Sidecar] write stdout failed: {e}");
} }
} }
tauri_plugin_shell::process::CommandEvent::Stderr(line) => { tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
let _ = writeln!(log_file, "[stderr] {}", String::from_utf8_lossy(&line)); let line = String::from_utf8_lossy(&line);
Logger::global().append_log(line.to_string());
let _ = writeln!(log_file, "[stderr] {}", line);
} }
tauri_plugin_shell::process::CommandEvent::Terminated(term) => { tauri_plugin_shell::process::CommandEvent::Terminated(term) => {
let _ = writeln!(log_file, "[terminated] {:?}", term); let _ = writeln!(log_file, "[terminated] {:?}", term);
@@ -900,6 +906,7 @@ impl CoreManager {
/// 停止核心运行 /// 停止核心运行
pub async fn stop_core(&self) -> Result<()> { pub async fn stop_core(&self) -> Result<()> {
Logger::global().clear_logs();
match self.get_running_mode() { match self.get_running_mode() {
RunningMode::Service => self.stop_core_by_service().await, RunningMode::Service => self.stop_core_by_service().await,
RunningMode::Sidecar => self.stop_core_by_sidecar(), RunningMode::Sidecar => self.stop_core_by_sidecar(),

View File

@@ -1,4 +1,4 @@
use crate::singleton; use crate::{APP_HANDLE, singleton};
use parking_lot::RwLock; use parking_lot::RwLock;
use std::{ use std::{
sync::{ sync::{
@@ -10,6 +10,8 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use tauri::{AppHandle, Emitter, Manager, WebviewWindow}; use tauri::{AppHandle, Emitter, Manager, WebviewWindow};
use tauri_plugin_mihomo::{Mihomo, MihomoExt};
use tokio::sync::{RwLockReadGuard, RwLockWriteGuard};
use crate::{logging, utils::logging::Type}; use crate::{logging, utils::logging::Type};
@@ -107,7 +109,7 @@ impl NotificationSystem {
continue; continue;
} }
if let Some(window) = handle.get_window() { if let Some(window) = Handle::get_window() {
*system.last_emit_time.write() = Instant::now(); *system.last_emit_time.write() = Instant::now();
let (event_name_str, payload_result) = match event { let (event_name_str, payload_result) = match event {
@@ -249,7 +251,6 @@ impl NotificationSystem {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Handle { pub struct Handle {
pub app_handle: Arc<RwLock<Option<AppHandle>>>,
pub is_exiting: Arc<RwLock<bool>>, pub is_exiting: Arc<RwLock<bool>>,
startup_errors: Arc<RwLock<Vec<ErrorMessage>>>, startup_errors: Arc<RwLock<Vec<ErrorMessage>>>,
startup_completed: Arc<RwLock<bool>>, startup_completed: Arc<RwLock<bool>>,
@@ -259,7 +260,6 @@ pub struct Handle {
impl Default for Handle { impl Default for Handle {
fn default() -> Self { fn default() -> Self {
Self { Self {
app_handle: Arc::new(RwLock::new(None)),
is_exiting: Arc::new(RwLock::new(false)), is_exiting: Arc::new(RwLock::new(false)),
startup_errors: Arc::new(RwLock::new(Vec::new())), startup_errors: Arc::new(RwLock::new(Vec::new())),
startup_completed: Arc::new(RwLock::new(false)), startup_completed: Arc::new(RwLock::new(false)),
@@ -276,18 +276,13 @@ impl Handle {
Self::default() Self::default()
} }
pub fn init(&self, app_handle: AppHandle) { pub fn init(&self) {
// 如果正在退出,不要重新初始化 // 如果正在退出,不要重新初始化
if self.is_exiting() { if self.is_exiting() {
log::debug!("Handle::init called while exiting, skipping initialization"); log::debug!("Handle::init called while exiting, skipping initialization");
return; return;
} }
{
let mut handle = self.app_handle.write();
*handle = Some(app_handle);
}
let mut system_opt = self.notification_system.write(); let mut system_opt = self.notification_system.write();
if let Some(system) = system_opt.as_mut() { if let Some(system) = system_opt.as_mut() {
// 只在未运行时启动 // 只在未运行时启动
@@ -300,12 +295,22 @@ impl Handle {
} }
/// 获取 AppHandle /// 获取 AppHandle
pub fn app_handle(&self) -> Option<AppHandle> { #[allow(clippy::expect_used)]
self.app_handle.read().clone() pub fn app_handle() -> &'static AppHandle {
APP_HANDLE.get().expect("failed to get global app handle")
} }
pub fn get_window(&self) -> Option<WebviewWindow> { pub async fn mihomo() -> RwLockReadGuard<'static, Mihomo> {
let app_handle = self.app_handle()?; Self::app_handle().mihomo().read().await
}
#[allow(unused)]
pub async fn mihomo_mut() -> RwLockWriteGuard<'static, Mihomo> {
Self::app_handle().mihomo().write().await
}
pub fn get_window() -> Option<WebviewWindow> {
let app_handle = Self::app_handle();
let window: Option<WebviewWindow> = app_handle.get_webview_window("main"); let window: Option<WebviewWindow> = app_handle.get_webview_window("main");
if window.is_none() { if window.is_none() {
log::debug!(target:"app", "main window not found"); log::debug!(target:"app", "main window not found");
@@ -520,14 +525,10 @@ impl Handle {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
impl Handle { impl Handle {
pub fn set_activation_policy(&self, policy: tauri::ActivationPolicy) -> Result<(), String> { pub fn set_activation_policy(&self, policy: tauri::ActivationPolicy) -> Result<(), String> {
let app_handle = self.app_handle(); let app_handle = Self::app_handle();
if let Some(app_handle) = app_handle.as_ref() { app_handle
app_handle .set_activation_policy(policy)
.set_activation_policy(policy) .map_err(|e| e.to_string())
.map_err(|e| e.to_string())
} else {
Err("AppHandle not initialized".to_string())
}
} }
pub fn set_activation_policy_regular(&self) { pub fn set_activation_policy_regular(&self) {

View File

@@ -200,9 +200,7 @@ impl Hotkey {
hotkey: &str, hotkey: &str,
function: HotkeyFunction, function: HotkeyFunction,
) -> Result<()> { ) -> Result<()> {
let app_handle = handle::Handle::global() let app_handle = handle::Handle::app_handle();
.app_handle()
.ok_or_else(|| anyhow::anyhow!("Failed to get app handle for hotkey registration"))?;
let manager = app_handle.global_shortcut(); let manager = app_handle.global_shortcut();
logging!( logging!(
@@ -375,9 +373,7 @@ impl Hotkey {
} }
pub fn reset(&self) -> Result<()> { pub fn reset(&self) -> Result<()> {
let app_handle = handle::Handle::global() let app_handle = handle::Handle::app_handle();
.app_handle()
.ok_or_else(|| anyhow::anyhow!("Failed to get app handle for hotkey registration"))?;
let manager = app_handle.global_shortcut(); let manager = app_handle.global_shortcut();
manager.unregister_all()?; manager.unregister_all()?;
Ok(()) Ok(())
@@ -390,9 +386,7 @@ impl Hotkey {
} }
pub fn unregister(&self, hotkey: &str) -> Result<()> { pub fn unregister(&self, hotkey: &str) -> Result<()> {
let app_handle = handle::Handle::global() let app_handle = handle::Handle::app_handle();
.app_handle()
.ok_or_else(|| anyhow::anyhow!("Failed to get app handle for hotkey registration"))?;
let manager = app_handle.global_shortcut(); let manager = app_handle.global_shortcut();
manager.unregister(hotkey)?; manager.unregister(hotkey)?;
logging!(debug, Type::Hotkey, "Unregister hotkey {}", hotkey); logging!(debug, Type::Hotkey, "Unregister hotkey {}", hotkey);
@@ -468,17 +462,7 @@ impl Hotkey {
impl Drop for Hotkey { impl Drop for Hotkey {
fn drop(&mut self) { fn drop(&mut self) {
let app_handle = match handle::Handle::global().app_handle() { let app_handle = handle::Handle::app_handle();
Some(handle) => handle,
None => {
logging!(
error,
Type::Hotkey,
"Failed to get app handle during hotkey cleanup"
);
return;
}
};
if let Err(e) = app_handle.global_shortcut().unregister_all() { if let Err(e) = app_handle.global_shortcut().unregister_all() {
logging!( logging!(
error, error,

View File

@@ -0,0 +1,37 @@
use std::{collections::VecDeque, sync::Arc};
use once_cell::sync::OnceCell;
use parking_lot::{RwLock, RwLockReadGuard};
const LOGS_QUEUE_LEN: usize = 100;
pub struct Logger {
logs: Arc<RwLock<VecDeque<String>>>,
}
impl Logger {
pub fn global() -> &'static Logger {
static LOGGER: OnceCell<Logger> = OnceCell::new();
LOGGER.get_or_init(|| Logger {
logs: Arc::new(RwLock::new(VecDeque::with_capacity(LOGS_QUEUE_LEN + 10))),
})
}
pub fn get_logs(&self) -> RwLockReadGuard<'_, VecDeque<String>> {
self.logs.read()
}
pub fn append_log(&self, text: String) {
let mut logs = self.logs.write();
if logs.len() > LOGS_QUEUE_LEN {
logs.pop_front();
}
logs.push_back(text);
}
pub fn clear_logs(&self) {
let mut logs = self.logs.write();
logs.clear();
}
}

View File

@@ -5,6 +5,7 @@ mod core;
pub mod event_driven_proxy; pub mod event_driven_proxy;
pub mod handle; pub mod handle;
pub mod hotkey; pub mod hotkey;
pub mod logger;
pub mod service; pub mod service;
pub mod service_ipc; pub mod service_ipc;
pub mod sysopt; pub mod sysopt;

View File

@@ -1,5 +1,4 @@
use crate::{ use crate::{
cache::{CacheService, SHORT_TERM_TTL},
config::Config, config::Config,
core::service_ipc::{IpcCommand, send_ipc_request}, core::service_ipc::{IpcCommand, send_ipc_request},
logging, logging_error, logging, logging_error,
@@ -333,28 +332,24 @@ pub async fn force_reinstall_service() -> Result<()> {
/// 检查服务版本 - 使用IPC通信 /// 检查服务版本 - 使用IPC通信
async fn check_service_version() -> Result<String> { async fn check_service_version() -> Result<String> {
let cache = CacheService::global(); let version_arc: Result<String> = {
let key = CacheService::make_key("service", "version"); logging!(info, Type::Service, true, "开始检查服务版本 (IPC)");
let version_arc = cache let payload = serde_json::json!({});
.get_or_fetch(key, SHORT_TERM_TTL, || async { let response = send_ipc_request(IpcCommand::GetVersion, payload).await?;
logging!(info, Type::Service, true, "开始检查服务版本 (IPC)");
let payload = serde_json::json!({});
let response = send_ipc_request(IpcCommand::GetVersion, payload).await?;
let data = response let data = response
.data .data
.ok_or_else(|| anyhow::anyhow!("服务版本响应中没有数据"))?; .ok_or_else(|| anyhow::anyhow!("服务版本响应中没有数据"))?;
if let Some(nested_data) = data.get("data")
&& let Some(version) = nested_data.get("version").and_then(|v| v.as_str())
{
// logging!(info, Type::Service, true, "获取到服务版本: {}", version);
return Ok(version.to_string());
}
if let Some(nested_data) = data.get("data")
&& let Some(version) = nested_data.get("version").and_then(|v| v.as_str())
{
// logging!(info, Type::Service, true, "获取到服务版本: {}", version);
Ok(version.to_string())
} else {
Ok("unknown".to_string()) Ok("unknown".to_string())
}) }
.await; };
match version_arc.as_ref() { match version_arc.as_ref() {
Ok(v) => Ok(v.clone()), Ok(v) => Ok(v.clone()),

View File

@@ -262,10 +262,7 @@ impl Sysopt {
/// 尝试使用原来的自启动方法 /// 尝试使用原来的自启动方法
fn try_original_autostart_method(&self, is_enable: bool) { fn try_original_autostart_method(&self, is_enable: bool) {
let Some(app_handle) = Handle::global().app_handle() else { let app_handle = Handle::app_handle();
log::error!(target: "app", "App handle not available for autostart");
return;
};
let autostart_manager = app_handle.autolaunch(); let autostart_manager = app_handle.autolaunch();
if is_enable { if is_enable {
@@ -292,9 +289,7 @@ impl Sysopt {
} }
// 回退到原来的方法 // 回退到原来的方法
let app_handle = Handle::global() let app_handle = Handle::app_handle();
.app_handle()
.ok_or_else(|| anyhow::anyhow!("App handle not available"))?;
let autostart_manager = app_handle.autolaunch(); let autostart_manager = app_handle.autolaunch();
match autostart_manager.is_enabled() { match autostart_manager.is_enabled() {

View File

@@ -139,6 +139,27 @@ impl Timer {
Ok(()) Ok(())
} }
/// 每 3 秒更新系统托盘菜单,总共执行 3 次
pub fn add_update_tray_menu_task(&self) -> Result<()> {
let tid = self.timer_count.fetch_add(1, Ordering::SeqCst);
let delay_timer = self.delay_timer.write();
let task = TaskBuilder::default()
.set_task_id(tid)
.set_maximum_parallel_runnable_num(1)
.set_frequency_count_down_by_seconds(3, 3)
.spawn_async_routine(|| async move {
logging!(info, Type::Timer, "Updating tray menu");
crate::core::tray::Tray::global()
.update_tray_display()
.await
})
.context("failed to create update tray menu timer task")?;
delay_timer
.add_task(task)
.context("failed to add update tray menu timer task")?;
Ok(())
}
/// Refresh timer tasks with better error handling /// Refresh timer tasks with better error handling
pub async fn refresh(&self) -> Result<()> { pub async fn refresh(&self) -> Result<()> {
// Generate diff outside of lock to minimize lock contention // Generate diff outside of lock to minimize lock contention

View File

@@ -3,16 +3,13 @@ use tauri::Emitter;
use tauri::tray::TrayIconBuilder; use tauri::tray::TrayIconBuilder;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub mod speed_rate; pub mod speed_rate;
use crate::ipc::Rate;
use crate::module::lightweight; use crate::module::lightweight;
use crate::process::AsyncHandler; use crate::process::AsyncHandler;
use crate::utils::window_manager::WindowManager; use crate::utils::window_manager::WindowManager;
use crate::{ use crate::{
Type, cmd, Type, cmd,
config::Config, config::Config,
feat, feat, logging,
ipc::IpcManager,
logging,
module::lightweight::is_in_lightweight_mode, module::lightweight::is_in_lightweight_mode,
singleton_lazy, singleton_lazy,
utils::{dirs::find_target_icons, i18n::t}, utils::{dirs::find_target_icons, i18n::t},
@@ -34,6 +31,8 @@ use tauri::{
tray::{MouseButton, MouseButtonState, TrayIconEvent}, tray::{MouseButton, MouseButtonState, TrayIconEvent},
}; };
// TODO: 是否需要将可变菜单抽离存储起来,后续直接更新对应菜单实例,无需重新创建菜单(待考虑)
#[derive(Clone)] #[derive(Clone)]
struct TrayState {} struct TrayState {}
@@ -189,28 +188,25 @@ singleton_lazy!(Tray, TRAY, Tray::default);
impl Tray { impl Tray {
pub async fn init(&self) -> Result<()> { pub async fn init(&self) -> Result<()> {
let app_handle = handle::Handle::global() let app_handle = handle::Handle::app_handle();
.app_handle()
.ok_or_else(|| anyhow::anyhow!("Failed to get app handle for tray initialization"))?;
match self.create_tray_from_handle(&app_handle).await { match self.create_tray_from_handle(app_handle).await {
Ok(_) => { Ok(_) => {
log::info!(target: "app", "System tray created successfully"); log::info!(target: "app", "System tray created successfully");
Ok(())
} }
Err(e) => { Err(e) => {
log::warn!(target: "app", "System tray creation failed: {}, Application will continue running without tray icon", e);
// Don't return error, let application continue running without tray // Don't return error, let application continue running without tray
Ok(()) log::warn!(target: "app", "System tray creation failed: {}, Application will continue running without tray icon", e);
} }
} }
// TODO: 初始化时,暂时使用此方法更新系统托盘菜单,有效避免代理节点菜单空白
crate::core::timer::Timer::global().add_update_tray_menu_task()?;
Ok(())
} }
/// 更新托盘点击行为 /// 更新托盘点击行为
pub async fn update_click_behavior(&self) -> Result<()> { pub async fn update_click_behavior(&self) -> Result<()> {
let app_handle = handle::Handle::global() let app_handle = handle::Handle::app_handle();
.app_handle()
.ok_or_else(|| anyhow::anyhow!("Failed to get app handle for tray update"))?;
let tray_event = { Config::verge().await.latest_ref().tray_event.clone() }; let tray_event = { Config::verge().await.latest_ref().tray_event.clone() };
let tray_event: String = tray_event.unwrap_or("main_window".into()); let tray_event: String = tray_event.unwrap_or("main_window".into());
let tray = app_handle let tray = app_handle
@@ -250,18 +246,12 @@ impl Tray {
return Ok(()); return Ok(());
} }
let app_handle = match handle::Handle::global().app_handle() { let app_handle = handle::Handle::app_handle();
Some(handle) => handle,
None => {
log::warn!(target: "app", "更新托盘菜单失败: app_handle不存在");
return Ok(());
}
};
// 设置更新状态 // 设置更新状态
self.menu_updating.store(true, Ordering::Release); self.menu_updating.store(true, Ordering::Release);
let result = self.update_menu_internal(&app_handle).await; let result = self.update_menu_internal(app_handle).await;
{ {
let mut last_update = self.last_menu_update.lock(); let mut last_update = self.last_menu_update.lock();
@@ -318,14 +308,8 @@ impl Tray {
/// 更新托盘图标 /// 更新托盘图标
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub async fn update_icon(&self, _rate: Option<Rate>) -> Result<()> { pub async fn update_icon(&self) -> Result<()> {
let app_handle = match handle::Handle::global().app_handle() { let app_handle = handle::Handle::app_handle();
Some(handle) => handle,
None => {
log::warn!(target: "app", "更新托盘图标失败: app_handle不存在");
return Ok(());
}
};
let tray = match app_handle.tray_by_id("main") { let tray = match app_handle.tray_by_id("main") {
Some(tray) => tray, Some(tray) => tray,
@@ -355,14 +339,8 @@ impl Tray {
} }
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
pub async fn update_icon(&self, _rate: Option<Rate>) -> Result<()> { pub async fn update_icon(&self) -> Result<()> {
let app_handle = match handle::Handle::global().app_handle() { let app_handle = handle::Handle::app_handle();
Some(handle) => handle,
None => {
log::warn!(target: "app", "更新托盘图标失败: app_handle不存在");
return Ok(());
}
};
let tray = match app_handle.tray_by_id("main") { let tray = match app_handle.tray_by_id("main") {
Some(tray) => tray, Some(tray) => tray,
@@ -389,9 +367,7 @@ impl Tray {
/// 更新托盘显示状态的函数 /// 更新托盘显示状态的函数
pub async fn update_tray_display(&self) -> Result<()> { pub async fn update_tray_display(&self) -> Result<()> {
let app_handle = handle::Handle::global() let app_handle = handle::Handle::app_handle();
.app_handle()
.ok_or_else(|| anyhow::anyhow!("Failed to get app handle for tray update"))?;
let _tray = app_handle let _tray = app_handle
.tray_by_id("main") .tray_by_id("main")
.ok_or_else(|| anyhow::anyhow!("Failed to get main tray"))?; .ok_or_else(|| anyhow::anyhow!("Failed to get main tray"))?;
@@ -404,13 +380,7 @@ impl Tray {
/// 更新托盘提示 /// 更新托盘提示
pub async fn update_tooltip(&self) -> Result<()> { pub async fn update_tooltip(&self) -> Result<()> {
let app_handle = match handle::Handle::global().app_handle() { let app_handle = handle::Handle::app_handle();
Some(handle) => handle,
None => {
log::warn!(target: "app", "更新托盘提示失败: app_handle不存在");
return Ok(());
}
};
let verge = Config::verge().await.latest_ref().clone(); let verge = Config::verge().await.latest_ref().clone();
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false); let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
@@ -464,7 +434,7 @@ impl Tray {
// self.update_menu().await?; // self.update_menu().await?;
// 更新轻量模式显示状态 // 更新轻量模式显示状态
self.update_tray_display().await?; self.update_tray_display().await?;
self.update_icon(None).await?; self.update_icon().await?;
self.update_tooltip().await?; self.update_tooltip().await?;
Ok(()) Ok(())
} }
@@ -550,7 +520,7 @@ impl Tray {
// 确保所有状态更新完成 // 确保所有状态更新完成
self.update_tray_display().await?; self.update_tray_display().await?;
// self.update_menu().await?; // self.update_menu().await?;
self.update_icon(None).await?; self.update_icon().await?;
self.update_tooltip().await?; self.update_tooltip().await?;
Ok(()) Ok(())
@@ -578,14 +548,7 @@ async fn create_tray_menu(
.unwrap_or_default() .unwrap_or_default()
}; };
let proxy_nodes_data = cmd::get_proxies().await.unwrap_or_else(|e| { let proxy_nodes_data = handle::Handle::mihomo().await.get_proxies().await;
logging!(
error,
Type::Cmd,
"Failed to fetch proxies for tray menu: {e}"
);
serde_json::Value::Object(serde_json::Map::new())
});
let version = env!("CARGO_PKG_VERSION"); let version = env!("CARGO_PKG_VERSION");
@@ -638,46 +601,43 @@ async fn create_tray_menu(
let mut submenus = Vec::new(); let mut submenus = Vec::new();
let mut group_name_submenus_hash = HashMap::new(); let mut group_name_submenus_hash = HashMap::new();
if let Some(proxies) = proxy_nodes_data.get("proxies").and_then(|v| v.as_object()) { // TODO: 应用启动时,内核还未启动完全,无法获取代理节点信息
for (group_name, group_data) in proxies.iter() { if let Ok(proxy_nodes_data) = proxy_nodes_data {
for (group_name, group_data) in proxy_nodes_data.proxies.iter() {
// Filter groups based on mode // Filter groups based on mode
let should_show = match mode { let should_show = match mode {
"global" => group_name == "GLOBAL", "global" => group_name == "GLOBAL",
_ => group_name != "GLOBAL", _ => group_name != "GLOBAL",
} && } &&
// Check if the group is hidden // Check if the group is hidden
!group_data.get("hidden").and_then(|v| v.as_bool()).unwrap_or(false); !group_data.hidden.unwrap_or_default();
if !should_show { if !should_show {
continue; continue;
} }
let Some(all_proxies) = group_data.get("all").and_then(|v| v.as_array()) else { let Some(all_proxies) = group_data.all.as_ref() else {
continue; continue;
}; };
let now_proxy = group_data.get("now").and_then(|v| v.as_str()).unwrap_or(""); let now_proxy = group_data.now.as_deref().unwrap_or_default();
// Create proxy items // Create proxy items
let group_items: Vec<CheckMenuItem<Wry>> = all_proxies let group_items: Vec<CheckMenuItem<Wry>> = all_proxies
.iter() .iter()
.filter_map(|proxy_name| proxy_name.as_str())
.filter_map(|proxy_str| { .filter_map(|proxy_str| {
let is_selected = proxy_str == now_proxy; let is_selected = *proxy_str == now_proxy;
let item_id = format!("proxy_{}_{}", group_name, proxy_str); let item_id = format!("proxy_{}_{}", group_name, proxy_str);
// Get delay for display // Get delay for display
let delay_text = proxies let delay_text = proxy_nodes_data
.proxies
.get(proxy_str) .get(proxy_str)
.and_then(|p| p.get("history")) .and_then(|h| h.history.last())
.and_then(|h| h.as_array()) .map(|h| match h.delay {
.and_then(|h| h.last()) 0 => "-ms".to_string(),
.and_then(|r| r.get("delay"))
.and_then(|d| d.as_i64())
.map(|delay| match delay {
-1 => "-ms".to_string(),
delay if delay >= 10000 => "-ms".to_string(), delay if delay >= 10000 => "-ms".to_string(),
_ => format!("{}ms", delay), _ => format!("{}ms", h.delay),
}) })
.unwrap_or_else(|| "-ms".to_string()); .unwrap_or_else(|| "-ms".to_string());
@@ -1066,29 +1026,30 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
let group_name = parts[1]; let group_name = parts[1];
let proxy_name = parts[2]; let proxy_name = parts[2];
match cmd::proxy::update_proxy_and_sync( match handle::Handle::mihomo()
group_name.to_string(), .await
proxy_name.to_string(), .select_node_for_group(group_name, proxy_name)
) .await
.await
{ {
Ok(_) => { Ok(_) => {
log::info!(target: "app", "切换代理成功: {} -> {}", group_name, proxy_name); log::info!(target: "app", "切换代理成功: {} -> {}", group_name, proxy_name);
let _ = handle::Handle::app_handle()
.emit("verge://refresh-proxy-config", ());
} }
Err(e) => { Err(e) => {
log::error!(target: "app", "切换代理失败: {} -> {}, 错误: {:?}", group_name, proxy_name, e); log::error!(target: "app", "切换代理失败: {} -> {}, 错误: {:?}", group_name, proxy_name, e);
// Fallback to IPC update // Fallback to IPC update
if (IpcManager::global() if (handle::Handle::mihomo()
.update_proxy(group_name, proxy_name) .await
.select_node_for_group(group_name, proxy_name)
.await) .await)
.is_ok() .is_ok()
{ {
log::info!(target: "app", "代理切换回退成功: {} -> {}", group_name, proxy_name); log::info!(target: "app", "代理切换回退成功: {} -> {}", group_name, proxy_name);
if let Some(app_handle) = handle::Handle::global().app_handle() { let app_handle = handle::Handle::app_handle();
let _ = app_handle.emit("verge://force-refresh-proxies", ()); let _ = app_handle.emit("verge://force-refresh-proxies", ());
}
} }
} }
} }

View File

@@ -1,14 +1,11 @@
use crate::{ use crate::{
config::Config, config::Config,
core::{CoreManager, handle, tray}, core::{CoreManager, handle, tray},
ipc::IpcManager,
logging_error, logging_error,
process::AsyncHandler, process::AsyncHandler,
utils::{logging::Type, resolve}, utils::{logging::Type, resolve},
}; };
use serde_yaml_ng::{Mapping, Value}; use serde_yaml_ng::{Mapping, Value};
use std::env;
use std::process::{Command, exit};
/// Restart the Clash core /// Restart the Clash core
pub async fn restart_clash_core() { pub async fn restart_clash_core() {
@@ -35,55 +32,57 @@ pub async fn restart_app() {
return; return;
} }
handle::Handle::notice_message("restart_app::info", "Restarting application..."); let app_handle = handle::Handle::app_handle();
app_handle.restart();
// TODO: PR Ref: https://github.com/clash-verge-rev/clash-verge-rev/pull/4960
// handle::Handle::notice_message("restart_app::info", "Restarting application...");
// Use the manual restart method consistently to ensure reliability across platforms // // Use the manual restart method consistently to ensure reliability across platforms
// This addresses the issue where app_handle.restart() doesn't work properly on Windows // // This addresses the issue where app_handle.restart() doesn't work properly on Windows
let current_exe = match env::current_exe() { // let current_exe = match env::current_exe() {
Ok(path) => path, // Ok(path) => path,
Err(_) => { // Err(_) => {
// If we can't get the current executable path, try to use the fallback method // // If we can't get the current executable path, try to use the fallback method
if let Some(app_handle) = handle::Handle::global().app_handle() { // if let Some(app_handle) = handle::Handle::global().app_handle() {
app_handle.restart(); // app_handle.restart();
} // }
exit(1); // If we reach here, either app_handle was None or restart() failed to restart // exit(1); // If we reach here, either app_handle was None or restart() failed to restart
} // }
}; // };
let mut cmd = Command::new(current_exe); // let mut cmd = Command::new(current_exe);
cmd.args(env::args().skip(1)); // cmd.args(env::args().skip(1));
match cmd.spawn() { // match cmd.spawn() {
Ok(child) => { // Ok(child) => {
log::info!(target: "app", "New application instance started with PID: {}", child.id()); // log::info!(target: "app", "New application instance started with PID: {}", child.id());
// Successfully started new process, now exit current process // // Successfully started new process, now exit current process
if let Some(app_handle) = handle::Handle::global().app_handle() { // if let Some(app_handle) = handle::Handle::global().app_handle() {
app_handle.exit(0); // app_handle.exit(0);
} else { // } else {
exit(0); // exit(0);
} // }
} // }
Err(e) => { // Err(e) => {
log::error!(target: "app", "Failed to start new application instance: {}", e); // log::error!(target: "app", "Failed to start new application instance: {}", e);
// If manual spawn fails, try the original restart method as a last resort // // If manual spawn fails, try the original restart method as a last resort
if let Some(app_handle) = handle::Handle::global().app_handle() { // if let Some(app_handle) = handle::Handle::global().app_handle() {
app_handle.restart(); // app_handle.restart();
} else { // } else {
exit(1); // exit(1);
} // }
} // }
} // }
} }
fn after_change_clash_mode() { fn after_change_clash_mode() {
AsyncHandler::spawn(move || async { AsyncHandler::spawn(move || async {
match IpcManager::global().get_connections().await { let mihomo = handle::Handle::mihomo().await;
match mihomo.get_connections().await {
Ok(connections) => { Ok(connections) => {
if let Some(connections_array) = connections["connections"].as_array() { if let Some(connections_array) = connections.connections {
for connection in connections_array { for connection in connections_array {
if let Some(id) = connection["id"].as_str() { let _ = mihomo.close_connection(&connection.id).await;
let _ = IpcManager::global().delete_connection(id).await;
}
} }
} }
} }
@@ -103,7 +102,11 @@ pub async fn change_clash_mode(mode: String) {
"mode": mode "mode": mode
}); });
log::debug!(target: "app", "change clash mode to {mode}"); log::debug!(target: "app", "change clash mode to {mode}");
match IpcManager::global().patch_configs(json_value).await { match handle::Handle::mihomo()
.await
.patch_base_config(&json_value)
.await
{
Ok(_) => { Ok(_) => {
// 更新订阅 // 更新订阅
Config::clash().await.data_mut().patch_config(mapping); Config::clash().await.data_mut().patch_config(mapping);
@@ -113,11 +116,7 @@ pub async fn change_clash_mode(mode: String) {
if clash_data.save_config().await.is_ok() { if clash_data.save_config().await.is_ok() {
handle::Handle::refresh_clash(); handle::Handle::refresh_clash();
logging_error!(Type::Tray, true, tray::Tray::global().update_menu().await); logging_error!(Type::Tray, true, tray::Tray::global().update_menu().await);
logging_error!( logging_error!(Type::Tray, true, tray::Tray::global().update_icon().await);
Type::Tray,
true,
tray::Tray::global().update_icon(None).await
);
} }
let is_auto_close_connection = Config::verge() let is_auto_close_connection = Config::verge()

View File

@@ -23,11 +23,7 @@ pub async fn patch_clash(patch: Mapping) -> Result<()> {
} else { } else {
if patch.get("mode").is_some() { if patch.get("mode").is_some() {
logging_error!(Type::Tray, true, tray::Tray::global().update_menu().await); logging_error!(Type::Tray, true, tray::Tray::global().update_menu().await);
logging_error!( logging_error!(Type::Tray, true, tray::Tray::global().update_icon().await);
Type::Tray,
true,
tray::Tray::global().update_icon(None).await
);
} }
Config::runtime().await.draft_mut().patch_config(patch); Config::runtime().await.draft_mut().patch_config(patch);
CoreManager::global().update_config().await?; CoreManager::global().update_config().await?;
@@ -211,7 +207,7 @@ pub async fn patch_verge(patch: IVerge, not_save_file: bool) -> Result<()> {
tray::Tray::global().update_menu().await?; tray::Tray::global().update_menu().await?;
} }
if (update_flags & (UpdateFlags::SystrayIcon as i32)) != 0 { if (update_flags & (UpdateFlags::SystrayIcon as i32)) != 0 {
tray::Tray::global().update_icon(None).await?; tray::Tray::global().update_icon().await?;
} }
if (update_flags & (UpdateFlags::SystrayTooltip as i32)) != 0 { if (update_flags & (UpdateFlags::SystrayTooltip as i32)) != 0 {
tray::Tray::global().update_tooltip().await?; tray::Tray::global().update_tooltip().await?;

View File

@@ -143,15 +143,15 @@ pub async fn update_profile(
Ok(_) => { Ok(_) => {
logging!(info, Type::Config, true, "[订阅更新] 更新成功"); logging!(info, Type::Config, true, "[订阅更新] 更新成功");
handle::Handle::refresh_clash(); handle::Handle::refresh_clash();
if let Err(err) = cmd::proxy::force_refresh_proxies().await { // if let Err(err) = cmd::proxy::force_refresh_proxies().await {
logging!( // logging!(
error, // error,
Type::Config, // Type::Config,
true, // true,
"[订阅更新] 代理组刷新失败: {}", // "[订阅更新] 代理组刷新失败: {}",
err // err
); // );
} // }
} }
Err(err) => { Err(err) => {
logging!(error, Type::Config, true, "[订阅更新] 更新失败: {}", err); logging!(error, Type::Config, true, "[订阅更新] 更新失败: {}", err);

View File

@@ -1,9 +1,6 @@
use crate::{ use crate::{
config::{Config, IVerge}, config::{Config, IVerge},
core::handle, core::handle,
ipc::IpcManager,
logging,
utils::logging::Type,
}; };
use std::env; use std::env;
use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_clipboard_manager::ClipboardExt;
@@ -26,7 +23,7 @@ pub async fn toggle_system_proxy() {
// 如果当前系统代理即将关闭且自动关闭连接设置为true则关闭所有连接 // 如果当前系统代理即将关闭且自动关闭连接设置为true则关闭所有连接
if enable if enable
&& auto_close_connection && auto_close_connection
&& let Err(err) = IpcManager::global().close_all_connections().await && let Err(err) = handle::Handle::mihomo().await.close_all_connections().await
{ {
log::error!(target: "app", "Failed to close all connections: {err}"); log::error!(target: "app", "Failed to close all connections: {err}");
} }
@@ -78,14 +75,7 @@ pub async fn copy_clash_env() {
.unwrap_or_else(|| "127.0.0.1".to_string()), .unwrap_or_else(|| "127.0.0.1".to_string()),
}; };
let Some(app_handle) = handle::Handle::global().app_handle() else { let app_handle = handle::Handle::app_handle();
logging!(
error,
Type::System,
"Failed to get app handle for proxy operation"
);
return;
};
let port = { let port = {
Config::verge() Config::verge()
.await .await

View File

@@ -2,7 +2,6 @@ use crate::utils::window_manager::WindowManager;
use crate::{ use crate::{
config::Config, config::Config,
core::{CoreManager, handle, sysopt}, core::{CoreManager, handle, sysopt},
ipc::IpcManager,
logging, logging,
module::lightweight, module::lightweight,
utils::logging::Type, utils::logging::Type,
@@ -23,17 +22,12 @@ async fn open_or_close_dashboard_internal() {
pub async fn quit() { pub async fn quit() {
logging!(debug, Type::System, true, "启动退出流程"); logging!(debug, Type::System, true, "启动退出流程");
let Some(app_handle) = handle::Handle::global().app_handle() else { // 获取应用句柄并设置退出标志
logging!( let app_handle = handle::Handle::app_handle();
error,
Type::System,
"Failed to get app handle for quit operation"
);
return;
};
handle::Handle::global().set_is_exiting(); handle::Handle::global().set_is_exiting();
if let Some(window) = handle::Handle::global().get_window() { // 优先关闭窗口,提供立即反馈
if let Some(window) = handle::Handle::get_window() {
let _ = window.hide(); let _ = window.hide();
log::info!(target: "app", "窗口已隐藏"); log::info!(target: "app", "窗口已隐藏");
} }
@@ -69,7 +63,14 @@ async fn clean_async() -> bool {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
let tun_timeout = Duration::from_secs(2); let tun_timeout = Duration::from_secs(2);
match timeout(tun_timeout, IpcManager::global().patch_configs(disable_tun)).await { match timeout(
tun_timeout,
handle::Handle::mihomo()
.await
.patch_base_config(&disable_tun),
)
.await
{
Ok(Ok(_)) => { Ok(Ok(_)) => {
log::info!(target: "app", "TUN模式已禁用"); log::info!(target: "app", "TUN模式已禁用");
tokio::time::sleep(Duration::from_millis(300)).await; tokio::time::sleep(Duration::from_millis(300)).await;
@@ -314,7 +315,7 @@ pub async fn hide() {
add_light_weight_timer().await; add_light_weight_timer().await;
} }
if let Some(window) = handle::Handle::global().get_window() if let Some(window) = handle::Handle::get_window()
&& window.is_visible().unwrap_or(false) && window.is_visible().unwrap_or(false)
{ {
let _ = window.hide(); let _ = window.hide();

View File

@@ -1,376 +0,0 @@
use std::time::Duration;
use kode_bridge::{
ClientConfig, IpcHttpClient, LegacyResponse,
errors::{AnyError, AnyResult},
};
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
use crate::{
logging, singleton_with_logging,
utils::{dirs::ipc_path, logging::Type},
};
// 定义用于URL路径的编码集合只编码真正必要的字符
const URL_PATH_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ') // 空格
.add(b'/') // 斜杠
.add(b'?') // 问号
.add(b'#') // 井号
.add(b'&') // 和号
.add(b'%'); // 百分号
// Helper function to create AnyError from string
fn create_error(msg: impl Into<String>) -> AnyError {
Box::new(std::io::Error::other(msg.into()))
}
pub struct IpcManager {
client: IpcHttpClient,
}
impl IpcManager {
pub fn new() -> Self {
logging!(info, Type::Ipc, true, "Creating new IpcManager instance");
let ipc_path_buf = ipc_path().unwrap_or_else(|e| {
logging!(error, Type::Ipc, true, "Failed to get IPC path: {}", e);
std::path::PathBuf::from("/tmp/clash-verge-ipc") // fallback path
});
let ipc_path = ipc_path_buf.to_str().unwrap_or_default();
let config = ClientConfig {
default_timeout: Duration::from_secs(5),
enable_pooling: false,
max_retries: 4,
retry_delay: Duration::from_millis(125),
max_concurrent_requests: 16,
max_requests_per_second: Some(64.0),
..Default::default()
};
#[allow(clippy::unwrap_used)]
let client = IpcHttpClient::with_config(ipc_path, config).unwrap();
Self { client }
}
}
impl IpcManager {
pub async fn request(
&self,
method: &str,
path: &str,
body: Option<&serde_json::Value>,
) -> AnyResult<LegacyResponse> {
self.client.request(method, path, body).await
}
}
impl IpcManager {
pub async fn send_request(
&self,
method: &str,
path: &str,
body: Option<&serde_json::Value>,
) -> AnyResult<serde_json::Value> {
let response = IpcManager::global().request(method, path, body).await?;
match method {
"GET" => Ok(response.json()?),
"PATCH" => {
if response.status == 204 {
Ok(serde_json::json!({"code": 204}))
} else {
Ok(response.json()?)
}
}
"PUT" | "DELETE" => {
if response.status == 204 {
Ok(serde_json::json!({"code": 204}))
} else {
match response.json() {
Ok(json) => Ok(json),
Err(_) => Ok(serde_json::json!({
"code": response.status,
"message": response.body,
"error": "failed to parse response as JSON"
})),
}
}
}
_ => match response.json() {
Ok(json) => Ok(json),
Err(_) => Ok(serde_json::json!({
"code": response.status,
"message": response.body,
"error": "failed to parse response as JSON"
})),
},
}
}
// 基础代理信息获取
pub async fn get_proxies(&self) -> AnyResult<serde_json::Value> {
let url = "/proxies";
self.send_request("GET", url, None).await
}
// 代理提供者信息获取
pub async fn get_providers_proxies(&self) -> AnyResult<serde_json::Value> {
let url = "/providers/proxies";
self.send_request("GET", url, None).await
}
// 连接管理
pub async fn get_connections(&self) -> AnyResult<serde_json::Value> {
let url = "/connections";
self.send_request("GET", url, None).await
}
pub async fn delete_connection(&self, id: &str) -> AnyResult<()> {
let encoded_id = utf8_percent_encode(id, URL_PATH_ENCODE_SET).to_string();
let url = format!("/connections/{encoded_id}");
let response = self.send_request("DELETE", &url, None).await?;
if response["code"] == 204 {
Ok(())
} else {
Err(create_error(
response["message"].as_str().unwrap_or("unknown error"),
))
}
}
pub async fn close_all_connections(&self) -> AnyResult<()> {
let url = "/connections";
let response = self.send_request("DELETE", url, None).await?;
if response["code"] == 204 {
Ok(())
} else {
Err(create_error(
response["message"]
.as_str()
.unwrap_or("unknown error")
.to_owned(),
))
}
}
}
impl IpcManager {
#[allow(dead_code)]
pub async fn is_mihomo_running(&self) -> AnyResult<()> {
let url = "/version";
let _response = self.send_request("GET", url, None).await?;
Ok(())
}
pub async fn put_configs_force(&self, clash_config_path: &str) -> AnyResult<()> {
let url = "/configs?force=true";
let payload = serde_json::json!({
"path": clash_config_path,
});
let _response = self.send_request("PUT", url, Some(&payload)).await?;
Ok(())
}
pub async fn patch_configs(&self, config: serde_json::Value) -> AnyResult<()> {
let url = "/configs";
let response = self.send_request("PATCH", url, Some(&config)).await?;
if response["code"] == 204 {
Ok(())
} else {
Err(create_error(
response["message"]
.as_str()
.unwrap_or("unknown error")
.to_owned(),
))
}
}
pub async fn test_proxy_delay(
&self,
name: &str,
test_url: Option<String>,
timeout: i32,
) -> AnyResult<serde_json::Value> {
let test_url =
test_url.unwrap_or_else(|| "https://cp.cloudflare.com/generate_204".to_string());
let encoded_name = utf8_percent_encode(name, URL_PATH_ENCODE_SET).to_string();
// 测速URL不再编码直接传递
let url = format!("/proxies/{encoded_name}/delay?url={test_url}&timeout={timeout}");
self.send_request("GET", &url, None).await
}
// 版本和配置相关
pub async fn get_version(&self) -> AnyResult<serde_json::Value> {
let url = "/version";
self.send_request("GET", url, None).await
}
pub async fn get_config(&self) -> AnyResult<serde_json::Value> {
let url = "/configs";
self.send_request("GET", url, None).await
}
pub async fn update_geo_data(&self) -> AnyResult<()> {
let url = "/configs/geo";
let response = self.send_request("POST", url, None).await?;
if response["code"] == 204 {
Ok(())
} else {
Err(create_error(
response["message"]
.as_str()
.unwrap_or("unknown error")
.to_string(),
))
}
}
pub async fn upgrade_core(&self) -> AnyResult<()> {
let url = "/upgrade";
let response = self.send_request("POST", url, None).await?;
if response["code"] == 204 {
Ok(())
} else {
Err(create_error(
response["message"]
.as_str()
.unwrap_or("unknown error")
.to_string(),
))
}
}
// 规则相关
pub async fn get_rules(&self) -> AnyResult<serde_json::Value> {
let url = "/rules";
self.send_request("GET", url, None).await
}
pub async fn get_rule_providers(&self) -> AnyResult<serde_json::Value> {
let url = "/providers/rules";
self.send_request("GET", url, None).await
}
pub async fn update_rule_provider(&self, name: &str) -> AnyResult<()> {
let encoded_name = utf8_percent_encode(name, URL_PATH_ENCODE_SET).to_string();
let url = format!("/providers/rules/{encoded_name}");
let response = self.send_request("PUT", &url, None).await?;
if response["code"] == 204 {
Ok(())
} else {
Err(create_error(
response["message"]
.as_str()
.unwrap_or("unknown error")
.to_string(),
))
}
}
// 代理相关
pub async fn update_proxy(&self, group: &str, proxy: &str) -> AnyResult<()> {
// 使用 percent-encoding 进行正确的 URL 编码
let encoded_group = utf8_percent_encode(group, URL_PATH_ENCODE_SET).to_string();
let url = format!("/proxies/{encoded_group}");
let payload = serde_json::json!({
"name": proxy
});
// println!("group: {}, proxy: {}", group, proxy);
match self.send_request("PUT", &url, Some(&payload)).await {
Ok(_) => {
// println!("updateProxy response: {:?}", response);
Ok(())
}
Err(e) => {
// println!("updateProxy encountered error: {}", e);
logging!(
error,
crate::utils::logging::Type::Ipc,
true,
"IPC: updateProxy encountered error: {} (ignored, always returning true)",
e
);
Ok(())
}
}
}
pub async fn proxy_provider_health_check(&self, name: &str) -> AnyResult<()> {
let encoded_name = utf8_percent_encode(name, URL_PATH_ENCODE_SET).to_string();
let url = format!("/providers/proxies/{encoded_name}/healthcheck");
let response = self.send_request("GET", &url, None).await?;
if response["code"] == 204 {
Ok(())
} else {
Err(create_error(
response["message"]
.as_str()
.unwrap_or("unknown error")
.to_string(),
))
}
}
pub async fn update_proxy_provider(&self, name: &str) -> AnyResult<()> {
let encoded_name = utf8_percent_encode(name, URL_PATH_ENCODE_SET).to_string();
let url = format!("/providers/proxies/{encoded_name}");
let response = self.send_request("PUT", &url, None).await?;
if response["code"] == 204 {
Ok(())
} else {
Err(create_error(
response["message"]
.as_str()
.unwrap_or("unknown error")
.to_string(),
))
}
}
// 延迟测试相关
pub async fn get_group_proxy_delays(
&self,
group_name: &str,
url: Option<String>,
timeout: i32,
) -> AnyResult<serde_json::Value> {
let test_url = url.unwrap_or_else(|| "https://cp.cloudflare.com/generate_204".to_string());
let encoded_group_name = utf8_percent_encode(group_name, URL_PATH_ENCODE_SET).to_string();
// 测速URL不再编码直接传递
let url = format!("/group/{encoded_group_name}/delay?url={test_url}&timeout={timeout}");
self.send_request("GET", &url, None).await
}
// 调试相关
pub async fn is_debug_enabled(&self) -> AnyResult<bool> {
let url = "/debug/pprof";
match self.send_request("GET", url, None).await {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}
}
pub async fn gc(&self) -> AnyResult<()> {
let url = "/debug/gc";
let response = self.send_request("PUT", url, None).await?;
if response["code"] == 204 {
Ok(())
} else {
Err(create_error(
response["message"]
.as_str()
.unwrap_or("unknown error")
.to_string(),
))
}
}
// 日志相关功能已迁移到 logs.rs 模块,使用流式处理
}
// Use singleton macro with logging
singleton_with_logging!(IpcManager, INSTANCE, "IpcManager");

View File

@@ -1,330 +0,0 @@
use serde::{Deserialize, Serialize};
use std::{collections::VecDeque, sync::Arc, time::Instant};
use tauri::async_runtime::JoinHandle;
use tokio::{sync::RwLock, time::Duration};
use crate::{
ipc::monitor::MonitorData,
logging,
process::AsyncHandler,
singleton_with_logging,
utils::{dirs::ipc_path, logging::Type},
};
const MAX_LOGS: usize = 1000; // Maximum number of logs to keep in memory
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LogData {
#[serde(rename = "type")]
pub log_type: String,
pub payload: String,
}
#[derive(Debug, Clone)]
pub struct LogItem {
pub log_type: String,
pub payload: String,
pub time: String,
}
impl LogItem {
fn new(log_type: String, payload: String) -> Self {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| std::time::Duration::from_secs(0))
.as_secs();
// Simple time formatting (HH:MM:SS)
let hours = (now / 3600) % 24;
let minutes = (now / 60) % 60;
let seconds = now % 60;
let time_str = format!("{hours:02}:{minutes:02}:{seconds:02}");
Self {
log_type,
payload,
time: time_str,
}
}
}
#[derive(Debug, Clone)]
pub struct CurrentLogs {
pub logs: VecDeque<LogItem>,
// pub level: String,
pub last_updated: Instant,
}
impl Default for CurrentLogs {
fn default() -> Self {
Self {
logs: VecDeque::with_capacity(MAX_LOGS),
// level: "info".to_string(),
last_updated: Instant::now(),
}
}
}
impl MonitorData for CurrentLogs {
fn mark_fresh(&mut self) {
self.last_updated = Instant::now();
}
fn is_fresh_within(&self, duration: Duration) -> bool {
self.last_updated.elapsed() < duration
}
}
// Logs monitor with streaming support
pub struct LogsMonitor {
current: Arc<RwLock<CurrentLogs>>,
task_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
current_monitoring_level: Arc<RwLock<Option<String>>>,
}
// Use singleton_with_logging macro
singleton_with_logging!(LogsMonitor, INSTANCE, "LogsMonitor");
impl LogsMonitor {
fn new() -> Self {
let current = Arc::new(RwLock::new(CurrentLogs::default()));
Self {
current,
task_handle: Arc::new(RwLock::new(None)),
current_monitoring_level: Arc::new(RwLock::new(None)),
}
}
pub async fn start_monitoring(&self, level: Option<String>) {
let filter_level = level.clone().unwrap_or_else(|| "info".to_string());
// Check if we're already monitoring the same level
// let level_changed = {
// let current_level = self.current_monitoring_level.read().await;
// if let Some(existing_level) = current_level.as_ref() {
// if existing_level == &filter_level {
// logging!(
// info,
// Type::Ipc,
// true,
// "LogsMonitor: Already monitoring level '{}', skipping duplicate request",
// filter_level
// );
// return;
// }
// true // Level changed
// } else {
// true // First time or was stopped
// }
// };
// Stop existing monitoring task if level changed or first time
{
let mut handle = self.task_handle.write().await;
if let Some(task) = handle.take() {
task.abort();
logging!(
info,
Type::Ipc,
true,
"LogsMonitor: Stopped previous monitoring task (level changed)"
);
}
}
// We want to keep the logs cache even if the level changes,
// so we don't clear it here. The cache will be cleared only when the level changes
// and a new task is started. This allows us to keep logs from previous levels
// even if the level changes during monitoring.
// Clear logs cache when level changes to ensure fresh data
// if level_changed {
// let mut current = self.current.write().await;
// current.logs.clear();
// current.level = filter_level.clone();
// current.mark_fresh();
// logging!(
// info,
// Type::Ipc,
// true,
// "LogsMonitor: Cleared logs cache due to level change to '{}'",
// filter_level
// );
// }
// Update current monitoring level
{
let mut current_level = self.current_monitoring_level.write().await;
*current_level = Some(filter_level.clone());
}
let monitor_current = Arc::clone(&self.current);
let task = AsyncHandler::spawn(move || async move {
loop {
// Get fresh IPC path and client for each connection attempt
let (_ipc_path_buf, client) = match Self::create_ipc_client() {
Ok((path, client)) => (path, client),
Err(e) => {
logging!(error, Type::Ipc, true, "Failed to create IPC client: {}", e);
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
};
let url = if filter_level == "all" {
"/logs".to_string()
} else {
format!("/logs?level={filter_level}")
};
logging!(
info,
Type::Ipc,
true,
"LogsMonitor: Starting stream for {}",
url
);
let _ = client
.get(&url)
.timeout(Duration::from_secs(30))
.process_lines(|line| {
Self::process_log_line(line, Arc::clone(&monitor_current))
})
.await;
// Wait before retrying
tokio::time::sleep(Duration::from_secs(2)).await;
}
});
// Store the task handle
{
let mut handle = self.task_handle.write().await;
*handle = Some(task);
}
logging!(
info,
Type::Ipc,
true,
"LogsMonitor: Started new monitoring task for level: {:?}",
level
);
}
pub async fn stop_monitoring(&self) {
// Stop monitoring task but keep logs
{
let mut handle = self.task_handle.write().await;
if let Some(task) = handle.take() {
task.abort();
logging!(
info,
Type::Ipc,
true,
"LogsMonitor: Stopped monitoring task"
);
}
}
// Reset monitoring level
{
let mut monitoring_level = self.current_monitoring_level.write().await;
*monitoring_level = None;
}
}
fn create_ipc_client() -> Result<
(std::path::PathBuf, kode_bridge::IpcStreamClient),
Box<dyn std::error::Error + Send + Sync>,
> {
use kode_bridge::IpcStreamClient;
let ipc_path_buf = ipc_path()?;
let ipc_path = ipc_path_buf.to_str().ok_or("Invalid IPC path")?;
let client = IpcStreamClient::new(ipc_path)?;
Ok((ipc_path_buf, client))
}
fn process_log_line(
line: &str,
current: Arc<RwLock<CurrentLogs>>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let Ok(log_data) = serde_json::from_str::<LogData>(line.trim()) {
// Server-side filtering via query parameters handles the level filtering
// We only need to accept all logs since filtering is done at the endpoint level
let log_item = LogItem::new(log_data.log_type, log_data.payload);
AsyncHandler::spawn(move || async move {
let mut logs = current.write().await;
// Add new log
logs.logs.push_back(log_item);
// Keep only the last 1000 logs
if logs.logs.len() > 1000 {
logs.logs.pop_front();
}
logs.mark_fresh();
});
}
Ok(())
}
pub async fn current(&self) -> CurrentLogs {
self.current.read().await.clone()
}
pub async fn clear_logs(&self) {
let mut current = self.current.write().await;
current.logs.clear();
current.mark_fresh();
logging!(
info,
Type::Ipc,
true,
"LogsMonitor: Cleared frontend logs (monitoring continues)"
);
}
pub async fn get_logs_as_json(&self) -> serde_json::Value {
let current = self.current().await;
// Simply return all cached logs since filtering is handled by start_monitoring
// and the cache is cleared when level changes
let logs: Vec<serde_json::Value> = current
.logs
.iter()
.map(|log| {
serde_json::json!({
"type": log.log_type,
"payload": log.payload,
"time": log.time
})
})
.collect();
serde_json::Value::Array(logs)
}
}
pub async fn start_logs_monitoring(level: Option<String>) {
LogsMonitor::global().start_monitoring(level).await;
}
pub async fn stop_logs_monitoring() {
LogsMonitor::global().stop_monitoring().await;
}
pub async fn clear_logs() {
LogsMonitor::global().clear_logs().await;
}
pub async fn get_logs_json() -> serde_json::Value {
LogsMonitor::global().get_logs_as_json().await
}

View File

@@ -1,119 +0,0 @@
use serde::{Deserialize, Serialize};
use std::{sync::Arc, time::Instant};
use tokio::{sync::RwLock, time::Duration};
use crate::{
ipc::monitor::{IpcStreamMonitor, MonitorData, StreamingParser},
process::AsyncHandler,
singleton_lazy_with_logging,
utils::format::fmt_bytes,
};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MemoryData {
pub inuse: u64,
pub oslimit: u64,
}
#[derive(Debug, Clone)]
pub struct CurrentMemory {
pub inuse: u64,
pub oslimit: u64,
pub last_updated: Instant,
}
impl Default for CurrentMemory {
fn default() -> Self {
Self {
inuse: 0,
oslimit: 0,
last_updated: Instant::now(),
}
}
}
impl MonitorData for CurrentMemory {
fn mark_fresh(&mut self) {
self.last_updated = Instant::now();
}
fn is_fresh_within(&self, duration: Duration) -> bool {
self.last_updated.elapsed() < duration
}
}
impl StreamingParser for CurrentMemory {
fn parse_and_update(
line: &str,
current: Arc<RwLock<Self>>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let Ok(memory) = serde_json::from_str::<MemoryData>(line.trim()) {
AsyncHandler::spawn(move || async move {
let mut current_guard = current.write().await;
current_guard.inuse = memory.inuse;
current_guard.oslimit = memory.oslimit;
current_guard.mark_fresh();
});
}
Ok(())
}
}
// Minimal memory monitor using the new architecture
pub struct MemoryMonitor {
monitor: IpcStreamMonitor<CurrentMemory>,
}
impl Default for MemoryMonitor {
fn default() -> Self {
MemoryMonitor {
monitor: IpcStreamMonitor::new(
"/memory".to_string(),
Duration::from_secs(10),
Duration::from_secs(2),
Duration::from_secs(10),
),
}
}
}
// Use simplified singleton_lazy_with_logging macro
singleton_lazy_with_logging!(
MemoryMonitor,
INSTANCE,
"MemoryMonitor",
MemoryMonitor::default
);
impl MemoryMonitor {
pub async fn current(&self) -> CurrentMemory {
self.monitor.current().await
}
pub async fn is_fresh(&self) -> bool {
self.monitor.is_fresh().await
}
}
pub async fn get_current_memory() -> CurrentMemory {
MemoryMonitor::global().current().await
}
pub async fn get_formatted_memory() -> (String, String, f64, bool) {
let monitor = MemoryMonitor::global();
let memory = monitor.current().await;
let is_fresh = monitor.is_fresh().await;
let usage_percent = if memory.oslimit > 0 {
(memory.inuse as f64 / memory.oslimit as f64) * 100.0
} else {
0.0
};
(
fmt_bytes(memory.inuse),
fmt_bytes(memory.oslimit),
usage_percent,
is_fresh,
)
}

View File

@@ -1,15 +0,0 @@
pub mod general;
pub mod logs;
pub mod memory;
pub mod monitor;
pub mod traffic;
pub use general::IpcManager;
pub use logs::{clear_logs, get_logs_json, start_logs_monitoring, stop_logs_monitoring};
pub use memory::{get_current_memory, get_formatted_memory};
pub use traffic::{get_current_traffic, get_formatted_traffic};
pub struct Rate {
// pub up: usize,
// pub down: usize,
}

View File

@@ -1,120 +0,0 @@
use kode_bridge::IpcStreamClient;
use std::sync::Arc;
use tokio::{sync::RwLock, time::Duration};
use crate::{
logging,
process::AsyncHandler,
utils::{dirs::ipc_path, logging::Type},
};
/// Generic base structure for IPC monitoring data with freshness tracking
pub trait MonitorData: Clone + Send + Sync + 'static {
/// Update the last_updated timestamp to now
fn mark_fresh(&mut self);
/// Check if data is fresh based on the given duration
fn is_fresh_within(&self, duration: Duration) -> bool;
}
/// Trait for parsing streaming data and updating monitor state
pub trait StreamingParser: MonitorData {
/// Parse a line of streaming data and update the current state
fn parse_and_update(
line: &str,
current: Arc<RwLock<Self>>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
}
/// Generic IPC stream monitor that handles the common streaming pattern
pub struct IpcStreamMonitor<T>
where
T: MonitorData + StreamingParser + Default,
{
current: Arc<RwLock<T>>,
#[allow(dead_code)]
endpoint: String,
#[allow(dead_code)]
timeout: Duration,
#[allow(dead_code)]
retry_interval: Duration,
freshness_duration: Duration,
}
impl<T> IpcStreamMonitor<T>
where
T: MonitorData + StreamingParser + Default,
{
pub fn new(
endpoint: String,
timeout: Duration,
retry_interval: Duration,
freshness_duration: Duration,
) -> Self {
let current = Arc::new(RwLock::new(T::default()));
let monitor_current = Arc::clone(&current);
let endpoint_clone = endpoint.clone();
// Start the monitoring task
AsyncHandler::spawn(move || async move {
Self::streaming_task(monitor_current, endpoint_clone, timeout, retry_interval).await;
});
Self {
current,
endpoint,
timeout,
retry_interval,
freshness_duration,
}
}
pub async fn current(&self) -> T {
self.current.read().await.clone()
}
pub async fn is_fresh(&self) -> bool {
self.current
.read()
.await
.is_fresh_within(self.freshness_duration)
}
/// The core streaming task that can be specialized per monitor type
async fn streaming_task(
current: Arc<RwLock<T>>,
endpoint: String,
timeout: Duration,
retry_interval: Duration,
) {
loop {
let ipc_path_buf = match ipc_path() {
Ok(path) => path,
Err(e) => {
logging!(error, Type::Ipc, true, "Failed to get IPC path: {}", e);
tokio::time::sleep(retry_interval).await;
continue;
}
};
let ipc_path = ipc_path_buf.to_str().unwrap_or_default();
let client = match IpcStreamClient::new(ipc_path) {
Ok(client) => client,
Err(e) => {
logging!(error, Type::Ipc, true, "Failed to create IPC client: {}", e);
tokio::time::sleep(retry_interval).await;
continue;
}
};
let _ = client
.get(&endpoint)
.timeout(timeout)
.process_lines(|line| T::parse_and_update(line, Arc::clone(&current)))
.await;
tokio::time::sleep(retry_interval).await;
}
}
}

View File

@@ -1,153 +0,0 @@
use serde::{Deserialize, Serialize};
use std::{sync::Arc, time::Instant};
use tokio::{sync::RwLock, time::Duration};
use crate::{
ipc::monitor::{IpcStreamMonitor, MonitorData, StreamingParser},
process::AsyncHandler,
singleton_lazy_with_logging,
utils::format::fmt_bytes,
};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TrafficData {
pub up: u64,
pub down: u64,
}
#[derive(Debug, Clone)]
pub struct CurrentTraffic {
pub up_rate: u64,
pub down_rate: u64,
pub total_up: u64,
pub total_down: u64,
pub last_updated: Instant,
}
impl Default for CurrentTraffic {
fn default() -> Self {
Self {
up_rate: 0,
down_rate: 0,
total_up: 0,
total_down: 0,
last_updated: Instant::now(),
}
}
}
impl MonitorData for CurrentTraffic {
fn mark_fresh(&mut self) {
self.last_updated = Instant::now();
}
fn is_fresh_within(&self, duration: Duration) -> bool {
self.last_updated.elapsed() < duration
}
}
// Traffic monitoring state for calculating rates
#[derive(Debug, Clone, Default)]
pub struct TrafficMonitorState {
pub current: CurrentTraffic,
pub last_traffic: Option<TrafficData>,
}
impl MonitorData for TrafficMonitorState {
fn mark_fresh(&mut self) {
self.current.mark_fresh();
}
fn is_fresh_within(&self, duration: Duration) -> bool {
self.current.is_fresh_within(duration)
}
}
impl StreamingParser for TrafficMonitorState {
fn parse_and_update(
line: &str,
current: Arc<RwLock<Self>>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let Ok(traffic) = serde_json::from_str::<TrafficData>(line.trim()) {
AsyncHandler::spawn(move || async move {
let mut state_guard = current.write().await;
let (up_rate, down_rate) = state_guard
.last_traffic
.as_ref()
.map(|l| {
(
traffic.up.saturating_sub(l.up),
traffic.down.saturating_sub(l.down),
)
})
.unwrap_or((0, 0));
state_guard.current = CurrentTraffic {
up_rate,
down_rate,
total_up: traffic.up,
total_down: traffic.down,
last_updated: Instant::now(),
};
state_guard.last_traffic = Some(traffic);
});
}
Ok(())
}
}
// Minimal traffic monitor using the new architecture
pub struct TrafficMonitor {
monitor: IpcStreamMonitor<TrafficMonitorState>,
}
impl Default for TrafficMonitor {
fn default() -> Self {
TrafficMonitor {
monitor: IpcStreamMonitor::new(
"/traffic".to_string(),
Duration::from_secs(10),
Duration::from_secs(1),
Duration::from_secs(5),
),
}
}
}
// Use simplified singleton_lazy_with_logging macro
singleton_lazy_with_logging!(
TrafficMonitor,
INSTANCE,
"TrafficMonitor",
TrafficMonitor::default
);
impl TrafficMonitor {
pub async fn current(&self) -> CurrentTraffic {
self.monitor.current().await.current
}
pub async fn is_fresh(&self) -> bool {
self.monitor.is_fresh().await
}
}
pub async fn get_current_traffic() -> CurrentTraffic {
TrafficMonitor::global().current().await
}
pub async fn get_formatted_traffic() -> (String, String, String, String, bool) {
let monitor = TrafficMonitor::global();
let traffic = monitor.current().await;
let is_fresh = monitor.is_fresh().await;
(
fmt_bytes(traffic.up_rate),
fmt_bytes(traffic.down_rate),
fmt_bytes(traffic.total_up),
fmt_bytes(traffic.total_down),
is_fresh,
)
}

View File

@@ -1,34 +1,32 @@
#![allow(non_snake_case)] #![allow(non_snake_case)]
#![recursion_limit = "512"] #![recursion_limit = "512"]
mod cache;
mod cmd; mod cmd;
pub mod config; pub mod config;
mod core; mod core;
mod enhance; mod enhance;
mod feat; mod feat;
mod ipc;
mod module; mod module;
mod process; mod process;
mod utils; mod utils;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use crate::utils::window_manager::WindowManager; use crate::utils::window_manager::WindowManager;
use crate::{ use crate::{
core::handle, core::{handle, hotkey},
core::hotkey,
process::AsyncHandler, process::AsyncHandler,
utils::{resolve, server}, utils::{resolve, server},
}; };
use config::Config; use config::Config;
use tauri::AppHandle; use once_cell::sync::OnceCell;
#[cfg(target_os = "macos")] use tauri::{AppHandle, Manager};
use tauri::Manager;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_deep_link::DeepLinkExt;
use tokio::time::{Duration, timeout}; use tokio::time::{Duration, timeout};
use utils::logging::Type; use utils::logging::Type;
pub static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new();
/// Application initialization helper functions /// Application initialization helper functions
mod app_init { mod app_init {
use super::*; use super::*;
@@ -41,7 +39,7 @@ mod app_init {
Ok(result) => { Ok(result) => {
if result.is_err() { if result.is_err() {
logging!(info, Type::Setup, true, "检测到已有应用实例运行"); logging!(info, Type::Setup, true, "检测到已有应用实例运行");
if let Some(app_handle) = handle::Handle::global().app_handle() { if let Some(app_handle) = APP_HANDLE.get() {
app_handle.exit(0); app_handle.exit(0);
} else { } else {
std::process::exit(0); std::process::exit(0);
@@ -75,7 +73,13 @@ mod app_init {
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_http::init()); .plugin(tauri_plugin_http::init())
.plugin(
tauri_plugin_mihomo::Builder::new()
.protocol(tauri_plugin_mihomo::models::Protocol::LocalSocket)
.socket_path(crate::config::IClashTemp::guard_external_controller_ipc())
.build(),
);
// Devtools plugin only in debug mode with feature tauri-dev // Devtools plugin only in debug mode with feature tauri-dev
// to avoid duplicated registering of logger since the devtools plugin also registers a logger // to avoid duplicated registering of logger since the devtools plugin also registers a logger
@@ -184,46 +188,13 @@ mod app_init {
cmd::update_proxy_chain_config_in_runtime, cmd::update_proxy_chain_config_in_runtime,
cmd::invoke_uwp_tool, cmd::invoke_uwp_tool,
cmd::copy_clash_env, cmd::copy_clash_env,
cmd::get_proxies,
cmd::force_refresh_proxies,
cmd::get_providers_proxies,
cmd::sync_tray_proxy_selection, cmd::sync_tray_proxy_selection,
cmd::update_proxy_and_sync,
cmd::save_dns_config, cmd::save_dns_config,
cmd::apply_dns_config, cmd::apply_dns_config,
cmd::check_dns_config_exists, cmd::check_dns_config_exists,
cmd::get_dns_config_content, cmd::get_dns_config_content,
cmd::validate_dns_config, cmd::validate_dns_config,
cmd::get_clash_version,
cmd::get_clash_config,
cmd::force_refresh_clash_config,
cmd::update_geo_data,
cmd::upgrade_clash_core,
cmd::get_clash_rules,
cmd::update_proxy_choice,
cmd::get_proxy_providers,
cmd::get_rule_providers,
cmd::proxy_provider_health_check,
cmd::update_proxy_provider,
cmd::update_rule_provider,
cmd::get_clash_connections,
cmd::delete_clash_connection,
cmd::close_all_clash_connections,
cmd::get_group_proxy_delays,
cmd::is_clash_debug_enabled,
cmd::clash_gc,
// Logging and monitoring
cmd::get_clash_logs, cmd::get_clash_logs,
cmd::start_logs_monitoring,
cmd::stop_logs_monitoring,
cmd::clear_logs,
cmd::get_traffic_data,
cmd::get_memory_data,
cmd::get_formatted_traffic_data,
cmd::get_formatted_memory_data,
cmd::get_system_monitor_overview,
cmd::start_traffic_service,
cmd::stop_traffic_service,
// Verge configuration // Verge configuration
cmd::get_verge_config, cmd::get_verge_config,
cmd::patch_verge_config, cmd::patch_verge_config,
@@ -251,8 +222,6 @@ mod app_init {
// Script validation // Script validation
cmd::script_validate_notice, cmd::script_validate_notice,
cmd::validate_script_file, cmd::validate_script_file,
// Clash API
cmd::clash_api_get_proxy_delay,
// Backup and WebDAV // Backup and WebDAV
cmd::create_webdav_backup, cmd::create_webdav_backup,
cmd::save_webdav_config, cmd::save_webdav_config,
@@ -321,6 +290,11 @@ pub fn run() {
.setup(|app| { .setup(|app| {
logging!(info, Type::Setup, true, "开始应用初始化..."); logging!(info, Type::Setup, true, "开始应用初始化...");
#[allow(clippy::expect_used)]
APP_HANDLE
.set(app.app_handle().clone())
.expect("failed to set global app handle");
// Setup autostart plugin // Setup autostart plugin
if let Err(e) = app_init::setup_autostart(app) { if let Err(e) = app_init::setup_autostart(app) {
logging!(error, Type::Setup, true, "Failed to setup autostart: {}", e); logging!(error, Type::Setup, true, "Failed to setup autostart: {}", e);
@@ -348,11 +322,9 @@ pub fn run() {
); );
} }
let app_handle = app.handle().clone();
logging!(info, Type::Setup, true, "执行主要设置操作..."); logging!(info, Type::Setup, true, "执行主要设置操作...");
resolve::resolve_setup_handle(app_handle); resolve::resolve_setup_handle();
resolve::resolve_setup_async(); resolve::resolve_setup_async();
resolve::resolve_setup_sync(); resolve::resolve_setup_sync();
@@ -368,7 +340,7 @@ pub fn run() {
use super::*; use super::*;
/// Handle application ready/resumed events /// Handle application ready/resumed events
pub fn handle_ready_resumed(app_handle: &AppHandle) { pub fn handle_ready_resumed(_app_handle: &AppHandle) {
// 双重检查:确保不在退出状态 // 双重检查:确保不在退出状态
if handle::Handle::global().is_exiting() { if handle::Handle::global().is_exiting() {
logging!( logging!(
@@ -381,11 +353,11 @@ pub fn run() {
} }
logging!(info, Type::System, true, "应用就绪或恢复"); logging!(info, Type::System, true, "应用就绪或恢复");
handle::Handle::global().init(app_handle.clone()); handle::Handle::global().init();
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
if let Some(window) = app_handle.get_webview_window("main") { if let Some(window) = _app_handle.get_webview_window("main") {
logging!(info, Type::Window, true, "设置macOS窗口标题"); logging!(info, Type::Window, true, "设置macOS窗口标题");
let _ = window.set_title("Clash Verge"); let _ = window.set_title("Clash Verge");
} }
@@ -394,7 +366,7 @@ pub fn run() {
/// Handle application reopen events (macOS) /// Handle application reopen events (macOS)
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub async fn handle_reopen(app_handle: &AppHandle, has_visible_windows: bool) { pub async fn handle_reopen(has_visible_windows: bool) {
logging!( logging!(
info, info,
Type::System, Type::System,
@@ -403,7 +375,7 @@ pub fn run() {
has_visible_windows has_visible_windows
); );
handle::Handle::global().init(app_handle.clone()); handle::Handle::global().init();
if !has_visible_windows { if !has_visible_windows {
// 当没有可见窗口时,设置为 regular 模式并显示主窗口 // 当没有可见窗口时,设置为 regular 模式并显示主窗口
@@ -436,7 +408,7 @@ pub fn run() {
log::info!(target: "app", "closing window..."); log::info!(target: "app", "closing window...");
if let tauri::WindowEvent::CloseRequested { api, .. } = api { if let tauri::WindowEvent::CloseRequested { api, .. } = api {
api.prevent_close(); api.prevent_close();
if let Some(window) = core::handle::Handle::global().get_window() { if let Some(window) = core::handle::Handle::get_window() {
let _ = window.hide(); let _ = window.hide();
} else { } else {
logging!(warn, Type::Window, true, "尝试隐藏窗口但窗口不存在"); logging!(warn, Type::Window, true, "尝试隐藏窗口但窗口不存在");
@@ -583,12 +555,17 @@ pub fn run() {
logging!(debug, Type::System, true, "忽略 Reopen 事件,应用正在退出"); logging!(debug, Type::System, true, "忽略 Reopen 事件,应用正在退出");
return; return;
} }
let app_handle = app_handle.clone();
AsyncHandler::spawn(move || async move { AsyncHandler::spawn(move || async move {
event_handlers::handle_reopen(&app_handle, has_visible_windows).await; event_handlers::handle_reopen(has_visible_windows).await;
}); });
} }
tauri::RunEvent::ExitRequested { api, code, .. } => { tauri::RunEvent::ExitRequested { api, code, .. } => {
tauri::async_runtime::block_on(async {
let _ = handle::Handle::mihomo()
.await
.clear_all_ws_connections()
.await;
});
// 如果已经在退出流程中,不要阻止退出 // 如果已经在退出流程中,不要阻止退出
if core::handle::Handle::global().is_exiting() { if core::handle::Handle::global().is_exiting() {
logging!( logging!(

View File

@@ -1,5 +1,4 @@
use crate::{ use crate::{
cache::CacheProxy,
config::Config, config::Config,
core::{handle, timer::Timer, tray::Tray}, core::{handle, timer::Timer, tray::Tray},
log_err, logging, log_err, logging,
@@ -176,7 +175,6 @@ pub async fn entry_lightweight_mode() -> bool {
// 回到 In // 回到 In
set_state(LightweightState::In); set_state(LightweightState::In);
CacheProxy::global().clean_default_keys();
true true
} }
@@ -219,7 +217,7 @@ pub async fn add_light_weight_timer() {
} }
fn setup_window_close_listener() { fn setup_window_close_listener() {
if let Some(window) = handle::Handle::global().get_window() { if let Some(window) = handle::Handle::get_window() {
let handler = window.listen("tauri://close-requested", move |_event| { let handler = window.listen("tauri://close-requested", move |_event| {
std::mem::drop(AsyncHandler::spawn(|| async { std::mem::drop(AsyncHandler::spawn(|| async {
if let Err(e) = setup_light_weight_timer().await { if let Err(e) = setup_light_weight_timer().await {
@@ -239,7 +237,7 @@ fn setup_window_close_listener() {
} }
fn cancel_window_close_listener() { fn cancel_window_close_listener() {
if let Some(window) = handle::Handle::global().get_window() { if let Some(window) = handle::Handle::get_window() {
let handler = WINDOW_CLOSE_HANDLER.swap(0, Ordering::AcqRel); let handler = WINDOW_CLOSE_HANDLER.swap(0, Ordering::AcqRel);
if handler != 0 { if handler != 0 {
window.unlisten(handler); window.unlisten(handler);
@@ -249,7 +247,7 @@ fn cancel_window_close_listener() {
} }
fn setup_webview_focus_listener() { fn setup_webview_focus_listener() {
if let Some(window) = handle::Handle::global().get_window() { if let Some(window) = handle::Handle::get_window() {
let handler = window.listen("tauri://focus", move |_event| { let handler = window.listen("tauri://focus", move |_event| {
log_err!(cancel_light_weight_timer()); log_err!(cancel_light_weight_timer());
logging!( logging!(
@@ -264,7 +262,7 @@ fn setup_webview_focus_listener() {
} }
fn cancel_webview_focus_listener() { fn cancel_webview_focus_listener() {
if let Some(window) = handle::Handle::global().get_window() { if let Some(window) = handle::Handle::get_window() {
let handler = WEBVIEW_FOCUS_HANDLER.swap(0, Ordering::AcqRel); let handler = WEBVIEW_FOCUS_HANDLER.swap(0, Ordering::AcqRel);
if handler != 0 { if handler != 0 {
window.unlisten(handler); window.unlisten(handler);

View File

@@ -38,17 +38,7 @@ impl PlatformSpecification {
let system_kernel_version = System::kernel_version().unwrap_or("Null".into()); let system_kernel_version = System::kernel_version().unwrap_or("Null".into());
let system_arch = System::cpu_arch(); let system_arch = System::cpu_arch();
let Some(handler) = handle::Handle::global().app_handle() else { let handler = handle::Handle::app_handle();
return Self {
system_name,
system_version,
system_kernel_version,
system_arch,
verge_version: "unknown".into(),
running_mode: "NotRunning".to_string(),
is_admin: false,
};
};
let verge_version = handler.package_info().version.to_string(); let verge_version = handler.package_info().version.to_string();
// 使用默认值避免在同步上下文中执行异步操作 // 使用默认值避免在同步上下文中执行异步操作

View File

@@ -51,53 +51,7 @@ pub fn app_home_dir() -> Result<PathBuf> {
} }
// 避免在Handle未初始化时崩溃 // 避免在Handle未初始化时崩溃
let app_handle = match handle::Handle::global().app_handle() { let app_handle = handle::Handle::app_handle();
Some(handle) => handle,
None => {
log::warn!(target: "app", "app_handle not initialized, using default path");
// 使用可执行文件目录作为备用
let exe_path = tauri::utils::platform::current_exe()?;
let exe_dir = exe_path
.parent()
.ok_or(anyhow::anyhow!("failed to get executable directory"))?;
// 使用系统临时目录 + 应用ID
#[cfg(target_os = "windows")]
{
if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
let path = PathBuf::from(local_app_data).join(APP_ID);
return Ok(path);
}
}
#[cfg(target_os = "macos")]
{
if let Some(home) = std::env::var_os("HOME") {
let path = PathBuf::from(home)
.join("Library")
.join("Application Support")
.join(APP_ID);
return Ok(path);
}
}
#[cfg(target_os = "linux")]
{
if let Some(home) = std::env::var_os("HOME") {
let path = PathBuf::from(home)
.join(".local")
.join("share")
.join(APP_ID);
return Ok(path);
}
}
// 如果无法获取系统目录,则回退到可执行文件目录
let fallback_dir = PathBuf::from(exe_dir).join(".config").join(APP_ID);
log::warn!(target: "app", "Using fallback data directory: {fallback_dir:?}");
return Ok(fallback_dir);
}
};
match app_handle.path().data_dir() { match app_handle.path().data_dir() {
Ok(dir) => Ok(dir.join(APP_ID)), Ok(dir) => Ok(dir.join(APP_ID)),
@@ -111,18 +65,7 @@ pub fn app_home_dir() -> Result<PathBuf> {
/// get the resources dir /// get the resources dir
pub fn app_resources_dir() -> Result<PathBuf> { pub fn app_resources_dir() -> Result<PathBuf> {
// 避免在Handle未初始化时崩溃 // 避免在Handle未初始化时崩溃
let app_handle = match handle::Handle::global().app_handle() { let app_handle = handle::Handle::app_handle();
Some(handle) => handle,
None => {
log::warn!(target: "app", "app_handle not initialized in app_resources_dir, using fallback");
// 使用可执行文件目录作为备用
let exe_dir = tauri::utils::platform::current_exe()?
.parent()
.ok_or(anyhow::anyhow!("failed to get executable directory"))?
.to_path_buf();
return Ok(exe_dir.join("resources"));
}
};
match app_handle.path().resource_dir() { match app_handle.path().resource_dir() {
Ok(dir) => Ok(dir.join("resources")), Ok(dir) => Ok(dir.join("resources")),

View File

@@ -1,4 +1,5 @@
/// Format bytes into human readable string (B, KB, MB, GB) /// Format bytes into human readable string (B, KB, MB, GB)
#[allow(unused)]
pub fn fmt_bytes(bytes: u64) -> String { pub fn fmt_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB"]; const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
let (mut val, mut unit) = (bytes as f64, 0); let (mut val, mut unit) = (bytes as f64, 0);

View File

@@ -494,15 +494,7 @@ pub fn init_scheme() -> Result<()> {
} }
pub async fn startup_script() -> Result<()> { pub async fn startup_script() -> Result<()> {
let app_handle = match handle::Handle::global().app_handle() { let app_handle = handle::Handle::app_handle();
Some(handle) => handle,
None => {
return Err(anyhow::anyhow!(
"app_handle not available for startup script execution"
));
}
};
let script_path = { let script_path = {
let verge = Config::verge().await; let verge = Config::verge().await;
let verge = verge.latest_ref(); let verge = verge.latest_ref();

View File

@@ -28,7 +28,7 @@ pub enum Type {
Lightweight, Lightweight,
Network, Network,
ProxyMode, ProxyMode,
Ipc, // Ipc,
// Cache, // Cache,
ClashVergeRev, ClashVergeRev,
} }
@@ -51,7 +51,7 @@ impl fmt::Display for Type {
Type::Lightweight => write!(f, "[Lightweight]"), Type::Lightweight => write!(f, "[Lightweight]"),
Type::Network => write!(f, "[Network]"), Type::Network => write!(f, "[Network]"),
Type::ProxyMode => write!(f, "[ProxMode]"), Type::ProxyMode => write!(f, "[ProxMode]"),
Type::Ipc => write!(f, "[IPC]"), // Type::Ipc => write!(f, "[IPC]"),
// Type::Cache => write!(f, "[Cache]"), // Type::Cache => write!(f, "[Cache]"),
Type::ClashVergeRev => write!(f, "[ClashVergeRev]"), Type::ClashVergeRev => write!(f, "[ClashVergeRev]"),
} }

View File

@@ -2,13 +2,7 @@
pub async fn set_public_dns(dns_server: String) { pub async fn set_public_dns(dns_server: String) {
use crate::{core::handle, utils::dirs}; use crate::{core::handle, utils::dirs};
use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::ShellExt;
let app_handle = match handle::Handle::global().app_handle() { let app_handle = handle::Handle::app_handle();
Some(handle) => handle,
None => {
log::error!(target: "app", "app_handle not available for DNS configuration");
return;
}
};
log::info!(target: "app", "try to set system dns"); log::info!(target: "app", "try to set system dns");
let resource_dir = match dirs::app_resources_dir() { let resource_dir = match dirs::app_resources_dir() {
@@ -50,13 +44,7 @@ pub async fn set_public_dns(dns_server: String) {
pub async fn restore_public_dns() { pub async fn restore_public_dns() {
use crate::{core::handle, utils::dirs}; use crate::{core::handle, utils::dirs};
use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::ShellExt;
let app_handle = match handle::Handle::global().app_handle() { let app_handle = handle::Handle::app_handle();
Some(handle) => handle,
None => {
log::error!(target: "app", "app_handle not available for DNS restoration");
return;
}
};
log::info!(target: "app", "try to unset system dns"); log::info!(target: "app", "try to unset system dns");
let resource_dir = match dirs::app_resources_dir() { let resource_dir = match dirs::app_resources_dir() {
Ok(dir) => dir, Ok(dir) => dir,

View File

@@ -1,5 +1,4 @@
use anyhow::Result; use anyhow::Result;
use tauri::AppHandle;
use crate::{ use crate::{
config::Config, config::Config,
@@ -18,8 +17,8 @@ pub mod ui;
pub mod window; pub mod window;
pub mod window_script; pub mod window_script;
pub fn resolve_setup_handle(app_handle: AppHandle) { pub fn resolve_setup_handle() {
init_handle(app_handle); init_handle();
} }
pub fn resolve_setup_sync() { pub fn resolve_setup_sync() {
@@ -121,9 +120,9 @@ pub async fn resolve_reset_async() -> Result<(), anyhow::Error> {
Ok(()) Ok(())
} }
pub fn init_handle(app_handle: AppHandle) { pub fn init_handle() {
logging!(info, Type::Setup, true, "Initializing app handle..."); logging!(info, Type::Setup, true, "Initializing app handle...");
handle::Handle::global().init(app_handle); handle::Handle::global().init();
} }
pub(super) fn init_scheme() { pub(super) fn init_scheme() {

View File

@@ -2,7 +2,7 @@ use tauri::WebviewWindow;
use crate::{ use crate::{
core::handle, core::handle,
logging, logging_error, logging_error,
utils::{ utils::{
logging::Type, logging::Type,
resolve::window_script::{INITIAL_LOADING_OVERLAY, WINDOW_INITIAL_SCRIPT}, resolve::window_script::{INITIAL_LOADING_OVERLAY, WINDOW_INITIAL_SCRIPT},
@@ -18,18 +18,10 @@ const MINIMAL_HEIGHT: f64 = 520.0;
/// 构建新的 WebView 窗口 /// 构建新的 WebView 窗口
pub fn build_new_window() -> Result<WebviewWindow, String> { pub fn build_new_window() -> Result<WebviewWindow, String> {
let app_handle = handle::Handle::global().app_handle().ok_or_else(|| { let app_handle = handle::Handle::app_handle();
logging!(
error,
Type::Window,
true,
"无法获取app_handle窗口创建失败"
);
"无法获取app_handle".to_string()
})?;
match tauri::WebviewWindowBuilder::new( match tauri::WebviewWindowBuilder::new(
&app_handle, app_handle,
"main", /* the unique window label */ "main", /* the unique window label */
tauri::WebviewUrl::App("index.html".into()), tauri::WebviewUrl::App("index.html".into()),
) )

View File

@@ -117,9 +117,8 @@ impl WindowManager {
/// 获取主窗口实例 /// 获取主窗口实例
pub fn get_main_window() -> Option<WebviewWindow<Wry>> { pub fn get_main_window() -> Option<WebviewWindow<Wry>> {
handle::Handle::global() let app_handle = handle::Handle::app_handle();
.app_handle() app_handle.get_webview_window("main")
.and_then(|app| app.get_webview_window("main"))
} }
/// 智能显示主窗口 /// 智能显示主窗口

View File

@@ -49,8 +49,11 @@
"security": { "security": {
"capabilities": ["desktop-capability", "migrated"], "capabilities": ["desktop-capability", "migrated"],
"assetProtocol": { "assetProtocol": {
"scope": ["$APPDATA/**", "$RESOURCE/../**", "**"], "enable": true,
"enable": true "scope": {
"allow": ["**"],
"requireLiteralLeadingDot": false
}
}, },
"csp": null "csp": null
} }

View File

@@ -4,8 +4,8 @@ import dayjs from "dayjs";
import { t } from "i18next"; import { t } from "i18next";
import { useImperativeHandle, useState, type Ref } from "react"; import { useImperativeHandle, useState, type Ref } from "react";
import { deleteConnection } from "@/services/cmds";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
import { closeConnections } from "tauri-plugin-mihomo-api";
export interface ConnectionDetailRef { export interface ConnectionDetailRef {
open: (detail: IConnectionsItem) => void; open: (detail: IConnectionsItem) => void;
@@ -97,7 +97,7 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
{ label: t("Type"), value: `${metadata.type}(${metadata.network})` }, { label: t("Type"), value: `${metadata.type}(${metadata.network})` },
]; ];
const onDelete = useLockFn(async () => deleteConnection(data.id)); const onDelete = useLockFn(async () => closeConnections(data.id));
return ( return (
<Box sx={{ userSelect: "text", color: theme.palette.text.secondary }}> <Box sx={{ userSelect: "text", color: theme.palette.text.secondary }}>

View File

@@ -10,8 +10,8 @@ import {
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { deleteConnection } from "@/services/cmds";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
import { closeConnections } from "tauri-plugin-mihomo-api";
const Tag = styled("span")(({ theme }) => ({ const Tag = styled("span")(({ theme }) => ({
fontSize: "10px", fontSize: "10px",
@@ -34,7 +34,7 @@ export const ConnectionItem = (props: Props) => {
const { id, metadata, chains, start, curUpload, curDownload } = value; const { id, metadata, chains, start, curUpload, curDownload } = value;
const onDelete = useLockFn(async () => deleteConnection(id)); const onDelete = useLockFn(async () => closeConnections(id));
const showTraffic = curUpload! >= 100 || curDownload! >= 100; const showTraffic = curUpload! >= 100 || curDownload! >= 100;
return ( return (

View File

@@ -53,7 +53,7 @@ export const ClashInfoCard = () => {
{t("Mixed Port")} {t("Mixed Port")}
</Typography> </Typography>
<Typography variant="body2" fontWeight="medium"> <Typography variant="body2" fontWeight="medium">
{clashConfig["mixed-port"] || "-"} {clashConfig.mixedPort || "-"}
</Typography> </Typography>
</Stack> </Stack>
<Divider /> <Divider />

View File

@@ -7,10 +7,11 @@ import { Box, Paper, Stack, Typography } from "@mui/material";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { closeAllConnections } from "tauri-plugin-mihomo-api";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-context"; import { useAppData } from "@/providers/app-data-context";
import { closeAllConnections, patchClashMode } from "@/services/cmds"; import { patchClashMode } from "@/services/cmds";
export const ClashModeCard = () => { export const ClashModeCard = () => {
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -36,8 +36,8 @@ import { EnhancedCard } from "@/components/home/enhanced-card";
import { useProxySelection } from "@/hooks/use-proxy-selection"; import { useProxySelection } from "@/hooks/use-proxy-selection";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-context"; import { useAppData } from "@/providers/app-data-context";
import { getGroupProxyDelays, providerHealthCheck } from "@/services/cmds";
import delayManager from "@/services/delay"; import delayManager from "@/services/delay";
import { delayGroup, healthcheckProxyProvider } from "tauri-plugin-mihomo-api";
// 本地存储的键名 // 本地存储的键名
const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group"; const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group";
@@ -466,7 +466,7 @@ export const CurrentProxyCard = () => {
if (providers.size > 0) { if (providers.size > 0) {
console.log(`[CurrentProxyCard] 开始测试提供者节点`); console.log(`[CurrentProxyCard] 开始测试提供者节点`);
await Promise.allSettled( await Promise.allSettled(
[...providers].map((p) => providerHealthCheck(p)), [...providers].map((p) => healthcheckProxyProvider(p)),
); );
} }
@@ -478,7 +478,7 @@ export const CurrentProxyCard = () => {
try { try {
await Promise.race([ await Promise.race([
delayManager.checkListDelay(proxyNames, groupName, timeout), delayManager.checkListDelay(proxyNames, groupName, timeout),
getGroupProxyDelays(groupName, url, timeout), delayGroup(groupName, url, timeout),
]); ]);
console.log(`[CurrentProxyCard] 延迟测试完成,组: ${groupName}`); console.log(`[CurrentProxyCard] 延迟测试完成,组: ${groupName}`);
} catch (error) { } catch (error) {

View File

@@ -92,7 +92,7 @@ export const EnhancedCanvasTrafficGraph = memo(
const { t } = useTranslation(); const { t } = useTranslation();
// 使用增强版全局流量数据管理 // 使用增强版全局流量数据管理
const { dataPoints, getDataForTimeRange, isDataFresh, samplerStats } = const { dataPoints, getDataForTimeRange, samplerStats } =
useTrafficGraphDataEnhanced(); useTrafficGraphDataEnhanced();
// 基础状态 // 基础状态
@@ -865,6 +865,7 @@ export const EnhancedCanvasTrafficGraph = memo(
}} }}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onClick={toggleStyle}
/> />
{/* 控制层覆盖 */} {/* 控制层覆盖 */}
@@ -962,8 +963,8 @@ export const EnhancedCanvasTrafficGraph = memo(
lineHeight: 1.2, lineHeight: 1.2,
}} }}
> >
Points: {displayData.length} | Fresh: {isDataFresh ? "✓" : "✗"} | Points: {displayData.length} | Compressed:{" "}
Compressed: {samplerStats.compressedBufferSize} {samplerStats.compressedBufferSize}
</Box> </Box>
{/* 悬浮提示框 */} {/* 悬浮提示框 */}
@@ -988,6 +989,7 @@ export const EnhancedCanvasTrafficGraph = memo(
boxShadow: "0 4px 12px rgba(0,0,0,0.15)", boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
backdropFilter: "none", backdropFilter: "none",
opacity: 1, opacity: 1,
whiteSpace: "nowrap",
}} }}
> >
<Box color="text.secondary" mb={0.2}> <Box color="text.secondary" mb={0.2}>

View File

@@ -7,7 +7,6 @@ import {
MemoryRounded, MemoryRounded,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { import {
Box,
Grid, Grid,
PaletteColor, PaletteColor,
Paper, Paper,
@@ -15,16 +14,16 @@ import {
alpha, alpha,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { ReactNode, memo, useCallback, useMemo, useRef } from "react"; import { useRef, memo, useMemo } from "react";
import { ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { TrafficErrorBoundary } from "@/components/common/traffic-error-boundary"; import { TrafficErrorBoundary } from "@/components/common/traffic-error-boundary";
import { useTrafficDataEnhanced } from "@/hooks/use-traffic-monitor"; import { useConnectionData } from "@/hooks/use-connection-data";
import { useMemoryData } from "@/hooks/use-memory-data";
import { useTrafficData } from "@/hooks/use-traffic-data";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { useVisibility } from "@/hooks/use-visibility"; import { useVisibility } from "@/hooks/use-visibility";
import { useAppData } from "@/providers/app-data-context";
import { gc, isDebugEnabled } from "@/services/cmds";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
import { import {
@@ -148,51 +147,33 @@ export const EnhancedTrafficStats = () => {
const trafficRef = useRef<EnhancedCanvasTrafficGraphRef>(null); const trafficRef = useRef<EnhancedCanvasTrafficGraphRef>(null);
const pageVisible = useVisibility(); const pageVisible = useVisibility();
// 使用AppDataProvider const {
const { connections } = useAppData(); response: { data: traffic },
} = useTrafficData();
// 使用增强版的统一流量数据Hook const {
const { traffic, memory, isLoading, isDataFresh, hasValidData } = response: { data: memory },
useTrafficDataEnhanced(); } = useMemoryData();
const {
response: { data: connections },
} = useConnectionData();
// 是否显示流量图表 // 是否显示流量图表
const trafficGraph = verge?.traffic_graph ?? true; const trafficGraph = verge?.traffic_graph ?? true;
// 检查是否支持调试
// TODO: merge this hook with layout-traffic.tsx
const { data: isDebug } = useSWR(
`clash-verge-rev-internal://isDebugEnabled`,
() => isDebugEnabled(),
{
// default value before is fetched
fallbackData: false,
},
);
// Canvas组件现在直接从全局Hook获取数据无需手动添加数据点 // Canvas组件现在直接从全局Hook获取数据无需手动添加数据点
// 执行垃圾回收
const handleGarbageCollection = useCallback(async () => {
if (isDebug) {
try {
await gc();
console.log("[Debug] 垃圾回收已执行");
} catch (err) {
console.error("[Debug] 垃圾回收失败:", err);
}
}
}, [isDebug]);
// 使用useMemo计算解析后的流量数据 // 使用useMemo计算解析后的流量数据
const parsedData = useMemo(() => { const parsedData = useMemo(() => {
const [up, upUnit] = parseTraffic(traffic?.raw?.up_rate || 0); const [up, upUnit] = parseTraffic(traffic?.up || 0);
const [down, downUnit] = parseTraffic(traffic?.raw?.down_rate || 0); const [down, downUnit] = parseTraffic(traffic?.down || 0);
const [inuse, inuseUnit] = parseTraffic(memory?.raw?.inuse || 0); const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0);
const [uploadTotal, uploadTotalUnit] = parseTraffic( const [uploadTotal, uploadTotalUnit] = parseTraffic(
connections.uploadTotal, connections?.uploadTotal,
); );
const [downloadTotal, downloadTotalUnit] = parseTraffic( const [downloadTotal, downloadTotalUnit] = parseTraffic(
connections.downloadTotal, connections?.downloadTotal,
); );
return { return {
@@ -206,7 +187,7 @@ export const EnhancedTrafficStats = () => {
uploadTotalUnit, uploadTotalUnit,
downloadTotal, downloadTotal,
downloadTotalUnit, downloadTotalUnit,
connectionsCount: connections.count, connectionsCount: connections?.connections.length,
}; };
}, [traffic, memory, connections]); }, [traffic, memory, connections]);
@@ -228,33 +209,10 @@ export const EnhancedTrafficStats = () => {
> >
<div style={{ height: "100%", position: "relative" }}> <div style={{ height: "100%", position: "relative" }}>
<EnhancedCanvasTrafficGraph ref={trafficRef} /> <EnhancedCanvasTrafficGraph ref={trafficRef} />
{isDebug && (
<div
style={{
position: "absolute",
top: "2px",
left: "2px",
zIndex: 10,
backgroundColor: "rgba(0,0,0,0.5)",
color: "white",
fontSize: "8px",
padding: "2px 4px",
borderRadius: "4px",
}}
>
DEBUG: {trafficRef.current ? "图表已初始化" : "图表未初始化"}
<br />
: {isDataFresh ? "active" : "inactive"}
<br />
: {traffic?.is_fresh ? "Fresh" : "Stale"}
<br />
{new Date().toISOString().slice(11, 19)}
</div>
)}
</div> </div>
</Paper> </Paper>
); );
}, [trafficGraph, pageVisible, theme.palette.divider, isDebug]); }, [trafficGraph, pageVisible, theme.palette.divider]);
// 使用useMemo计算统计卡片配置 // 使用useMemo计算统计卡片配置
const statCards = useMemo( const statCards = useMemo(
@@ -300,10 +258,10 @@ export const EnhancedTrafficStats = () => {
value: parsedData.inuse, value: parsedData.inuse,
unit: parsedData.inuseUnit, unit: parsedData.inuseUnit,
color: "error" as const, color: "error" as const,
onClick: isDebug ? handleGarbageCollection : undefined, onClick: undefined,
}, },
], ],
[t, parsedData, isDebug, handleGarbageCollection], [t, parsedData],
); );
return ( return (
@@ -320,28 +278,11 @@ export const EnhancedTrafficStats = () => {
</Grid> </Grid>
)} )}
{/* 统计卡片区域 */} {/* 统计卡片区域 */}
{statCards.map((card, index) => ( {statCards.map((card, _index) => (
<Grid key={index} size={4}> <Grid key={card.title} size={4}>
<CompactStatCard {...card} /> <CompactStatCard {...(card as StatCardProps)} />
</Grid> </Grid>
))} ))}
{/* 数据状态指示器(调试用)*/}
{isDebug && (
<Grid size={12}>
<Box
sx={{
p: 1,
bgcolor: "action.hover",
borderRadius: 1,
fontSize: "0.75rem",
}}
>
: {isDataFresh ? "新鲜" : "过期"} | :{" "}
{hasValidData ? "是" : "否"} | : {isLoading ? "是" : "否"}
</Box>
</Grid>
)}
</Grid> </Grid>
</TrafficErrorBoundary> </TrafficErrorBoundary>
); );

View File

@@ -6,34 +6,19 @@ import {
import { Box, Typography } from "@mui/material"; import { Box, Typography } from "@mui/material";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { LightweightTrafficErrorBoundary } from "@/components/common/traffic-error-boundary"; import { LightweightTrafficErrorBoundary } from "@/components/common/traffic-error-boundary";
import { useClashInfo } from "@/hooks/use-clash";
import { useTrafficDataEnhanced } from "@/hooks/use-traffic-monitor";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { useVisibility } from "@/hooks/use-visibility"; import { useVisibility } from "@/hooks/use-visibility";
import { isDebugEnabled, gc, startTrafficService } from "@/services/cmds";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
import { TrafficGraph, type TrafficRef } from "./traffic-graph"; import { TrafficGraph, type TrafficRef } from "./traffic-graph";
import { useTrafficData } from "@/hooks/use-traffic-data";
import { useMemoryData } from "@/hooks/use-memory-data";
// setup the traffic // setup the traffic
export const LayoutTraffic = () => { export const LayoutTraffic = () => {
const { data: isDebug } = useSWR(
"clash-verge-rev-internal://isDebugEnabled",
() => isDebugEnabled(),
{
// default value before is fetched
fallbackData: false,
},
);
if (isDebug) {
console.debug("[Traffic][LayoutTraffic] 组件正在渲染");
}
const { t } = useTranslation(); const { t } = useTranslation();
const { clashInfo } = useClashInfo();
const { verge } = useVerge(); const { verge } = useVerge();
// whether hide traffic graph // whether hide traffic graph
@@ -42,31 +27,19 @@ export const LayoutTraffic = () => {
const trafficRef = useRef<TrafficRef>(null); const trafficRef = useRef<TrafficRef>(null);
const pageVisible = useVisibility(); const pageVisible = useVisibility();
// 使用增强版的统一流量数据Hook const {
const { traffic, memory } = useTrafficDataEnhanced(); response: { data: traffic },
} = useTrafficData();
// 启动流量服务 const {
useEffect(() => { response: { data: memory },
console.log( } = useMemoryData();
"[Traffic][LayoutTraffic] useEffect 触发clashInfo:",
clashInfo,
"pageVisible:",
pageVisible,
);
// 简化条件,只要组件挂载就尝试启动服务
console.log("[Traffic][LayoutTraffic] 开始启动流量服务");
startTrafficService().catch((error) => {
console.error("[Traffic][LayoutTraffic] 启动流量服务失败:", error);
});
}, []); // 移除依赖,只在组件挂载时启动一次
// 监听数据变化,为图表添加数据点 // 监听数据变化,为图表添加数据点
useEffect(() => { useEffect(() => {
if (traffic?.raw && trafficRef.current) { if (trafficRef.current) {
trafficRef.current.appendData({ trafficRef.current.appendData({
up: traffic.raw.up_rate || 0, up: traffic?.up || 0,
down: traffic.raw.down_rate || 0, down: traffic?.down || 0,
}); });
} }
}, [traffic]); }, [traffic]);
@@ -75,9 +48,9 @@ export const LayoutTraffic = () => {
const displayMemory = verge?.enable_memory_usage ?? true; const displayMemory = verge?.enable_memory_usage ?? true;
// 使用parseTraffic统一处理转换保持与首页一致的显示格式 // 使用parseTraffic统一处理转换保持与首页一致的显示格式
const [up, upUnit] = parseTraffic(traffic?.raw?.up_rate || 0); const [up, upUnit] = parseTraffic(traffic?.up || 0);
const [down, downUnit] = parseTraffic(traffic?.raw?.down_rate || 0); const [down, downUnit] = parseTraffic(traffic?.down || 0);
const [inuse, inuseUnit] = parseTraffic(memory?.raw?.inuse || 0); const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0);
const boxStyle: any = { const boxStyle: any = {
display: "flex", display: "flex",
@@ -114,18 +87,16 @@ export const LayoutTraffic = () => {
<Box display="flex" flexDirection="column" gap={0.75}> <Box display="flex" flexDirection="column" gap={0.75}>
<Box <Box
title={`${t("Upload Speed")} ${traffic?.is_fresh ? "" : "(Stale)"}`} title={`${t("Upload Speed")}`}
{...boxStyle} {...boxStyle}
sx={{ sx={{
...boxStyle.sx, ...boxStyle.sx,
opacity: traffic?.is_fresh ? 1 : 0.6, // opacity: traffic?.is_fresh ? 1 : 0.6,
}} }}
> >
<ArrowUpwardRounded <ArrowUpwardRounded
{...iconStyle} {...iconStyle}
color={ color={(traffic?.up || 0) > 0 ? "secondary" : "disabled"}
(traffic?.raw?.up_rate || 0) > 0 ? "secondary" : "disabled"
}
/> />
<Typography {...valStyle} color="secondary"> <Typography {...valStyle} color="secondary">
{up} {up}
@@ -134,18 +105,16 @@ export const LayoutTraffic = () => {
</Box> </Box>
<Box <Box
title={`${t("Download Speed")} ${traffic?.is_fresh ? "" : "(Stale)"}`} title={`${t("Download Speed")}`}
{...boxStyle} {...boxStyle}
sx={{ sx={{
...boxStyle.sx, ...boxStyle.sx,
opacity: traffic?.is_fresh ? 1 : 0.6, // opacity: traffic?.is_fresh ? 1 : 0.6,
}} }}
> >
<ArrowDownwardRounded <ArrowDownwardRounded
{...iconStyle} {...iconStyle}
color={ color={(traffic?.down || 0) > 0 ? "primary" : "disabled"}
(traffic?.raw?.down_rate || 0) > 0 ? "primary" : "disabled"
}
/> />
<Typography {...valStyle} color="primary"> <Typography {...valStyle} color="primary">
{down} {down}
@@ -155,15 +124,15 @@ export const LayoutTraffic = () => {
{displayMemory && ( {displayMemory && (
<Box <Box
title={`${t(isDebug ? "Memory Cleanup" : "Memory Usage")} ${memory?.is_fresh ? "" : "(Stale)"} ${"usage_percent" in (memory?.formatted || {}) && memory.formatted.usage_percent ? `(${memory.formatted.usage_percent.toFixed(1)}%)` : ""}`} title={`${t("Memory Usage")} `}
{...boxStyle} {...boxStyle}
sx={{ sx={{
cursor: isDebug ? "pointer" : "auto", cursor: "auto",
opacity: memory?.is_fresh ? 1 : 0.6, // opacity: memory?.is_fresh ? 1 : 0.6,
}} }}
color={isDebug ? "success.main" : "disabled"} color={"disabled"}
onClick={async () => { onClick={async () => {
isDebug && (await gc()); // isDebug && (await gc());
}} }}
> >
<MemoryRounded {...iconStyle} /> <MemoryRounded {...iconStyle} />

View File

@@ -20,26 +20,12 @@ import { useLockFn } from "ahooks";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateProxyProvider } from "tauri-plugin-mihomo-api";
import { useAppData } from "@/providers/app-data-context"; import { useAppData } from "@/providers/app-data-context";
import { proxyProviderUpdate } from "@/services/cmds";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
// 定义代理提供者类型
interface ProxyProviderItem {
name?: string;
proxies: any[];
updatedAt: number;
vehicleType: string;
subscriptionInfo?: {
Upload: number;
Download: number;
Total: number;
Expire: number;
};
}
// 样式化组件 - 类型框 // 样式化组件 - 类型框
const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({ const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
display: "inline-block", display: "inline-block",
@@ -74,7 +60,7 @@ export const ProviderButton = () => {
// 设置更新状态 // 设置更新状态
setUpdating((prev) => ({ ...prev, [name]: true })); setUpdating((prev) => ({ ...prev, [name]: true }));
await proxyProviderUpdate(name); await updateProxyProvider(name);
// 刷新数据 // 刷新数据
await refreshProxy(); await refreshProxy();
@@ -115,7 +101,7 @@ export const ProviderButton = () => {
// 改为串行逐个更新所有provider // 改为串行逐个更新所有provider
for (const name of allProviders) { for (const name of allProviders) {
try { try {
await proxyProviderUpdate(name); await updateProxyProvider(name);
// 每个更新完成后更新状态 // 每个更新完成后更新状态
setUpdating((prev) => ({ ...prev, [name]: false })); setUpdating((prev) => ({ ...prev, [name]: false }));
} catch (err) { } catch (err) {
@@ -177,161 +163,164 @@ export const ProviderButton = () => {
<DialogContent> <DialogContent>
<List sx={{ py: 0, minHeight: 250 }}> <List sx={{ py: 0, minHeight: 250 }}>
{Object.entries(proxyProviders || {}).map(([key, item]) => { {Object.entries(proxyProviders || {})
const provider = item as ProxyProviderItem; .sort()
const time = dayjs(provider.updatedAt); .map(([key, item]) => {
const isUpdating = updating[key]; const provider = item;
const time = dayjs(provider.updatedAt);
const isUpdating = updating[key];
// 订阅信息 // 订阅信息
const sub = provider.subscriptionInfo; const sub = provider.subscriptionInfo;
const hasSubInfo = !!sub; const hasSubInfo = !!sub;
const upload = sub?.Upload || 0; const upload = sub?.Upload || 0;
const download = sub?.Download || 0; const download = sub?.Download || 0;
const total = sub?.Total || 0; const total = sub?.Total || 0;
const expire = sub?.Expire || 0; const expire = sub?.Expire || 0;
// 流量使用进度 // 流量使用进度
const progress = const progress =
total > 0 total > 0
? Math.min( ? Math.min(
Math.round(((download + upload) * 100) / total) + 1, Math.round(((download + upload) * 100) / total) + 1,
100, 100,
) )
: 0; : 0;
return ( return (
<ListItem <ListItem
key={key} key={key}
sx={[ sx={[
{ {
p: 0, p: 0,
mb: "8px", mb: "8px",
borderRadius: 2, borderRadius: 2,
overflow: "hidden", overflow: "hidden",
transition: "all 0.2s", transition: "all 0.2s",
}, },
({ palette: { mode, primary } }) => { ({ palette: { mode, primary } }) => {
const bgcolor = mode === "light" ? "#ffffff" : "#24252f"; const bgcolor =
const hoverColor = mode === "light" ? "#ffffff" : "#24252f";
mode === "light" const hoverColor =
? alpha(primary.main, 0.1) mode === "light"
: alpha(primary.main, 0.2); ? alpha(primary.main, 0.1)
: alpha(primary.main, 0.2);
return { return {
backgroundColor: bgcolor, backgroundColor: bgcolor,
"&:hover": { "&:hover": {
backgroundColor: hoverColor, backgroundColor: hoverColor,
}, },
}; };
}, },
]} ]}
>
<ListItemText
sx={{ px: 2, py: 1 }}
primary={
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography
variant="subtitle1"
component="div"
noWrap
title={key}
sx={{ display: "flex", alignItems: "center" }}
>
<span style={{ marginRight: "8px" }}>{key}</span>
<TypeBox component="span">
{provider.proxies.length}
</TypeBox>
<TypeBox component="span">
{provider.vehicleType}
</TypeBox>
</Typography>
<Typography
variant="body2"
color="text.secondary"
noWrap
>
<small>{t("Update At")}: </small>
{time.fromNow()}
</Typography>
</Box>
}
secondary={
<>
{/* 订阅信息 */}
{hasSubInfo && (
<>
<Box
sx={{
mb: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span title={t("Used / Total") as string}>
{parseTraffic(upload + download)} /{" "}
{parseTraffic(total)}
</span>
<span title={t("Expire Time") as string}>
{parseExpire(expire)}
</span>
</Box>
{/* 进度条 */}
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 6,
borderRadius: 3,
opacity: total > 0 ? 1 : 0,
}}
/>
</>
)}
</>
}
/>
<Divider orientation="vertical" flexItem />
<Box
sx={{
width: 40,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
> >
<IconButton <ListItemText
size="small" sx={{ px: 2, py: 1 }}
color="primary" primary={
onClick={() => { <Box
updateProvider(key); sx={{
}} display: "flex",
disabled={isUpdating} justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography
variant="subtitle1"
component="div"
noWrap
title={key}
sx={{ display: "flex", alignItems: "center" }}
>
<span style={{ marginRight: "8px" }}>{key}</span>
<TypeBox component="span">
{provider.proxies.length}
</TypeBox>
<TypeBox component="span">
{provider.vehicleType}
</TypeBox>
</Typography>
<Typography
variant="body2"
color="text.secondary"
noWrap
>
<small>{t("Update At")}: </small>
{time.fromNow()}
</Typography>
</Box>
}
secondary={
<>
{/* 订阅信息 */}
{hasSubInfo && (
<>
<Box
sx={{
mb: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span title={t("Used / Total") as string}>
{parseTraffic(upload + download)} /{" "}
{parseTraffic(total)}
</span>
<span title={t("Expire Time") as string}>
{parseExpire(expire)}
</span>
</Box>
{/* 进度条 */}
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 6,
borderRadius: 3,
opacity: total > 0 ? 1 : 0,
}}
/>
</>
)}
</>
}
/>
<Divider orientation="vertical" flexItem />
<Box
sx={{ sx={{
animation: isUpdating width: 40,
? "spin 1s linear infinite" display: "flex",
: "none", justifyContent: "center",
"@keyframes spin": { alignItems: "center",
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" },
},
}} }}
title={t("Update Provider") as string}
> >
<RefreshRounded /> <IconButton
</IconButton> size="small"
</Box> color="primary"
</ListItem> onClick={() => {
); updateProvider(key);
})} }}
disabled={isUpdating}
sx={{
animation: isUpdating
? "spin 1s linear infinite"
: "none",
"@keyframes spin": {
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" },
},
}}
title={t("Update Provider") as string}
>
<RefreshRounded />
</IconButton>
</Box>
</ListItem>
);
})}
</List> </List>
</DialogContent> </DialogContent>

View File

@@ -34,14 +34,13 @@ import {
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR from "swr"; import useSWR from "swr";
import { useAppData } from "@/providers/app-data-context";
import { import {
closeAllConnections, closeAllConnections,
getProxies, selectNodeForGroup,
updateProxyAndSync, } from "tauri-plugin-mihomo-api";
updateProxyChainConfigInRuntime,
} from "@/services/cmds"; import { useAppData } from "@/providers/app-data-context";
import { calcuProxies, updateProxyChainConfigInRuntime } from "@/services/cmds";
interface ProxyChainItem { interface ProxyChainItem {
id: string; id: string;
@@ -204,7 +203,7 @@ export const ProxyChain = ({
// 获取当前代理信息以检查连接状态 // 获取当前代理信息以检查连接状态
const { data: currentProxies, mutate: mutateProxies } = useSWR( const { data: currentProxies, mutate: mutateProxies } = useSWR(
"getProxies", "getProxies",
getProxies, calcuProxies,
{ {
revalidateOnFocus: true, revalidateOnFocus: true,
revalidateIfStale: true, revalidateIfStale: true,
@@ -367,7 +366,7 @@ export const ProxyChain = ({
const targetGroup = mode === "global" ? "GLOBAL" : selectedGroup; const targetGroup = mode === "global" ? "GLOBAL" : selectedGroup;
await updateProxyAndSync(targetGroup || "GLOBAL", lastNode.name); await selectNodeForGroup(targetGroup || "GLOBAL", lastNode.name);
localStorage.setItem("proxy-chain-group", targetGroup || "GLOBAL"); localStorage.setItem("proxy-chain-group", targetGroup || "GLOBAL");
localStorage.setItem("proxy-chain-exit-node", lastNode.name); localStorage.setItem("proxy-chain-exit-node", lastNode.name);

View File

@@ -14,14 +14,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import useSWR from "swr"; import useSWR from "swr";
import { delayGroup, healthcheckProxyProvider } from "tauri-plugin-mihomo-api";
import { useProxySelection } from "@/hooks/use-proxy-selection"; import { useProxySelection } from "@/hooks/use-proxy-selection";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-context"; import { useAppData } from "@/providers/app-data-context";
import { import {
getGroupProxyDelays,
getRuntimeConfig, getRuntimeConfig,
providerHealthCheck,
updateProxyChainConfigInRuntime, updateProxyChainConfigInRuntime,
} from "@/services/cmds"; } from "@/services/cmds";
import delayManager from "@/services/delay"; import delayManager from "@/services/delay";
@@ -153,15 +152,14 @@ export const ProxyGroups = (props: Props) => {
// 添加和清理滚动事件监听器 // 添加和清理滚动事件监听器
useEffect(() => { useEffect(() => {
const currentScroller = scrollerRef.current; if (!scrollerRef.current) return;
if (currentScroller) { scrollerRef.current.addEventListener("scroll", handleScroll, {
currentScroller.addEventListener("scroll", handleScroll, { passive: true,
passive: true, });
});
return () => { return () => {
currentScroller.removeEventListener("scroll", handleScroll); scrollerRef.current?.removeEventListener("scroll", handleScroll);
}; };
}
}, [handleScroll]); }, [handleScroll]);
// 滚动到顶部 // 滚动到顶部
@@ -215,6 +213,7 @@ export const ProxyGroups = (props: Props) => {
const currentGroup = getCurrentGroup(); const currentGroup = getCurrentGroup();
const availableGroups = getAvailableGroups(); const availableGroups = getAvailableGroups();
// TODO: 频繁点击切换代理节点,导致应用卡死
const handleChangeProxy = useCallback( const handleChangeProxy = useCallback(
(group: IProxyGroupItem, proxy: IProxyItem) => { (group: IProxyGroupItem, proxy: IProxyItem) => {
if (isChainMode) { if (isChainMode) {
@@ -273,7 +272,7 @@ export const ProxyGroups = (props: Props) => {
if (providers.size) { if (providers.size) {
console.log(`[ProxyGroups] 发现提供者,数量: ${providers.size}`); console.log(`[ProxyGroups] 发现提供者,数量: ${providers.size}`);
Promise.allSettled( Promise.allSettled(
[...providers].map((p) => providerHealthCheck(p)), [...providers].map((p) => healthcheckProxyProvider(p)),
).then(() => { ).then(() => {
console.log(`[ProxyGroups] 提供者健康检查完成`); console.log(`[ProxyGroups] 提供者健康检查完成`);
onProxies(); onProxies();
@@ -289,7 +288,7 @@ export const ProxyGroups = (props: Props) => {
try { try {
await Promise.race([ await Promise.race([
delayManager.checkListDelay(names, groupName, timeout), delayManager.checkListDelay(names, groupName, timeout),
getGroupProxyDelays(groupName, url, timeout).then((result) => { delayGroup(groupName, url, timeout).then((result) => {
console.log( console.log(
`[ProxyGroups] getGroupProxyDelays返回结果数量:`, `[ProxyGroups] getGroupProxyDelays返回结果数量:`,
Object.keys(result || {}).length, Object.keys(result || {}).length,
@@ -518,7 +517,7 @@ export const ProxyGroups = (props: Props) => {
}, },
}} }}
> >
{availableGroups.map((group: any, index: number) => ( {availableGroups.map((group: any, _index: number) => (
<MenuItem <MenuItem
key={group.name} key={group.name}
onClick={() => handleGroupSelect(group.name)} onClick={() => handleGroupSelect(group.name)}

View File

@@ -37,12 +37,12 @@ export const ProxyItemMini = (props: Props) => {
return () => { return () => {
delayManager.removeListener(proxy.name, group.name); delayManager.removeListener(proxy.name, group.name);
}; };
}, [proxy.name, group.name]); }, [isPreset, proxy.name, group.name]);
useEffect(() => { useEffect(() => {
if (!proxy) return; if (!proxy) return;
setDelay(delayManager.getDelayFix(proxy, group.name)); setDelay(delayManager.getDelayFix(proxy, group.name));
}, [proxy]); }, [proxy, group.name]);
const onDelay = useLockFn(async () => { const onDelay = useLockFn(async () => {
setDelay(-2); setDelay(-2);
@@ -200,7 +200,7 @@ export const ProxyItemMini = (props: Props) => {
</Widget> </Widget>
)} )}
{delay > 0 && ( {delay >= 0 && (
// 显示延迟 // 显示延迟
<Widget <Widget
className="the-delay" className="the-delay"
@@ -220,7 +220,7 @@ export const ProxyItemMini = (props: Props) => {
{delayManager.formatDelay(delay, timeout)} {delayManager.formatDelay(delay, timeout)}
</Widget> </Widget>
)} )}
{delay !== -2 && delay <= 0 && selected && ( {proxy.type !== "Direct" && delay !== -2 && delay < 0 && selected && (
// 展示已选择的icon // 展示已选择的icon
<CheckCircleOutlineRounded <CheckCircleOutlineRounded
className="the-icon" className="the-icon"

View File

@@ -19,19 +19,11 @@ import { useLockFn } from "ahooks";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateRuleProvider } from "tauri-plugin-mihomo-api";
import { useAppData } from "@/providers/app-data-context"; import { useAppData } from "@/providers/app-data-context";
import { ruleProviderUpdate } from "@/services/cmds";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
// 定义规则提供者类型
interface RuleProviderItem {
behavior: string;
ruleCount: number;
updatedAt: number;
vehicleType: string;
}
// 辅助组件 - 类型框 // 辅助组件 - 类型框
const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({ const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
display: "inline-block", display: "inline-block",
@@ -60,7 +52,7 @@ export const ProviderButton = () => {
// 设置更新状态 // 设置更新状态
setUpdating((prev) => ({ ...prev, [name]: true })); setUpdating((prev) => ({ ...prev, [name]: true }));
await ruleProviderUpdate(name); await updateRuleProvider(name);
// 刷新数据 // 刷新数据
await refreshRules(); await refreshRules();
@@ -101,7 +93,7 @@ export const ProviderButton = () => {
// 改为串行逐个更新所有provider // 改为串行逐个更新所有provider
for (const name of allProviders) { for (const name of allProviders) {
try { try {
await ruleProviderUpdate(name); await updateRuleProvider(name);
// 每个更新完成后更新状态 // 每个更新完成后更新状态
setUpdating((prev) => ({ ...prev, [name]: false })); setUpdating((prev) => ({ ...prev, [name]: false }));
} catch (err) { } catch (err) {
@@ -160,112 +152,117 @@ export const ProviderButton = () => {
<DialogContent> <DialogContent>
<List sx={{ py: 0, minHeight: 250 }}> <List sx={{ py: 0, minHeight: 250 }}>
{Object.entries(ruleProviders || {}).map(([key, item]) => { {Object.entries(ruleProviders || {})
const provider = item as RuleProviderItem; .sort()
const time = dayjs(provider.updatedAt); .map(([key, item]) => {
const isUpdating = updating[key]; const provider = item;
const time = dayjs(provider.updatedAt);
const isUpdating = updating[key];
return ( return (
<ListItem <ListItem
key={key} key={key}
sx={[ sx={[
{ {
p: 0, p: 0,
mb: "8px", mb: "8px",
borderRadius: 2, borderRadius: 2,
overflow: "hidden", overflow: "hidden",
transition: "all 0.2s", transition: "all 0.2s",
}, },
({ palette: { mode, primary } }) => { ({ palette: { mode, primary } }) => {
const bgcolor = mode === "light" ? "#ffffff" : "#24252f"; const bgcolor =
const hoverColor = mode === "light" ? "#ffffff" : "#24252f";
mode === "light" const hoverColor =
? alpha(primary.main, 0.1) mode === "light"
: alpha(primary.main, 0.2); ? alpha(primary.main, 0.1)
: alpha(primary.main, 0.2);
return { return {
backgroundColor: bgcolor, backgroundColor: bgcolor,
"&:hover": { "&:hover": {
backgroundColor: hoverColor, backgroundColor: hoverColor,
borderColor: alpha(primary.main, 0.3), borderColor: alpha(primary.main, 0.3),
}, },
}; };
}, },
]} ]}
>
<ListItemText
sx={{ px: 2, py: 1 }}
primary={
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography
variant="subtitle1"
component="div"
noWrap
title={key}
sx={{ display: "flex", alignItems: "center" }}
>
<span style={{ marginRight: "8px" }}>{key}</span>
<TypeBox component="span">
{provider.ruleCount}
</TypeBox>
</Typography>
<Typography
variant="body2"
color="text.secondary"
noWrap
>
<small>{t("Update At")}: </small>
{time.fromNow()}
</Typography>
</Box>
}
secondary={
<Box sx={{ display: "flex" }}>
<TypeBox component="span">
{provider.vehicleType}
</TypeBox>
<TypeBox component="span">{provider.behavior}</TypeBox>
</Box>
}
/>
<Divider orientation="vertical" flexItem />
<Box
sx={{
width: 40,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
> >
<IconButton <ListItemText
size="small" sx={{ px: 2, py: 1 }}
color="primary" primary={
onClick={() => updateProvider(key)} <Box
disabled={isUpdating} sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography
variant="subtitle1"
component="div"
noWrap
title={key}
sx={{ display: "flex", alignItems: "center" }}
>
<span style={{ marginRight: "8px" }}>{key}</span>
<TypeBox component="span">
{provider.ruleCount}
</TypeBox>
</Typography>
<Typography
variant="body2"
color="text.secondary"
noWrap
>
<small>{t("Update At")}: </small>
{time.fromNow()}
</Typography>
</Box>
}
secondary={
<Box sx={{ display: "flex" }}>
<TypeBox component="span">
{provider.vehicleType}
</TypeBox>
<TypeBox component="span">
{provider.behavior}
</TypeBox>
</Box>
}
/>
<Divider orientation="vertical" flexItem />
<Box
sx={{ sx={{
animation: isUpdating width: 40,
? "spin 1s linear infinite" display: "flex",
: "none", justifyContent: "center",
"@keyframes spin": { alignItems: "center",
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" },
},
}} }}
title={t("Update Provider") as string}
> >
<RefreshRounded /> <IconButton
</IconButton> size="small"
</Box> color="primary"
</ListItem> onClick={() => updateProvider(key)}
); disabled={isUpdating}
})} sx={{
animation: isUpdating
? "spin 1s linear infinite"
: "none",
"@keyframes spin": {
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" },
},
}}
title={t("Update Provider") as string}
>
<RefreshRounded />
</IconButton>
</Box>
</ListItem>
);
})}
</List> </List>
</DialogContent> </DialogContent>

View File

@@ -16,16 +16,11 @@ import type { Ref } from "react";
import { useImperativeHandle, useState } from "react"; import { useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { mutate } from "swr"; import { mutate } from "swr";
import { closeAllConnections, upgradeCore } from "tauri-plugin-mihomo-api";
import { BaseDialog, DialogRef } from "@/components/base"; import { BaseDialog, DialogRef } from "@/components/base";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { import { changeClashCore, restartCore } from "@/services/cmds";
changeClashCore,
closeAllConnections,
forceRefreshClashConfig,
restartCore,
upgradeCore,
} from "@/services/cmds";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
const VALID_CORE = [ const VALID_CORE = [
@@ -66,8 +61,6 @@ export function ClashCoreViewer({ ref }: { ref?: Ref<DialogRef> }) {
mutateVerge(); mutateVerge();
setTimeout(async () => { setTimeout(async () => {
// 核心切换后强制刷新配置缓存
await forceRefreshClashConfig();
mutate("getClashConfig"); mutate("getClashConfig");
mutate("getVersion"); mutate("getVersion");
setChangingCore(null); setChangingCore(null);

View File

@@ -20,6 +20,7 @@ import {
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
import { getBaseConfig } from "tauri-plugin-mihomo-api";
import { BaseDialog, DialogRef, Switch } from "@/components/base"; import { BaseDialog, DialogRef, Switch } from "@/components/base";
import { BaseFieldset } from "@/components/base/base-fieldset"; import { BaseFieldset } from "@/components/base/base-fieldset";
@@ -29,7 +30,6 @@ import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-context"; import { useAppData } from "@/providers/app-data-context";
import { import {
getAutotemProxy, getAutotemProxy,
getClashConfig,
getNetworkInterfacesInfo, getNetworkInterfacesInfo,
getSystemHostname, getSystemHostname,
getSystemProxy, getSystemProxy,
@@ -123,26 +123,21 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
return "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,172.29.0.0/16,localhost,*.local,*.crashlytics.com,<local>"; return "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,172.29.0.0/16,localhost,*.local,*.crashlytics.com,<local>";
}; };
const { data: clashConfig } = useSWR("getClashConfig", getClashConfig, { const { data: clashConfig } = useSWR("getClashConfig", getBaseConfig, {
revalidateOnFocus: false, revalidateOnFocus: false,
revalidateIfStale: true, revalidateIfStale: true,
dedupingInterval: 1000, dedupingInterval: 1000,
errorRetryInterval: 5000, errorRetryInterval: 5000,
}); });
const [prevMixedPort, setPrevMixedPort] = useState( const [prevMixedPort, setPrevMixedPort] = useState(clashConfig?.mixedPort);
clashConfig?.["mixed-port"],
);
useEffect(() => { useEffect(() => {
if ( if (clashConfig?.mixedPort && clashConfig.mixedPort !== prevMixedPort) {
clashConfig?.["mixed-port"] && setPrevMixedPort(clashConfig.mixedPort);
clashConfig?.["mixed-port"] !== prevMixedPort
) {
setPrevMixedPort(clashConfig?.["mixed-port"]);
resetSystemProxy(); resetSystemProxy();
} }
}, [clashConfig?.["mixed-port"]]); }, [clashConfig?.mixedPort]);
const resetSystemProxy = async () => { const resetSystemProxy = async () => {
try { try {
@@ -180,7 +175,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
if (isPacMode) { if (isPacMode) {
const host = value.proxy_host || "127.0.0.1"; const host = value.proxy_host || "127.0.0.1";
const port = verge?.verge_mixed_port || clashConfig["mixed-port"] || 7897; const port = verge?.verge_mixed_port || clashConfig.mixedPort || 7897;
return `${host}:${port}`; return `${host}:${port}`;
} else { } else {
return systemProxyAddress; return systemProxyAddress;
@@ -332,7 +327,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
if (pacContent) { if (pacContent) {
pacContent = pacContent.replace(/%proxy_host%/g, value.proxy_host); pacContent = pacContent.replace(/%proxy_host%/g, value.proxy_host);
// 将 mixed-port 转换为字符串 // 将 mixed-port 转换为字符串
const mixedPortStr = (clashConfig?.["mixed-port"] || "").toString(); const mixedPortStr = (clashConfig?.mixedPort || "").toString();
pacContent = pacContent.replace(/%mixed-port%/g, mixedPortStr); pacContent = pacContent.replace(/%mixed-port%/g, mixedPortStr);
} }

View File

@@ -4,14 +4,15 @@ import { invoke } from "@tauri-apps/api/core";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateGeo } from "tauri-plugin-mihomo-api";
import { DialogRef, Switch } from "@/components/base"; import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon"; import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { useClash } from "@/hooks/use-clash"; import { useClash } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { invoke_uwp_tool } from "@/services/cmds"; import { invoke_uwp_tool } from "@/services/cmds";
import { updateGeoData } from "@/services/cmds";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
import { useClashLog } from "@/services/states";
import getSystem from "@/utils/get-system"; import getSystem from "@/utils/get-system";
import { ClashCoreViewer } from "./mods/clash-core-viewer"; import { ClashCoreViewer } from "./mods/clash-core-viewer";
@@ -35,6 +36,7 @@ const SettingClash = ({ onError }: Props) => {
const { clash, version, mutateClash, patchClash } = useClash(); const { clash, version, mutateClash, patchClash } = useClash();
const { verge, patchVerge } = useVerge(); const { verge, patchVerge } = useVerge();
const [, setClashLog] = useClashLog();
const { const {
ipv6, ipv6,
@@ -64,7 +66,7 @@ const SettingClash = ({ onError }: Props) => {
}; };
const onUpdateGeo = async () => { const onUpdateGeo = async () => {
try { try {
await updateGeoData(); await updateGeo();
showNotice("success", t("GeoData Updated")); showNotice("success", t("GeoData Updated"));
} catch (err: any) { } catch (err: any) {
showNotice("error", err?.response.data.message || err.toString()); showNotice("error", err?.response.data.message || err.toString());
@@ -186,7 +188,10 @@ const SettingClash = ({ onError }: Props) => {
onCatch={onError} onCatch={onError}
onFormat={(e: any) => e.target.value} onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ "log-level": e })} onChange={(e) => onChangeData({ "log-level": e })}
onGuard={(e) => patchClash({ "log-level": e })} onGuard={(e) => {
setClashLog((pre: any) => ({ ...pre, logLevel: e }));
return patchClash({ "log-level": e });
}}
> >
<Select size="small" sx={{ width: 100, "> div": { py: "7.5px" } }}> <Select size="small" sx={{ width: 100, "> div": { py: "7.5px" } }}>
<MenuItem value="debug">Debug</MenuItem> <MenuItem value="debug">Debug</MenuItem>

View File

@@ -5,7 +5,7 @@ import { Box, Divider, MenuItem, Menu, styled, alpha } from "@mui/material";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { UnlistenFn } from "@tauri-apps/api/event"; import { UnlistenFn } from "@tauri-apps/api/event";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BaseLoading } from "@/components/base"; import { BaseLoading } from "@/components/base";
@@ -44,11 +44,11 @@ export const TestItem = (props: Props) => {
const [iconCachePath, setIconCachePath] = useState(""); const [iconCachePath, setIconCachePath] = useState("");
const { addListener } = useListen(); const { addListener } = useListen();
const onDelay = async () => { const onDelay = useCallback(async () => {
setDelay(-2); setDelay(-2);
const result = await cmdTestDelay(url); const result = await cmdTestDelay(url);
setDelay(result); setDelay(result);
}; }, [url]);
useEffect(() => { useEffect(() => {
initIconCachePath(); initIconCachePath();

View File

@@ -1,12 +1,11 @@
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
import { getVersion } from "tauri-plugin-mihomo-api";
import { getVersion } from "@/services/cmds";
import { import {
getClashInfo, getClashInfo,
patchClashConfig, patchClashConfig,
getRuntimeConfig, getRuntimeConfig,
forceRefreshClashConfig,
} from "@/services/cmds"; } from "@/services/cmds";
export const useClash = () => { export const useClash = () => {
@@ -25,11 +24,9 @@ export const useClash = () => {
mutateClash(); mutateClash();
}); });
const version = versionData?.premium const version = versionData?.meta
? `${versionData.version} Premium` ? `${versionData.version} Mihomo`
: versionData?.meta : versionData?.version || "-";
? `${versionData.version} Mihomo`
: versionData?.version || "-";
return { return {
clash, clash,
@@ -123,10 +120,7 @@ export const useClashInfo = () => {
await patchClashConfig(patch); await patchClashConfig(patch);
mutateInfo(); mutateInfo();
// 配置修改后强制刷新缓存
await forceRefreshClashConfig();
mutate("getClashConfig"); mutate("getClashConfig");
// IPC调用不需要刷新axios实例
}; };
return { return {

View File

@@ -0,0 +1,108 @@
import { useLocalStorage } from "foxact/use-local-storage";
import { useEffect, useRef } from "react";
import { mutate } from "swr";
import useSWRSubscription from "swr/subscription";
import { MihomoWebSocket } from "tauri-plugin-mihomo-api";
export const initConnData: IConnections = {
uploadTotal: 0,
downloadTotal: 0,
connections: [],
};
export const useConnectionData = () => {
const [date, setDate] = useLocalStorage("mihomo_connection_date", Date.now());
const subscriptKey = `getClashConnection-${date}`;
const ws = useRef<MihomoWebSocket | null>(null);
const wsFirstConnection = useRef<boolean>(true);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const response = useSWRSubscription<IConnections, any, string | null>(
subscriptKey,
(_key, { next }) => {
const reconnect = async () => {
await ws.current?.close();
ws.current = null;
timeoutRef.current = setTimeout(async () => await connect(), 500);
};
const connect = () =>
MihomoWebSocket.connect_connections()
.then((ws_) => {
ws.current = ws_;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
ws_.addListener(async (msg) => {
if (msg.type === "Text") {
if (msg.data.startsWith("Websocket error")) {
next(msg.data);
await reconnect();
} else {
const data = JSON.parse(msg.data) as IConnections;
next(null, (old = initConnData) => {
const oldConn = old.connections;
const maxLen = data.connections?.length;
const connections: IConnectionsItem[] = [];
const rest = (data.connections || []).filter((each) => {
const index = oldConn.findIndex((o) => o.id === each.id);
if (index >= 0 && index < maxLen) {
const old = oldConn[index];
each.curUpload = each.upload - old.upload;
each.curDownload = each.download - old.download;
connections[index] = each;
return false;
}
return true;
});
for (let i = 0; i < maxLen; ++i) {
if (!connections[i] && rest.length > 0) {
connections[i] = rest.shift()!;
connections[i].curUpload = 0;
connections[i].curDownload = 0;
}
}
return { ...data, connections };
});
}
}
});
})
.catch((_) => {
if (!ws.current) {
timeoutRef.current = setTimeout(async () => await connect(), 500);
}
});
if (
wsFirstConnection.current ||
(ws.current && !wsFirstConnection.current)
) {
wsFirstConnection.current = false;
if (ws.current) {
ws.current.close();
ws.current = null;
}
connect();
}
return () => {
ws.current?.close();
};
},
{
fallbackData: initConnData,
keepPreviousData: true,
},
);
useEffect(() => {
mutate(`$sub$${subscriptKey}`);
}, [date, subscriptKey]);
const refreshGetClashConnection = () => {
setDate(Date.now());
};
return { response, refreshGetClashConnection };
};

View File

@@ -0,0 +1,151 @@
import dayjs from "dayjs";
import { useLocalStorage } from "foxact/use-local-storage";
import { useEffect, useRef } from "react";
import { mutate } from "swr";
import useSWRSubscription from "swr/subscription";
import { MihomoWebSocket } from "tauri-plugin-mihomo-api";
import { getClashLogs } from "@/services/cmds";
import { useClashLog } from "@/services/states";
const MAX_LOG_NUM = 1000;
export const useLogData = () => {
const [clashLog] = useClashLog();
const enableLog = clashLog.enable;
const logLevel = clashLog.logLevel;
const [date, setDate] = useLocalStorage("mihomo_logs_date", Date.now());
const subscriptKey = enableLog ? `getClashLog-${date}` : null;
const ws = useRef<MihomoWebSocket | null>(null);
const wsFirstConnection = useRef<boolean>(true);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const response = useSWRSubscription<ILogItem[], any, string | null>(
subscriptKey,
(_key, { next }) => {
const reconnect = async () => {
await ws.current?.close();
ws.current = null;
timeoutRef.current = setTimeout(async () => await connect(), 500);
};
const connect = () =>
MihomoWebSocket.connect_logs(logLevel)
.then(async (ws_) => {
ws.current = ws_;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
const logs = await getClashLogs();
let filterLogs: ILogItem[] = [];
switch (logLevel) {
case "debug":
filterLogs = logs.filter((i) =>
["debug", "info", "warning", "error"].includes(i.type),
);
break;
case "info":
filterLogs = logs.filter((i) =>
["info", "warning", "error"].includes(i.type),
);
break;
case "warning":
filterLogs = logs.filter((i) =>
["warning", "error"].includes(i.type),
);
break;
case "error":
filterLogs = logs.filter((i) => i.type === "error");
break;
case "silent":
filterLogs = [];
break;
default:
filterLogs = logs;
break;
}
next(null, filterLogs);
const buffer: ILogItem[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const flush = () => {
if (buffer.length > 0) {
next(null, (l) => {
let newList = [...(l ?? []), ...buffer.splice(0)];
if (newList.length > MAX_LOG_NUM) {
newList = newList.slice(
-Math.min(MAX_LOG_NUM, newList.length),
);
}
return newList;
});
}
flushTimer = null;
};
ws_.addListener(async (msg) => {
if (msg.type === "Text") {
if (msg.data.startsWith("Websocket error")) {
next(msg.data);
await reconnect();
} else {
const data = JSON.parse(msg.data) as ILogItem;
data.time = dayjs().format("MM-DD HH:mm:ss");
buffer.push(data);
// flush data
if (!flushTimer) {
flushTimer = setTimeout(flush, 50);
}
}
}
});
})
.catch((_) => {
if (!ws.current) {
timeoutRef.current = setTimeout(async () => await connect(), 500);
}
});
if (
wsFirstConnection.current ||
(ws.current && !wsFirstConnection.current)
) {
wsFirstConnection.current = false;
if (ws.current) {
ws.current.close();
ws.current = null;
}
connect();
}
return () => {
ws.current?.close();
};
},
{
fallbackData: [],
keepPreviousData: true,
},
);
useEffect(() => {
mutate(`$sub$${subscriptKey}`);
}, [date, subscriptKey]);
useEffect(() => {
if (!logLevel) return;
ws.current?.close();
setDate(Date.now());
}, [logLevel]);
const refreshGetClashLog = (clear = false) => {
if (clear) {
mutate(`$sub$${subscriptKey}`, []);
} else {
setDate(Date.now());
}
};
return { response, refreshGetClashLog };
};

View File

@@ -1,11 +1,11 @@
import { import {
useGlobalLogData, useGlobalLogData,
clearGlobalLogs, clearGlobalLogs,
LogLevel, // LogLevel,
} from "@/services/global-log-service"; } from "@/services/global-log-service";
// 为了向后兼容,导出相同的类型 // 为了向后兼容,导出相同的类型
export type { LogLevel }; // export type { LogLevel };
export const useLogData = useGlobalLogData; export const useLogData = useGlobalLogData;

View File

@@ -0,0 +1,84 @@
import { useLocalStorage } from "foxact/use-local-storage";
import { useEffect, useRef } from "react";
import { mutate } from "swr";
import useSWRSubscription from "swr/subscription";
import { MihomoWebSocket } from "tauri-plugin-mihomo-api";
export interface IMemoryUsageItem {
inuse: number;
oslimit?: number;
}
export const useMemoryData = () => {
const [date, setDate] = useLocalStorage("mihomo_memory_date", Date.now());
const subscriptKey = `getClashMemory-${date}`;
const ws = useRef<MihomoWebSocket | null>(null);
const wsFirstConnection = useRef<boolean>(true);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const response = useSWRSubscription<IMemoryUsageItem, any, string | null>(
subscriptKey,
(_key, { next }) => {
const reconnect = async () => {
await ws.current?.close();
ws.current = null;
timeoutRef.current = setTimeout(async () => await connect(), 500);
};
const connect = () =>
MihomoWebSocket.connect_memory()
.then((ws_) => {
ws.current = ws_;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
ws_.addListener(async (msg) => {
if (msg.type === "Text") {
if (msg.data.startsWith("Websocket error")) {
next(msg.data, { inuse: 0 });
await reconnect();
} else {
const data = JSON.parse(msg.data) as IMemoryUsageItem;
next(null, data);
}
}
});
})
.catch((_) => {
if (!ws.current) {
timeoutRef.current = setTimeout(async () => await connect(), 500);
}
});
if (
wsFirstConnection.current ||
(ws.current && !wsFirstConnection.current)
) {
wsFirstConnection.current = false;
if (ws.current) {
ws.current.close();
ws.current = null;
}
connect();
}
return () => {
ws.current?.close();
};
},
{
fallbackData: { inuse: 0 },
keepPreviousData: true,
},
);
useEffect(() => {
mutate(`$sub$${subscriptKey}`);
}, [date, subscriptKey]);
const refreshGetClashMemory = () => {
setDate(Date.now());
};
return { response, refreshGetClashMemory };
};

View File

@@ -1,12 +1,12 @@
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
import { selectNodeForGroup } from "tauri-plugin-mihomo-api";
import { import {
getProfiles, getProfiles,
patchProfile, patchProfile,
patchProfilesConfig, patchProfilesConfig,
forceRefreshProxies,
} from "@/services/cmds"; } from "@/services/cmds";
import { getProxies, updateProxy } from "@/services/cmds"; import { calcuProxies } from "@/services/cmds";
export const useProfiles = () => { export const useProfiles = () => {
const { const {
@@ -72,7 +72,7 @@ export const useProfiles = () => {
console.log("[ActivateSelected] 开始处理代理选择"); console.log("[ActivateSelected] 开始处理代理选择");
const [proxiesData, profileData] = await Promise.all([ const [proxiesData, profileData] = await Promise.all([
getProxies(), calcuProxies(),
getProfiles(), getProfiles(),
]); ]);
@@ -124,7 +124,7 @@ export const useProfiles = () => {
`[ActivateSelected] 需要切换代理组 ${name}: ${now} -> ${targetProxy}`, `[ActivateSelected] 需要切换代理组 ${name}: ${now} -> ${targetProxy}`,
); );
hasChange = true; hasChange = true;
updateProxy(name, targetProxy); selectNodeForGroup(name, targetProxy);
} }
newSelected.push({ name, now: targetProxy || now }); newSelected.push({ name, now: targetProxy || now });
@@ -141,11 +141,8 @@ export const useProfiles = () => {
await patchProfile(profileData.current!, { selected: newSelected }); await patchProfile(profileData.current!, { selected: newSelected });
console.log("[ActivateSelected] 代理选择配置保存成功"); console.log("[ActivateSelected] 代理选择配置保存成功");
// 切换节点后强制刷新后端缓存
await forceRefreshProxies();
setTimeout(() => { setTimeout(() => {
mutate("getProxies", getProxies()); mutate("getProxies", calcuProxies());
}, 100); }, 100);
} catch (error: any) { } catch (error: any) {
console.error( console.error(

View File

@@ -1,24 +1,22 @@
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import {
closeConnections,
getConnections,
selectNodeForGroup,
} from "tauri-plugin-mihomo-api";
import { useProfiles } from "@/hooks/use-profiles"; import { useProfiles } from "@/hooks/use-profiles";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { import { syncTrayProxySelection } from "@/services/cmds";
updateProxy,
updateProxyAndSync,
forceRefreshProxies,
syncTrayProxySelection,
getConnections,
deleteConnection,
} from "@/services/cmds";
// 缓存连接清理 // 缓存连接清理
const cleanupConnections = async (previousProxy: string) => { const cleanupConnections = async (previousProxy: string) => {
try { try {
const { connections } = await getConnections(); const { connections } = await getConnections();
const cleanupPromises = connections const cleanupPromises = (connections ?? [])
.filter((conn) => conn.chains.includes(previousProxy)) .filter((conn) => conn.chains.includes(previousProxy))
.map((conn) => deleteConnection(conn.id)); .map((conn) => closeConnections(conn.id));
if (cleanupPromises.length > 0) { if (cleanupPromises.length > 0) {
await Promise.allSettled(cleanupPromises); await Promise.allSettled(cleanupPromises);
@@ -77,7 +75,8 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
await patchCurrent({ selected: current.selected }); await patchCurrent({ selected: current.selected });
} }
await updateProxyAndSync(groupName, proxyName); await selectNodeForGroup(groupName, proxyName);
await syncTrayProxySelection();
console.log( console.log(
`[ProxySelection] 代理和状态同步完成: ${groupName} -> ${proxyName}`, `[ProxySelection] 代理和状态同步完成: ${groupName} -> ${proxyName}`,
); );
@@ -98,8 +97,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
); );
try { try {
await updateProxy(groupName, proxyName); await selectNodeForGroup(groupName, proxyName);
await forceRefreshProxies();
await syncTrayProxySelection(); await syncTrayProxySelection();
onSuccess?.(); onSuccess?.();
console.log( console.log(

View File

@@ -1,8 +1,9 @@
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
import { closeAllConnections } from "tauri-plugin-mihomo-api";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-context"; import { useAppData } from "@/providers/app-data-context";
import { closeAllConnections, getAutotemProxy } from "@/services/cmds"; import { getAutotemProxy } from "@/services/cmds";
// 系统代理状态检测统一逻辑 // 系统代理状态检测统一逻辑
export const useSystemProxyState = () => { export const useSystemProxyState = () => {

View File

@@ -0,0 +1,84 @@
import { useLocalStorage } from "foxact/use-local-storage";
import { useEffect, useRef } from "react";
import { mutate } from "swr";
import useSWRSubscription from "swr/subscription";
import { MihomoWebSocket } from "tauri-plugin-mihomo-api";
import { TrafficRef } from "@/components/layout/traffic-graph";
export const useTrafficData = () => {
const [date, setDate] = useLocalStorage("mihomo_traffic_date", Date.now());
const subscriptKey = `getClashTraffic-${date}`;
const trafficRef = useRef<TrafficRef>(null);
const ws = useRef<MihomoWebSocket | null>(null);
const wsFirstConnection = useRef<boolean>(true);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const response = useSWRSubscription<ITrafficItem, any, string | null>(
subscriptKey,
(_key, { next }) => {
const reconnect = async () => {
await ws.current?.close();
ws.current = null;
timeoutRef.current = setTimeout(async () => await connect(), 500);
};
const connect = async () => {
MihomoWebSocket.connect_traffic()
.then(async (ws_) => {
ws.current = ws_;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
ws_.addListener(async (msg) => {
if (msg.type === "Text") {
if (msg.data.startsWith("Websocket error")) {
next(msg.data, { up: 0, down: 0 });
await reconnect();
} else {
const data = JSON.parse(msg.data) as ITrafficItem;
trafficRef.current?.appendData(data);
next(null, data);
}
}
});
})
.catch((_) => {
if (!ws.current) {
timeoutRef.current = setTimeout(async () => await connect(), 500);
}
});
};
if (
wsFirstConnection.current ||
(ws.current && !wsFirstConnection.current)
) {
wsFirstConnection.current = false;
if (ws.current) {
ws.current.close();
ws.current = null;
}
connect();
}
return () => {
ws.current?.close();
};
},
{
fallbackData: { up: 0, down: 0 },
keepPreviousData: true,
},
);
useEffect(() => {
mutate(`$sub$${subscriptKey}`);
}, [date, subscriptKey]);
const refreshGetClashTraffic = () => {
setDate(Date.now());
};
return { response, refreshGetClashTraffic };
};

View File

@@ -1,9 +1,9 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import useSWR from "swr";
import { useClashInfo } from "@/hooks/use-clash"; // import { useClashInfo } from "@/hooks/use-clash";
import { useVisibility } from "@/hooks/use-visibility"; // import { useVisibility } from "@/hooks/use-visibility";
import { getSystemMonitorOverviewSafe } from "@/services/cmds";
import { useTrafficData } from "./use-traffic-data";
// 增强的流量数据点接口 // 增强的流量数据点接口
export interface ITrafficDataPoint { export interface ITrafficDataPoint {
@@ -175,14 +175,17 @@ class TrafficDataSampler {
// 全局单例 // 全局单例
const refCounter = new ReferenceCounter(); const refCounter = new ReferenceCounter();
let globalSampler: TrafficDataSampler | null = null; let globalSampler: TrafficDataSampler | null = null;
let lastValidData: ISystemMonitorOverview | null = null; // let lastValidData: ISystemMonitorOverview | null = null;
/** /**
* 增强的流量监控Hook - 支持数据压缩、采样和引用计数 * 增强的流量监控Hook - 支持数据压缩、采样和引用计数
*/ */
export const useTrafficMonitorEnhanced = () => { export const useTrafficMonitorEnhanced = () => {
const { clashInfo } = useClashInfo(); // const { clashInfo } = useClashInfo();
const pageVisible = useVisibility(); // const pageVisible = useVisibility();
const {
response: { data: traffic },
} = useTrafficData();
// 初始化采样器 // 初始化采样器
if (!globalSampler) { if (!globalSampler) {
@@ -230,69 +233,87 @@ export const useTrafficMonitorEnhanced = () => {
refCounter.onCountChange(handleCountChange); refCounter.onCountChange(handleCountChange);
}, []); }, []);
// 只有在有引用时才启用SWR // const monitorData = useRef<ISystemMonitorOverview | null>(null);
const shouldFetch = clashInfo && pageVisible && refCounter.getCount() > 0; useEffect(() => {
if (globalSampler) {
// 添加到采样器
const timestamp = Date.now();
const dataPoint: ITrafficDataPoint = {
up: traffic?.up || 0,
down: traffic?.down || 0,
timestamp,
name: new Date(timestamp).toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}),
};
globalSampler.addDataPoint(dataPoint);
triggerUpdate();
}
}, [traffic, triggerUpdate]);
const { data: monitorData, error } = useSWR<ISystemMonitorOverview>( // const { data: monitorData, error } = useSWR<ISystemMonitorOverview>(
shouldFetch ? "getSystemMonitorOverviewSafe" : null, // shouldFetch ? "getSystemMonitorOverviewSafe" : null,
getSystemMonitorOverviewSafe, // getSystemMonitorOverviewSafe,
{ // {
refreshInterval: shouldFetch ? 1000 : 0, // 只有在需要时才刷新 // refreshInterval: shouldFetch ? 1000 : 0, // 只有在需要时才刷新
keepPreviousData: true, // keepPreviousData: true,
onSuccess: (data) => { // onSuccess: (data) => {
// console.log("[TrafficMonitorEnhanced] 获取到监控数据:", data); // // console.log("[TrafficMonitorEnhanced] 获取到监控数据:", data);
if (data?.traffic?.raw && globalSampler) { // if (data?.traffic?.raw && globalSampler) {
// 保存最后有效数据 // // 保存最后有效数据
lastValidData = data; // lastValidData = data;
// 添加到采样器 // // 添加到采样器
const timestamp = Date.now(); // const timestamp = Date.now();
const dataPoint: ITrafficDataPoint = { // const dataPoint: ITrafficDataPoint = {
up: data.traffic.raw.up_rate || 0, // up: data.traffic.raw.up_rate || 0,
down: data.traffic.raw.down_rate || 0, // down: data.traffic.raw.down_rate || 0,
timestamp, // timestamp,
name: new Date(timestamp).toLocaleTimeString("en-US", { // name: new Date(timestamp).toLocaleTimeString("en-US", {
hour12: false, // hour12: false,
hour: "2-digit", // hour: "2-digit",
minute: "2-digit", // minute: "2-digit",
second: "2-digit", // second: "2-digit",
}), // }),
}; // };
globalSampler.addDataPoint(dataPoint); // globalSampler.addDataPoint(dataPoint);
triggerUpdate(); // triggerUpdate();
} // }
}, // },
onError: (error) => { // onError: (error) => {
console.error( // console.error(
"[TrafficMonitorEnhanced] 网络错误,使用最后有效数据. 错误详情:", // "[TrafficMonitorEnhanced] 网络错误,使用最后有效数据. 错误详情:",
{ // {
message: error?.message || "未知错误", // message: error?.message || "未知错误",
stack: error?.stack || "无堆栈信息", // stack: error?.stack || "无堆栈信息",
}, // },
); // );
// 网络错误时不清空数据,继续使用最后有效值 // // 网络错误时不清空数据,继续使用最后有效值
// 但是添加一个错误标记的数据点流量为0 // // 但是添加一个错误标记的数据点流量为0
if (globalSampler) { // if (globalSampler) {
const timestamp = Date.now(); // const timestamp = Date.now();
const errorPoint: ITrafficDataPoint = { // const errorPoint: ITrafficDataPoint = {
up: 0, // up: 0,
down: 0, // down: 0,
timestamp, // timestamp,
name: new Date(timestamp).toLocaleTimeString("en-US", { // name: new Date(timestamp).toLocaleTimeString("en-US", {
hour12: false, // hour12: false,
hour: "2-digit", // hour: "2-digit",
minute: "2-digit", // minute: "2-digit",
second: "2-digit", // second: "2-digit",
}), // }),
}; // };
globalSampler.addDataPoint(errorPoint); // globalSampler.addDataPoint(errorPoint);
triggerUpdate(); // triggerUpdate();
} // }
}, // },
}, // },
); // );
// 获取指定时间范围的数据 // 获取指定时间范围的数据
const getDataForTimeRange = useCallback( const getDataForTimeRange = useCallback(
@@ -324,28 +345,28 @@ export const useTrafficMonitorEnhanced = () => {
}, []); }, []);
// 构建返回的监控数据优先使用当前数据fallback到最后有效数据 // 构建返回的监控数据优先使用当前数据fallback到最后有效数据
const currentData = monitorData || lastValidData; // const currentData = monitorData.current || lastValidData;
const trafficMonitorData = { // const trafficMonitorData = {
traffic: currentData?.traffic || { // traffic: currentData?.traffic || {
raw: { up: 0, down: 0, up_rate: 0, down_rate: 0 }, // raw: { up: 0, down: 0, up_rate: 0, down_rate: 0 },
formatted: { // formatted: {
up_rate: "0B", // up_rate: "0B",
down_rate: "0B", // down_rate: "0B",
total_up: "0B", // total_up: "0B",
total_down: "0B", // total_down: "0B",
}, // },
is_fresh: false, // is_fresh: false,
}, // },
memory: currentData?.memory || { // memory: currentData?.memory || {
raw: { inuse: 0, oslimit: 0, usage_percent: 0 }, // raw: { inuse: 0, oslimit: 0, usage_percent: 0 },
formatted: { inuse: "0B", oslimit: "0B", usage_percent: 0 }, // formatted: { inuse: "0B", oslimit: "0B", usage_percent: 0 },
is_fresh: false, // is_fresh: false,
}, // },
}; // };
return { return {
// 监控数据 // 监控数据
monitorData: trafficMonitorData, // monitorData: trafficMonitorData,
// 图表数据管理 // 图表数据管理
graphData: { graphData: {
@@ -355,10 +376,9 @@ export const useTrafficMonitorEnhanced = () => {
}, },
// 状态信息 // 状态信息
isLoading: !currentData && !error, // isLoading: !currentData,
error, // isDataFresh: currentData?.traffic?.is_fresh || false,
isDataFresh: currentData?.traffic?.is_fresh || false, // hasValidData: !!lastValidData,
hasValidData: !!lastValidData,
// 性能统计 // 性能统计
samplerStats: getSamplerStats(), samplerStats: getSamplerStats(),
@@ -369,30 +389,28 @@ export const useTrafficMonitorEnhanced = () => {
/** /**
* 轻量级流量数据Hook * 轻量级流量数据Hook
*/ */
export const useTrafficDataEnhanced = () => { // export const useTrafficDataEnhanced = () => {
const { monitorData, isLoading, error, isDataFresh, hasValidData } = // const { monitorData, isLoading, isDataFresh, hasValidData } =
useTrafficMonitorEnhanced(); // useTrafficMonitorEnhanced();
return { // return {
traffic: monitorData.traffic, // traffic: monitorData.traffic,
memory: monitorData.memory, // memory: monitorData.memory,
isLoading, // isLoading,
error, // isDataFresh,
isDataFresh, // hasValidData,
hasValidData, // };
}; // };
};
/** /**
* 图表数据Hook * 图表数据Hook
*/ */
export const useTrafficGraphDataEnhanced = () => { export const useTrafficGraphDataEnhanced = () => {
const { graphData, isDataFresh, samplerStats, referenceCount } = const { graphData, samplerStats, referenceCount } =
useTrafficMonitorEnhanced(); useTrafficMonitorEnhanced();
return { return {
...graphData, ...graphData,
isDataFresh,
samplerStats, samplerStats,
referenceCount, referenceCount,
}; };

View File

@@ -3,14 +3,11 @@
import "./assets/styles/index.scss"; import "./assets/styles/index.scss";
import { ResizeObserver } from "@juggle/resize-observer"; import { ResizeObserver } from "@juggle/resize-observer";
if (!window.ResizeObserver) {
window.ResizeObserver = ResizeObserver;
}
import { ComposeContextProvider } from "foxact/compose-context-provider"; import { ComposeContextProvider } from "foxact/compose-context-provider";
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { MihomoWebSocket } from "tauri-plugin-mihomo-api";
import { BaseErrorBoundary } from "./components/base"; import { BaseErrorBoundary } from "./components/base";
import Layout from "./pages/_layout"; import Layout from "./pages/_layout";
@@ -22,6 +19,10 @@ import {
UpdateStateProvider, UpdateStateProvider,
} from "./services/states"; } from "./services/states";
if (!window.ResizeObserver) {
window.ResizeObserver = ResizeObserver;
}
const mainElementId = "root"; const mainElementId = "root";
const container = document.getElementById(mainElementId); const container = document.getElementById(mainElementId);
@@ -90,3 +91,9 @@ window.addEventListener("error", (event) => {
window.addEventListener("unhandledrejection", (event) => { window.addEventListener("unhandledrejection", (event) => {
console.error("[main.tsx] 未处理的Promise拒绝:", event.reason); console.error("[main.tsx] 未处理的Promise拒绝:", event.reason);
}); });
// 页面关闭/刷新事件
window.addEventListener("beforeunload", async () => {
// 强制清理所有 WebSocket 实例, 防止内存泄漏
await MihomoWebSocket.cleanupAll();
});

View File

@@ -1,26 +1,33 @@
import { List, Paper, ThemeProvider, SvgIcon } from "@mui/material"; import { List, Paper, SvgIcon, ThemeProvider } from "@mui/material";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import { useLocalStorage } from "foxact/use-local-storage"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useCallback, useState, useRef } from "react";
import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation, useRoutes, useNavigate } from "react-router-dom"; import { useLocation, useNavigate, useRoutes } from "react-router-dom";
import { SWRConfig, mutate } from "swr"; import { SWRConfig, mutate } from "swr";
import iconDark from "@/assets/image/icon_dark.svg?react"; import iconDark from "@/assets/image/icon_dark.svg?react";
import iconLight from "@/assets/image/icon_light.svg?react"; import iconLight from "@/assets/image/icon_light.svg?react";
import LogoSvg from "@/assets/image/logo.svg?react"; import LogoSvg from "@/assets/image/logo.svg?react";
import { NoticeManager } from "@/components/base/NoticeManager";
import { LayoutItem } from "@/components/layout/layout-item"; import { LayoutItem } from "@/components/layout/layout-item";
import { LayoutTraffic } from "@/components/layout/layout-traffic"; import { LayoutTraffic } from "@/components/layout/layout-traffic";
import { UpdateButton } from "@/components/layout/update-button"; import { UpdateButton } from "@/components/layout/update-button";
import { useCustomTheme } from "@/components/layout/use-custom-theme"; import { useCustomTheme } from "@/components/layout/use-custom-theme";
import { useClashInfo } from "@/hooks/use-clash";
import { useConnectionData } from "@/hooks/use-connection-data";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import { useListen } from "@/hooks/use-listen";
import { useLogData } from "@/hooks/use-log-data-new";
import { useMemoryData } from "@/hooks/use-memory-data";
import { useTrafficData } from "@/hooks/use-traffic-data";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { getAxios } from "@/services/api"; import { getAxios } from "@/services/api";
import { forceRefreshClashConfig } from "@/services/cmds"; import { showNotice } from "@/services/noticeService";
import { useThemeMode, useEnableLog } from "@/services/states"; import { useClashLog, useThemeMode } from "@/services/states";
import getSystem from "@/utils/get-system"; import getSystem from "@/utils/get-system";
import { routers } from "./_routers"; import { routers } from "./_routers";
@@ -28,19 +35,6 @@ import { routers } from "./_routers";
import "dayjs/locale/ru"; import "dayjs/locale/ru";
import "dayjs/locale/zh-cn"; import "dayjs/locale/zh-cn";
import { useListen } from "@/hooks/use-listen";
import { listen } from "@tauri-apps/api/event";
import { useClashInfo } from "@/hooks/use-clash";
import { initGlobalLogService } from "@/services/global-log-service";
import { invoke } from "@tauri-apps/api/core";
import { showNotice } from "@/services/noticeService";
import { NoticeManager } from "@/components/base/NoticeManager";
import { LogLevel } from "@/hooks/use-log-data";
const appWindow = getCurrentWebviewWindow(); const appWindow = getCurrentWebviewWindow();
export const portableFlag = false; export const portableFlag = false;
@@ -157,14 +151,20 @@ const handleNoticeMessage = (
}; };
const Layout = () => { const Layout = () => {
useTrafficData();
useMemoryData();
useConnectionData();
useLogData();
const mode = useThemeMode(); const mode = useThemeMode();
const isDark = mode === "light" ? false : true; const isDark = mode === "light" ? false : true;
const { t } = useTranslation(); const { t } = useTranslation();
const { theme } = useCustomTheme(); const { theme } = useCustomTheme();
const { verge } = useVerge(); const { verge } = useVerge();
const { clashInfo } = useClashInfo(); const { clashInfo } = useClashInfo();
const [enableLog] = useEnableLog(); const [clashLog] = useClashLog();
const [logLevel] = useLocalStorage<LogLevel>("log:log-level", "info"); const enableLog = clashLog.enable;
const logLevel = clashLog.logLevel;
// const [logLevel] = useLocalStorage<LogLevel>("log:log-level", "info");
const { language, start_page } = verge ?? {}; const { language, start_page } = verge ?? {};
const { switchLanguage } = useI18n(); const { switchLanguage } = useI18n();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -193,19 +193,17 @@ const Layout = () => {
); );
// 初始化全局日志服务 // 初始化全局日志服务
useEffect(() => { // useEffect(() => {
if (clashInfo) { // if (clashInfo) {
initGlobalLogService(enableLog, logLevel); // initGlobalLogService(enableLog, logLevel);
} // }
}, [clashInfo, enableLog, logLevel]); // }, [clashInfo, enableLog, logLevel]);
// 设置监听器 // 设置监听器
useEffect(() => { useEffect(() => {
const listeners = [ const listeners = [
addListener("verge://refresh-clash-config", async () => { addListener("verge://refresh-clash-config", async () => {
await getAxios(true); await getAxios(true);
// 后端配置变更事件触发,强制刷新配置缓存
await forceRefreshClashConfig();
mutate("getProxies"); mutate("getProxies");
mutate("getVersion"); mutate("getVersion");
mutate("getClashConfig"); mutate("getClashConfig");
@@ -521,15 +519,16 @@ const Layout = () => {
borderTopRightRadius: "0px", borderTopRightRadius: "0px",
}} }}
onContextMenu={(e) => { onContextMenu={(e) => {
if ( // TODO: 禁止右键菜单
OS === "windows" && // if (
!["input", "textarea"].includes( // OS === "windows" &&
e.currentTarget.tagName.toLowerCase(), // !["input", "textarea"].includes(
) && // e.currentTarget.tagName.toLowerCase(),
!e.currentTarget.isContentEditable // ) &&
) { // !e.currentTarget.isContentEditable
e.preventDefault(); // ) {
} // e.preventDefault();
// }
}} }}
sx={[ sx={[
({ palette }) => ({ bgcolor: palette.background.paper }), ({ palette }) => ({ bgcolor: palette.background.paper }),

View File

@@ -9,6 +9,7 @@ import { useLockFn } from "ahooks";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Virtuoso } from "react-virtuoso"; import { Virtuoso } from "react-virtuoso";
import { closeAllConnections } from "tauri-plugin-mihomo-api";
import { BaseEmpty, BasePage } from "@/components/base"; import { BaseEmpty, BasePage } from "@/components/base";
import { BaseSearchBox } from "@/components/base/base-search-box"; import { BaseSearchBox } from "@/components/base/base-search-box";
@@ -19,9 +20,8 @@ import {
} from "@/components/connection/connection-detail"; } from "@/components/connection/connection-detail";
import { ConnectionItem } from "@/components/connection/connection-item"; import { ConnectionItem } from "@/components/connection/connection-item";
import { ConnectionTable } from "@/components/connection/connection-table"; import { ConnectionTable } from "@/components/connection/connection-table";
import { useConnectionData } from "@/hooks/use-connection-data";
import { useVisibility } from "@/hooks/use-visibility"; import { useVisibility } from "@/hooks/use-visibility";
import { useAppData } from "@/providers/app-data-context";
import { closeAllConnections } from "@/services/cmds";
import { useConnectionSetting } from "@/services/states"; import { useConnectionSetting } from "@/services/states";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
@@ -39,8 +39,9 @@ const ConnectionsPage = () => {
const [match, setMatch] = useState(() => (_: string) => true); const [match, setMatch] = useState(() => (_: string) => true);
const [curOrderOpt, setOrderOpt] = useState("Default"); const [curOrderOpt, setOrderOpt] = useState("Default");
// 使用全局数据 const {
const { connections } = useAppData(); response: { data: connections },
} = useConnectionData();
const [setting, setSetting] = useConnectionSetting(); const [setting, setSetting] = useConnectionSetting();
@@ -72,30 +73,30 @@ const ConnectionsPage = () => {
if (isPaused) { if (isPaused) {
return ( return (
frozenData ?? { frozenData ?? {
uploadTotal: connections.uploadTotal, uploadTotal: connections?.uploadTotal,
downloadTotal: connections.downloadTotal, downloadTotal: connections?.downloadTotal,
connections: connections.data, connections: connections?.connections,
} }
); );
} }
return { return {
uploadTotal: connections.uploadTotal, uploadTotal: connections?.uploadTotal,
downloadTotal: connections.downloadTotal, downloadTotal: connections?.downloadTotal,
connections: connections.data, connections: connections?.connections,
}; };
}, [isPaused, frozenData, connections, pageVisible]); }, [isPaused, frozenData, connections, pageVisible]);
const [filterConn] = useMemo(() => { const [filterConn] = useMemo(() => {
const orderFunc = orderOpts[curOrderOpt]; const orderFunc = orderOpts[curOrderOpt];
let conns = displayData.connections.filter((conn) => { let conns = displayData.connections?.filter((conn) => {
const { host, destinationIP, process } = conn.metadata; const { host, destinationIP, process } = conn.metadata;
return ( return (
match(host || "") || match(destinationIP || "") || match(process || "") match(host || "") || match(destinationIP || "") || match(process || "")
); );
}); });
if (orderFunc) conns = orderFunc(conns); if (orderFunc) conns = orderFunc(conns ?? []);
return [conns]; return [conns];
}, [displayData, match, curOrderOpt, orderOpts]); }, [displayData, match, curOrderOpt, orderOpts]);
@@ -112,9 +113,9 @@ const ConnectionsPage = () => {
setIsPaused((prev) => { setIsPaused((prev) => {
if (!prev) { if (!prev) {
setFrozenData({ setFrozenData({
uploadTotal: connections.uploadTotal, uploadTotal: connections?.uploadTotal ?? 0,
downloadTotal: connections.downloadTotal, downloadTotal: connections?.downloadTotal ?? 0,
connections: connections.data, connections: connections?.connections ?? [],
}); });
} else { } else {
setFrozenData(null); setFrozenData(null);
@@ -206,7 +207,7 @@ const ConnectionsPage = () => {
<BaseSearchBox onSearch={handleSearch} /> <BaseSearchBox onSearch={handleSearch} />
</Box> </Box>
{filterConn.length === 0 ? ( {!filterConn || filterConn.length === 0 ? (
<BaseEmpty /> <BaseEmpty />
) : isTableLayout ? ( ) : isTableLayout ? (
<ConnectionTable <ConnectionTable

View File

@@ -3,7 +3,6 @@ import {
PauseCircleOutlineRounded, PauseCircleOutlineRounded,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { Box, Button, IconButton, MenuItem } from "@mui/material"; import { Box, Button, IconButton, MenuItem } from "@mui/material";
import { useLocalStorage } from "foxact/use-local-storage";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Virtuoso } from "react-virtuoso"; import { Virtuoso } from "react-virtuoso";
@@ -13,27 +12,22 @@ import { BaseSearchBox } from "@/components/base/base-search-box";
import { SearchState } from "@/components/base/base-search-box"; import { SearchState } from "@/components/base/base-search-box";
import { BaseStyledSelect } from "@/components/base/base-styled-select"; import { BaseStyledSelect } from "@/components/base/base-styled-select";
import LogItem from "@/components/log/log-item"; import LogItem from "@/components/log/log-item";
import { LogLevel } from "@/hooks/use-log-data"; import { useLogData } from "@/hooks/use-log-data-new";
import { import { toggleLogEnabled } from "@/services/global-log-service";
useGlobalLogData, import { LogFilter, useClashLog } from "@/services/states";
clearGlobalLogs,
changeLogLevel,
toggleLogEnabled,
} from "@/services/global-log-service";
import { useEnableLog } from "@/services/states";
// 后端通过 /logs?level={level} 进行筛选,前端不再需要手动筛选日志级别
const LogPage = () => { const LogPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [enableLog, setEnableLog] = useEnableLog(); const [clashLog, setClashLog] = useClashLog();
const [logLevel, setLogLevel] = useLocalStorage<LogLevel>( const enableLog = clashLog.enable;
"log:log-level", const logState = clashLog.logFilter;
"info",
);
const [match, setMatch] = useState(() => (_: string) => true); const [match, setMatch] = useState(() => (_: string) => true);
const logData = useGlobalLogData("all");
const [searchState, setSearchState] = useState<SearchState>(); const [searchState, setSearchState] = useState<SearchState>();
const {
response: { data: logData },
refreshGetClashLog,
} = useLogData();
const filterLogs = useMemo(() => { const filterLogs = useMemo(() => {
if (!logData || logData.length === 0) { if (!logData || logData.length === 0) {
@@ -49,18 +43,21 @@ const LogPage = () => {
const matchesSearch = match(searchText); const matchesSearch = match(searchText);
return matchesSearch; return (
(logState == "all" ? true : data.type.includes(logState)) &&
matchesSearch
);
}); });
}, [logData, match]); }, [logData, logState, match]);
const handleLogLevelChange = (newLevel: LogLevel) => { const handleLogLevelChange = (newLevel: string) => {
setLogLevel(newLevel); setClashLog((pre: any) => ({ ...pre, logFilter: newLevel }));
changeLogLevel(newLevel); // changeLogLevel(newLevel);
}; };
const handleToggleLog = async () => { const handleToggleLog = async () => {
await toggleLogEnabled(); await toggleLogEnabled();
setEnableLog(!enableLog); setClashLog((pre: any) => ({ ...pre, enable: !enableLog }));
}; };
return ( return (
@@ -92,7 +89,8 @@ const LogPage = () => {
size="small" size="small"
variant="contained" variant="contained"
onClick={() => { onClick={() => {
clearGlobalLogs(); refreshGetClashLog(true);
// clearGlobalLogs();
}} }}
> >
{t("Clear")} {t("Clear")}
@@ -111,14 +109,14 @@ const LogPage = () => {
}} }}
> >
<BaseStyledSelect <BaseStyledSelect
value={logLevel} value={logState}
onChange={(e) => handleLogLevelChange(e.target.value as LogLevel)} onChange={(e) => handleLogLevelChange(e.target.value as LogFilter)}
> >
<MenuItem value="all">ALL</MenuItem> <MenuItem value="all">ALL</MenuItem>
<MenuItem value="debug">DEBUG</MenuItem> <MenuItem value="debug">DEBUG</MenuItem>
<MenuItem value="info">INFO</MenuItem> <MenuItem value="info">INFO</MenuItem>
<MenuItem value="warning">WARNING</MenuItem> <MenuItem value="warn">WARN</MenuItem>
<MenuItem value="error">ERROR</MenuItem> <MenuItem value="err">ERROR</MenuItem>
</BaseStyledSelect> </BaseStyledSelect>
<BaseSearchBox <BaseSearchBox
onSearch={(matcher, state) => { onSearch={(matcher, state) => {

View File

@@ -34,6 +34,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
import { closeAllConnections } from "tauri-plugin-mihomo-api";
import { BasePage, DialogRef } from "@/components/base"; import { BasePage, DialogRef } from "@/components/base";
import { BaseStyledTextField } from "@/components/base/base-styled-text-field"; import { BaseStyledTextField } from "@/components/base/base-styled-text-field";
@@ -47,7 +48,6 @@ import { ConfigViewer } from "@/components/setting/mods/config-viewer";
import { useListen } from "@/hooks/use-listen"; import { useListen } from "@/hooks/use-listen";
import { useProfiles } from "@/hooks/use-profiles"; import { useProfiles } from "@/hooks/use-profiles";
import { import {
closeAllConnections,
createProfile, createProfile,
deleteProfile, deleteProfile,
enhanceProfiles, enhanceProfiles,

View File

@@ -3,14 +3,13 @@ import { useLockFn } from "ahooks";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR from "swr"; import useSWR from "swr";
import { closeAllConnections, getBaseConfig } from "tauri-plugin-mihomo-api";
import { BasePage } from "@/components/base"; import { BasePage } from "@/components/base";
import { ProviderButton } from "@/components/proxy/provider-button"; import { ProviderButton } from "@/components/proxy/provider-button";
import { ProxyGroups } from "@/components/proxy/proxy-groups"; import { ProxyGroups } from "@/components/proxy/proxy-groups";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { import {
closeAllConnections,
getClashConfig,
getRuntimeProxyChainConfig, getRuntimeProxyChainConfig,
patchClashMode, patchClashMode,
updateProxyChainConfigInRuntime, updateProxyChainConfigInRuntime,
@@ -33,7 +32,7 @@ const ProxyPage = () => {
const { data: clashConfig, mutate: mutateClash } = useSWR( const { data: clashConfig, mutate: mutateClash } = useSWR(
"getClashConfig", "getClashConfig",
getClashConfig, getBaseConfig,
{ {
revalidateOnFocus: false, revalidateOnFocus: false,
revalidateIfStale: true, revalidateIfStale: true,

View File

@@ -74,7 +74,7 @@ const RulesPage = () => {
<BaseSearchBox onSearch={(match) => setMatch(() => match)} /> <BaseSearchBox onSearch={(match) => setMatch(() => match)} />
</Box> </Box>
{filteredRules.length > 0 ? ( {filteredRules && filteredRules.length > 0 ? (
<> <>
<Virtuoso <Virtuoso
ref={virtuosoRef} ref={virtuosoRef}

View File

@@ -1,22 +1,28 @@
import { createContext, use } from "react"; import { createContext, use } from "react";
import {
BaseConfig,
ProxyProvider,
Rule,
RuleProvider,
} from "tauri-plugin-mihomo-api";
export interface AppDataContextType { export interface AppDataContextType {
proxies: any; proxies: any;
clashConfig: any; clashConfig: BaseConfig;
rules: any[]; rules: Rule[];
sysproxy: any; sysproxy: any;
runningMode?: string; runningMode?: string;
uptime: number; uptime: number;
proxyProviders: any; proxyProviders: Record<string, ProxyProvider>;
ruleProviders: any; ruleProviders: Record<string, RuleProvider>;
connections: { // connections: {
data: ConnectionWithSpeed[]; // data: ConnectionWithSpeed[];
count: number; // count: number;
uploadTotal: number; // uploadTotal: number;
downloadTotal: number; // downloadTotal: number;
}; // };
traffic: { up: number; down: number }; // traffic: { up: number; down: number };
memory: { inuse: number }; // memory: { inuse: number };
systemProxyAddress: string; systemProxyAddress: string;
refreshProxy: () => Promise<any>; refreshProxy: () => Promise<any>;

View File

@@ -1,30 +1,24 @@
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import React, { useCallback, useEffect, useMemo, useRef } from "react"; import React, { useCallback, useEffect, useMemo } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { useClashInfo } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge";
import { useVisibility } from "@/hooks/use-visibility";
import { import {
forceRefreshProxies, getBaseConfig,
getAppUptime,
getClashConfig,
getConnections,
getMemoryData,
getProxies,
getProxyProviders,
getRuleProviders, getRuleProviders,
getRules, getRules,
} from "tauri-plugin-mihomo-api";
// import { useClashInfo } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge";
// import { useVisibility } from "@/hooks/use-visibility";
import {
calcuProxies,
calcuProxyProviders,
getAppUptime,
getRunningMode, getRunningMode,
getSystemProxy, getSystemProxy,
getTrafficData,
} from "@/services/cmds"; } from "@/services/cmds";
import { import { AppDataContext, AppDataContextType } from "./app-data-context";
AppDataContext,
type ConnectionSpeedData,
type ConnectionWithSpeed,
} from "./app-data-context";
// 全局数据提供者组件 // 全局数据提供者组件
export const AppDataProvider = ({ export const AppDataProvider = ({
@@ -32,60 +26,60 @@ export const AppDataProvider = ({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) => { }) => {
const pageVisible = useVisibility(); // const pageVisible = useVisibility();
const { clashInfo } = useClashInfo(); // const { clashInfo } = useClashInfo();
const { verge } = useVerge(); const { verge } = useVerge();
// 存储上一次连接数据用于速度计算 // 存储上一次连接数据用于速度计算
const previousConnectionsRef = useRef<Map<string, ConnectionSpeedData>>( // const previousConnectionsRef = useRef<Map<string, ConnectionSpeedData>>(
new Map(), // new Map(),
); // );
// 计算连接速度的函数 // 计算连接速度的函数
const calculateConnectionSpeeds = ( // const calculateConnectionSpeeds = (
currentConnections: IConnectionsItem[], // currentConnections: IConnectionsItem[],
): ConnectionWithSpeed[] => { // ): ConnectionWithSpeed[] => {
const now = Date.now(); // const now = Date.now();
const currentMap = new Map<string, ConnectionSpeedData>(); // const currentMap = new Map<string, ConnectionSpeedData>();
return currentConnections.map((conn) => { // return currentConnections.map((conn) => {
const connWithSpeed: ConnectionWithSpeed = { // const connWithSpeed: ConnectionWithSpeed = {
...conn, // ...conn,
curUpload: 0, // curUpload: 0,
curDownload: 0, // curDownload: 0,
}; // };
const currentData: ConnectionSpeedData = { // const currentData: ConnectionSpeedData = {
id: conn.id, // id: conn.id,
upload: conn.upload, // upload: conn.upload,
download: conn.download, // download: conn.download,
timestamp: now, // timestamp: now,
}; // };
currentMap.set(conn.id, currentData); // currentMap.set(conn.id, currentData);
const previousData = previousConnectionsRef.current.get(conn.id); // const previousData = previousConnectionsRef.current.get(conn.id);
if (previousData) { // if (previousData) {
const timeDiff = (now - previousData.timestamp) / 1000; // 转换为秒 // const timeDiff = (now - previousData.timestamp) / 1000; // 转换为秒
if (timeDiff > 0) { // if (timeDiff > 0) {
const uploadDiff = conn.upload - previousData.upload; // const uploadDiff = conn.upload - previousData.upload;
const downloadDiff = conn.download - previousData.download; // const downloadDiff = conn.download - previousData.download;
// 计算每秒速度 (字节/秒) // // 计算每秒速度 (字节/秒)
connWithSpeed.curUpload = Math.max(0, uploadDiff / timeDiff); // connWithSpeed.curUpload = Math.max(0, uploadDiff / timeDiff);
connWithSpeed.curDownload = Math.max(0, downloadDiff / timeDiff); // connWithSpeed.curDownload = Math.max(0, downloadDiff / timeDiff);
} // }
} // }
return connWithSpeed; // return connWithSpeed;
}); // });
}; // };
// 基础数据 - 中频率更新 (5秒) // 基础数据 - 中频率更新 (5秒)
const { data: proxiesData, mutate: refreshProxy } = useSWR( const { data: proxiesData, mutate: refreshProxy } = useSWR(
"getProxies", "getProxies",
getProxies, calcuProxies,
{ {
refreshInterval: 5000, refreshInterval: 5000,
revalidateOnFocus: true, revalidateOnFocus: true,
@@ -177,19 +171,27 @@ export const AppDataProvider = ({
lastProfileId = newProfileId; lastProfileId = newProfileId;
lastUpdateTime = now; lastUpdateTime = now;
scheduleTimeout(() => { // 刷新规则数据
void forceRefreshProxies() refreshRules().catch((error) =>
.catch((error) => { console.warn("[AppDataProvider] 规则刷新失败:", error),
console.warn("[AppDataProvider] forceRefreshProxies 失败:", error); );
}) refreshRuleProviders().catch((error) =>
.finally(() => { console.warn("[AppDataProvider] 规则提供者刷新失败:", error),
scheduleTimeout(() => { );
refreshProxy().catch((error) => {
console.warn("[AppDataProvider] 普通刷新也失败:", error); // scheduleTimeout(() => {
}); // void forceRefreshProxies()
}, 200); // .catch((error) => {
}); // console.warn("[AppDataProvider] forceRefreshProxies 失败:", error);
}, 0); // })
// .finally(() => {
// scheduleTimeout(() => {
// refreshProxy().catch((error) => {
// console.warn("[AppDataProvider] 普通刷新也失败:", error);
// });
// }, 200);
// });
// }, 0);
}; };
const handleRefreshClash = () => { const handleRefreshClash = () => {
@@ -205,11 +207,11 @@ export const AppDataProvider = ({
scheduleTimeout(async () => { scheduleTimeout(async () => {
try { try {
console.log("[AppDataProvider] Clash刷新 - 强制刷新代理缓存"); console.log("[AppDataProvider] Clash刷新 - 强制刷新代理缓存");
await withTimeout( // await withTimeout(
forceRefreshProxies(), // forceRefreshProxies(),
8000, // 8000,
"forceRefreshProxies timeout", // "forceRefreshProxies timeout",
); // );
await refreshProxy(); await refreshProxy();
} catch (error) { } catch (error) {
console.error( console.error(
@@ -240,27 +242,27 @@ export const AppDataProvider = ({
}, 100); }, 100);
}; };
const handleForceRefreshProxies = () => { // const handleForceRefreshProxies = () => {
console.log("[AppDataProvider] 强制代理刷新事件"); // console.log("[AppDataProvider] 强制代理刷新事件");
void forceRefreshProxies() // void forceRefreshProxies()
.then(() => { // .then(() => {
console.log("[AppDataProvider] 强制刷新代理缓存完成"); // console.log("[AppDataProvider] 强制刷新代理缓存完成");
return refreshProxy(); // return refreshProxy();
}) // })
.then(() => { // .then(() => {
console.log("[AppDataProvider] 前端代理数据刷新完成"); // console.log("[AppDataProvider] 前端代理数据刷新完成");
}) // })
.catch((error) => { // .catch((error) => {
console.warn("[AppDataProvider] 强制代理刷新失败:", error); // console.warn("[AppDataProvider] 强制代理刷新失败:", error);
refreshProxy().catch((fallbackError) => { // refreshProxy().catch((fallbackError) => {
console.warn( // console.warn(
"[AppDataProvider] 普通代理刷新也失败:", // "[AppDataProvider] 普通代理刷新也失败:",
fallbackError, // fallbackError,
); // );
}); // });
}); // });
}; // };
const initializeListeners = async () => { const initializeListeners = async () => {
try { try {
@@ -282,15 +284,15 @@ export const AppDataProvider = ({
"verge://refresh-proxy-config", "verge://refresh-proxy-config",
handleRefreshProxy, handleRefreshProxy,
); );
const unlistenForceRefresh = await listen( // const unlistenForceRefresh = await listen(
"verge://force-refresh-proxies", // "verge://force-refresh-proxies",
handleForceRefreshProxies, // handleForceRefreshProxies,
); // );
registerCleanup(() => { registerCleanup(() => {
unlistenClash(); unlistenClash();
unlistenProxy(); unlistenProxy();
unlistenForceRefresh(); // unlistenForceRefresh();
}); });
} catch (error) { } catch (error) {
console.warn("[AppDataProvider] 设置 Tauri 事件监听器失败:", error); console.warn("[AppDataProvider] 设置 Tauri 事件监听器失败:", error);
@@ -298,7 +300,7 @@ export const AppDataProvider = ({
const fallbackHandlers: Array<[string, EventListener]> = [ const fallbackHandlers: Array<[string, EventListener]> = [
["verge://refresh-clash-config", handleRefreshClash], ["verge://refresh-clash-config", handleRefreshClash],
["verge://refresh-proxy-config", handleRefreshProxy], ["verge://refresh-proxy-config", handleRefreshProxy],
["verge://force-refresh-proxies", handleForceRefreshProxies], // ["verge://force-refresh-proxies", handleForceRefreshProxies],
]; ];
fallbackHandlers.forEach(([eventName, handler]) => { fallbackHandlers.forEach(([eventName, handler]) => {
@@ -322,7 +324,7 @@ export const AppDataProvider = ({
const { data: clashConfig, mutate: refreshClashConfig } = useSWR( const { data: clashConfig, mutate: refreshClashConfig } = useSWR(
"getClashConfig", "getClashConfig",
getClashConfig, getBaseConfig,
{ {
refreshInterval: 60000, // 60秒刷新间隔减少频繁请求 refreshInterval: 60000, // 60秒刷新间隔减少频繁请求
revalidateOnFocus: false, revalidateOnFocus: false,
@@ -334,7 +336,7 @@ export const AppDataProvider = ({
// 提供者数据 // 提供者数据
const { data: proxyProviders, mutate: refreshProxyProviders } = useSWR( const { data: proxyProviders, mutate: refreshProxyProviders } = useSWR(
"getProxyProviders", "getProxyProviders",
getProxyProviders, calcuProxyProviders,
{ {
revalidateOnFocus: false, revalidateOnFocus: false,
revalidateOnReconnect: false, revalidateOnReconnect: false,
@@ -384,86 +386,93 @@ export const AppDataProvider = ({
// 高频率更新数据 (2秒) // 高频率更新数据 (2秒)
const { data: uptimeData } = useSWR("appUptime", getAppUptime, { const { data: uptimeData } = useSWR("appUptime", getAppUptime, {
// TODO: 运行时间
refreshInterval: 2000, refreshInterval: 2000,
revalidateOnFocus: false, revalidateOnFocus: false,
suspense: false, suspense: false,
}); });
// 连接数据 - 使用IPC轮询更新并计算速度 // 连接数据 - 使用IPC轮询更新并计算速度
const { // const {
data: connectionsData = { // data: connectionsData = {
connections: [], // connections: [],
uploadTotal: 0, // uploadTotal: 0,
downloadTotal: 0, // downloadTotal: 0,
}, // },
} = useSWR( // } = useSWR(
clashInfo && pageVisible ? "getConnections" : null, // clashInfo && pageVisible ? "getConnections" : null,
async () => { // async () => {
const data = await getConnections(); // const data = await getConnections();
const rawConnections: IConnectionsItem[] = data.connections || []; // const rawConnections =
// data.connections?.map((item) => {
// // TODO: transform bigint to number
// return { ...item, upload: 0, download: 0 } as IConnectionsItem;
// }) || [];
// 计算带速度的连接数据 // // 计算带速度的连接数据
const connectionsWithSpeed = calculateConnectionSpeeds(rawConnections); // const connectionsWithSpeed = calculateConnectionSpeeds(rawConnections);
// 更新上一次数据的引用 // // 更新上一次数据的引用
const currentMap = new Map<string, ConnectionSpeedData>(); // const currentMap = new Map<string, ConnectionSpeedData>();
const now = Date.now(); // const now = Date.now();
rawConnections.forEach((conn) => { // rawConnections.forEach((conn) => {
currentMap.set(conn.id, { // currentMap.set(conn.id, {
id: conn.id, // id: conn.id,
upload: conn.upload, // upload: conn.upload,
download: conn.download, // download: conn.download,
timestamp: now, // timestamp: now,
}); // });
}); // });
previousConnectionsRef.current = currentMap; // previousConnectionsRef.current = currentMap;
return { // return {
connections: connectionsWithSpeed, // connections: connectionsWithSpeed,
uploadTotal: data.uploadTotal || 0, // uploadTotal: data.uploadTotal || 0,
downloadTotal: data.downloadTotal || 0, // downloadTotal: data.downloadTotal || 0,
}; // };
}, // },
{ // {
refreshInterval: 1000, // 1秒刷新一次 // refreshInterval: 1000, // 1秒刷新一次
fallbackData: { connections: [], uploadTotal: 0, downloadTotal: 0 }, // fallbackData: { connections: [], uploadTotal: 0, downloadTotal: 0 },
keepPreviousData: true, // keepPreviousData: true,
onError: (error) => { // onError: (error) => {
console.error("[Connections] IPC 获取数据错误:", error); // console.error("[Connections] IPC 获取数据错误:", error);
}, // },
}, // },
); // );
// 流量数据 - 使用IPC轮询更新 // 流量数据 - 使用IPC轮询更新
const { data: trafficData = { up: 0, down: 0 } } = useSWR( // const trafficData = { up: 0, down: 0 };
clashInfo && pageVisible ? "getTrafficData" : null, // const { data: trafficData = { up: 0, down: 0 } } = useSWR(
getTrafficData, // clashInfo && pageVisible ? "getTrafficData" : null,
{ // getTrafficData,
refreshInterval: 1000, // 1秒刷新一次 // {
fallbackData: { up: 0, down: 0 }, // refreshInterval: 1000, // 1秒刷新一次
keepPreviousData: true, // fallbackData: { up: 0, down: 0 },
onSuccess: () => { // keepPreviousData: true,
// console.log("[Traffic][AppDataProvider] IPC 获取到流量数据:", data); // onSuccess: () => {
}, // // console.log("[Traffic][AppDataProvider] IPC 获取到流量数据:", data);
onError: (error) => { // },
console.error("[Traffic][AppDataProvider] IPC 获取数据错误:", error); // onError: (error) => {
}, // console.error("[Traffic][AppDataProvider] IPC 获取数据错误:", error);
}, // },
); // },
// );
// 内存数据 - 使用IPC轮询更新 // 内存数据 - 使用IPC轮询更新
const { data: memoryData = { inuse: 0 } } = useSWR( // const memoryData = { inuse: 0 };
clashInfo && pageVisible ? "getMemoryData" : null, // const { data: memoryData = { inuse: 0 } } = useSWR(
getMemoryData, // clashInfo && pageVisible ? "getMemoryData" : null,
{ // getMemoryData,
refreshInterval: 2000, // 2秒刷新一次 // {
fallbackData: { inuse: 0 }, // refreshInterval: 2000, // 2秒刷新一次
keepPreviousData: true, // fallbackData: { inuse: 0 },
onError: (error) => { // keepPreviousData: true,
console.error("[Memory] IPC 获取数据错误:", error); // onError: (error) => {
}, // console.error("[Memory] IPC 获取数据错误:", error);
}, // },
); // },
// );
// 提供统一的刷新方法 // 提供统一的刷新方法
const refreshAll = useCallback(async () => { const refreshAll = useCallback(async () => {
@@ -496,7 +505,7 @@ export const AppDataProvider = ({
// PAC模式显示我们期望设置的代理地址 // PAC模式显示我们期望设置的代理地址
const proxyHost = verge.proxy_host || "127.0.0.1"; const proxyHost = verge.proxy_host || "127.0.0.1";
const proxyPort = const proxyPort =
verge.verge_mixed_port || clashConfig["mixed-port"] || 7897; verge.verge_mixed_port || clashConfig.mixedPort || 7897;
return `${proxyHost}:${proxyPort}`; return `${proxyHost}:${proxyPort}`;
} else { } else {
// HTTP代理模式优先使用系统地址但如果格式不正确则使用期望地址 // HTTP代理模式优先使用系统地址但如果格式不正确则使用期望地址
@@ -511,7 +520,7 @@ export const AppDataProvider = ({
// 系统地址无效,返回期望的代理地址 // 系统地址无效,返回期望的代理地址
const proxyHost = verge.proxy_host || "127.0.0.1"; const proxyHost = verge.proxy_host || "127.0.0.1";
const proxyPort = const proxyPort =
verge.verge_mixed_port || clashConfig["mixed-port"] || 7897; verge.verge_mixed_port || clashConfig.mixedPort || 7897;
return `${proxyHost}:${proxyPort}`; return `${proxyHost}:${proxyPort}`;
} }
} }
@@ -521,26 +530,26 @@ export const AppDataProvider = ({
// 数据 // 数据
proxies: proxiesData, proxies: proxiesData,
clashConfig, clashConfig,
rules: rulesData || [], rules: rulesData?.rules || [],
sysproxy, sysproxy,
runningMode, runningMode,
uptime: uptimeData || 0, uptime: uptimeData || 0,
// 提供者数据 // 提供者数据
proxyProviders: proxyProviders || {}, proxyProviders: proxyProviders || {},
ruleProviders: ruleProviders || {}, ruleProviders: ruleProviders?.providers || {},
// 连接数据 // 连接数据
connections: { // connections: {
data: connectionsData.connections || [], // data: connectionsData.connections || [],
count: connectionsData.connections?.length || 0, // count: connectionsData.connections?.length || 0,
uploadTotal: connectionsData.uploadTotal || 0, // uploadTotal: connectionsData.uploadTotal || 0,
downloadTotal: connectionsData.downloadTotal || 0, // downloadTotal: connectionsData.downloadTotal || 0,
}, // },
// 实时流量数据 // 实时流量数据
traffic: trafficData, // traffic: trafficData,
memory: memoryData, // memory: memoryData,
systemProxyAddress: calculateSystemProxyAddress(), systemProxyAddress: calculateSystemProxyAddress(),
@@ -552,7 +561,7 @@ export const AppDataProvider = ({
refreshProxyProviders, refreshProxyProviders,
refreshRuleProviders, refreshRuleProviders,
refreshAll, refreshAll,
}; } as AppDataContextType;
}, [ }, [
proxiesData, proxiesData,
clashConfig, clashConfig,
@@ -560,9 +569,9 @@ export const AppDataProvider = ({
sysproxy, sysproxy,
runningMode, runningMode,
uptimeData, uptimeData,
connectionsData, // connectionsData,
trafficData, // trafficData,
memoryData, // memoryData,
proxyProviders, proxyProviders,
ruleProviders, ruleProviders,
verge, verge,

View File

@@ -1,4 +1,6 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import dayjs from "dayjs";
import { getProxies, getProxyProviders } from "tauri-plugin-mihomo-api";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
@@ -107,64 +109,11 @@ export async function patchClashMode(payload: string) {
return invoke<void>("patch_clash_mode", { payload }); return invoke<void>("patch_clash_mode", { payload });
} }
// New IPC-based API functions to replace HTTP API calls
export async function getVersion() {
return invoke<{
premium: boolean;
meta?: boolean;
version: string;
}>("get_clash_version");
}
export async function getClashConfig() {
return invoke<IConfigData>("get_clash_config");
}
export async function forceRefreshClashConfig() {
return invoke<IConfigData>("force_refresh_clash_config");
}
export async function updateGeoData() {
return invoke<void>("update_geo_data");
}
export async function upgradeCore() {
return invoke<void>("upgrade_clash_core");
}
export async function getRules() {
const response = await invoke<{ rules: IRuleItem[] }>("get_clash_rules");
return response?.rules || [];
}
export async function getProxyDelay(
name: string,
url?: string,
timeout?: number,
) {
return invoke<{ delay: number }>("clash_api_get_proxy_delay", {
name,
url,
timeout: timeout || 10000,
});
}
export async function updateProxy(group: string, proxy: string) {
// const start = Date.now();
await invoke<void>("update_proxy_choice", { group, proxy });
// const duration = Date.now() - start;
// console.log(`[API] updateProxy 耗时: ${duration}ms`);
}
export async function syncTrayProxySelection() { export async function syncTrayProxySelection() {
return invoke<void>("sync_tray_proxy_selection"); return invoke<void>("sync_tray_proxy_selection");
} }
export async function updateProxyAndSync(group: string, proxy: string) { export async function calcuProxies(): Promise<{
return invoke<void>("update_proxy_and_sync", { group, proxy });
}
export async function getProxies(): Promise<{
global: IProxyGroupItem; global: IProxyGroupItem;
direct: IProxyItem; direct: IProxyItem;
groups: IProxyGroupItem[]; groups: IProxyGroupItem[];
@@ -172,19 +121,17 @@ export async function getProxies(): Promise<{
proxies: IProxyItem[]; proxies: IProxyItem[];
}> { }> {
const [proxyResponse, providerResponse] = await Promise.all([ const [proxyResponse, providerResponse] = await Promise.all([
invoke<{ proxies: Record<string, IProxyItem> }>("get_proxies"), getProxies(),
invoke<{ providers: Record<string, IProxyProviderItem> }>( calcuProxyProviders(),
"get_providers_proxies",
),
]); ]);
const proxyRecord = proxyResponse.proxies; const proxyRecord = proxyResponse.proxies;
const providerRecord = providerResponse.providers || {}; const providerRecord = providerResponse;
// provider name map // provider name map
const providerMap = Object.fromEntries( const providerMap = Object.fromEntries(
Object.entries(providerRecord).flatMap(([provider, item]) => Object.entries(providerRecord).flatMap(([provider, item]) =>
item.proxies.map((p) => [p.name, { ...p, provider }]), item!.proxies.map((p) => [p.name, { ...p, provider }]),
), ),
); );
@@ -209,7 +156,7 @@ export async function getProxies(): Promise<{
let groups: IProxyGroupItem[] = Object.values(proxyRecord).reduce< let groups: IProxyGroupItem[] = Object.values(proxyRecord).reduce<
IProxyGroupItem[] IProxyGroupItem[]
>((acc, each) => { >((acc, each) => {
if (each.name !== "GLOBAL" && each.all) { if (each?.name !== "GLOBAL" && each?.all) {
acc.push({ acc.push({
...each, ...each,
all: each.all!.map((item) => generateItem(item)), all: each.all!.map((item) => generateItem(item)),
@@ -242,209 +189,57 @@ export async function getProxies(): Promise<{
const proxies = [direct, reject].concat( const proxies = [direct, reject].concat(
Object.values(proxyRecord).filter( Object.values(proxyRecord).filter(
(p) => !p.all?.length && p.name !== "DIRECT" && p.name !== "REJECT", (p) => !p?.all?.length && p?.name !== "DIRECT" && p?.name !== "REJECT",
), ),
); );
const _global: IProxyGroupItem = { const _global = {
...global, ...global,
all: global?.all?.map((item) => generateItem(item)) || [], all: global?.all?.map((item) => generateItem(item)) || [],
}; };
return { global: _global, direct, groups, records: proxyRecord, proxies }; return {
global: _global as IProxyGroupItem,
direct: direct as IProxyItem,
groups,
records: proxyRecord as Record<string, IProxyItem>,
proxies: (proxies as IProxyItem[]) ?? [],
};
} }
export async function getProxyProviders() { export async function calcuProxyProviders() {
const response = await invoke<{ const providers = await getProxyProviders();
providers: Record<string, IProxyProviderItem>;
}>("get_providers_proxies");
if (!response || !response.providers) {
console.warn(
"getProxyProviders: Invalid response structure, returning empty object",
);
return {};
}
const providers = response.providers as Record<string, IProxyProviderItem>;
return Object.fromEntries( return Object.fromEntries(
Object.entries(providers).filter(([, item]) => { Object.entries(providers.providers)
const type = item.vehicleType.toLowerCase(); .sort()
return type === "http" || type === "file"; .filter(
}), ([_, item]) =>
item?.vehicleType === "HTTP" || item?.vehicleType === "File",
),
); );
} }
export async function getRuleProviders() {
const response = await invoke<{
providers: Record<string, IRuleProviderItem>;
}>("get_rule_providers");
const providers = (response.providers || {}) as Record<
string,
IRuleProviderItem
>;
return Object.fromEntries(
Object.entries(providers).filter(([, item]) => {
const type = item.vehicleType.toLowerCase();
return type === "http" || type === "file";
}),
);
}
export async function providerHealthCheck(name: string) {
return invoke<void>("proxy_provider_health_check", { name });
}
export async function proxyProviderUpdate(name: string) {
return invoke<void>("update_proxy_provider", { name });
}
export async function ruleProviderUpdate(name: string) {
return invoke<void>("update_rule_provider", { name });
}
export async function getConnections() {
return invoke<IConnections>("get_clash_connections");
}
export async function deleteConnection(id: string) {
return invoke<void>("delete_clash_connection", { id });
}
export async function closeAllConnections() {
return invoke<void>("close_all_clash_connections");
}
export async function getGroupProxyDelays(
groupName: string,
url?: string,
timeout?: number,
) {
return invoke<Record<string, number>>("get_group_proxy_delays", {
groupName,
url,
timeout,
});
}
export async function getTrafficData() {
// console.log("[Traffic][Service] 开始调用 get_traffic_data");
const result = await invoke<ITrafficItem>("get_traffic_data");
// console.log("[Traffic][Service] get_traffic_data 返回结果:", result);
return result;
}
export async function getMemoryData() {
console.log("[Memory][Service] 开始调用 get_memory_data");
const result = await invoke<{
inuse: number;
oslimit?: number;
usage_percent?: number;
last_updated?: number;
}>("get_memory_data");
// console.debug("[Memory][Service] get_memory_data 返回结果:", result);
return result;
}
export async function getFormattedTrafficData() {
console.log("[Traffic][Service] 开始调用 get_formatted_traffic_data");
const result = await invoke<IFormattedTrafficData>(
"get_formatted_traffic_data",
);
// console.debug(
// "[Traffic][Service] get_formatted_traffic_data 返回结果:",
// result,
// );
return result;
}
export async function getFormattedMemoryData() {
console.log("[Memory][Service] 开始调用 get_formatted_memory_data");
const result = await invoke<IFormattedMemoryData>(
"get_formatted_memory_data",
);
// console.debug("[Memory][Service] get_formatted_memory_data 返回结果:", result);
return result;
}
export async function getSystemMonitorOverview() {
console.log("[Monitor][Service] 开始调用 get_system_monitor_overview");
const result = await invoke<ISystemMonitorOverview>(
"get_system_monitor_overview",
);
// console.debug(
// "[Monitor][Service] get_system_monitor_overview 返回结果:",
// result,
// );
return result;
}
// 带数据验证的安全版本
export async function getSystemMonitorOverviewSafe() {
// console.log(
// "[Monitor][Service] 开始调用安全版本 get_system_monitor_overview",
// );
try {
const result = await invoke<any>("get_system_monitor_overview");
// console.log("[Monitor][Service] 原始数据:", result);
// 导入验证器(动态导入避免循环依赖)
const { systemMonitorValidator } = await import("@/utils/data-validator");
if (systemMonitorValidator.validate(result)) {
// console.log("[Monitor][Service] 数据验证通过");
return result as ISystemMonitorOverview;
} else {
// console.warn("[Monitor][Service] 数据验证失败,使用清理后的数据");
return systemMonitorValidator.sanitize(result);
}
} catch {
// console.error("[Monitor][Service] API调用失败:", error);
// 返回安全的默认值
const { systemMonitorValidator } = await import("@/utils/data-validator");
return systemMonitorValidator.sanitize(null);
}
}
export async function startTrafficService() {
console.log("[Traffic][Service] 开始调用 start_traffic_service");
try {
const result = await invoke<void>("start_traffic_service");
console.log("[Traffic][Service] start_traffic_service 调用成功");
return result;
} catch (error) {
console.error("[Traffic][Service] start_traffic_service 调用失败:", error);
throw error;
}
}
export async function stopTrafficService() {
console.log("[Traffic][Service] 开始调用 stop_traffic_service");
const result = await invoke<void>("stop_traffic_service");
console.log("[Traffic][Service] stop_traffic_service 调用成功");
return result;
}
export async function isDebugEnabled() {
return invoke<boolean>("is_clash_debug_enabled");
}
export async function gc() {
return invoke<void>("clash_gc");
}
export async function getClashLogs() { export async function getClashLogs() {
return invoke<any>("get_clash_logs"); const regex = /time="(.+?)"\s+level=(.+?)\s+msg="(.+?)"/;
} const newRegex = /(.+?)\s+(.+?)\s+(.+)/;
const logs = await invoke<string[]>("get_clash_logs");
export async function startLogsMonitoring(level?: string) { return logs.reduce<ILogItem[]>((acc, log) => {
return invoke<void>("start_logs_monitoring", { level }); const result = log.match(regex);
} if (result) {
const [_, _time, type, payload] = result;
const time = dayjs(_time).format("MM-DD HH:mm:ss");
acc.push({ time, type, payload });
return acc;
}
export async function stopLogsMonitoring() { const result2 = log.match(newRegex);
return invoke<void>("stop_logs_monitoring"); if (result2) {
const [_, time, type, payload] = result2;
acc.push({ time, type, payload });
}
return acc;
}, []);
} }
export async function clearLogs() { export async function clearLogs() {
@@ -576,16 +371,6 @@ export async function cmdGetProxyDelay(
} }
} }
/// 用于profile切换等场景
export async function forceRefreshProxies() {
const start = Date.now();
console.log("[API] 强制刷新代理缓存");
const result = await invoke<any>("force_refresh_proxies");
const duration = Date.now() - start;
console.log(`[API] 代理缓存刷新完成,耗时: ${duration}ms`);
return result;
}
export async function cmdTestDelay(url: string) { export async function cmdTestDelay(url: string) {
return invoke<number>("test_delay", { url }); return invoke<number>("test_delay", { url });
} }

View File

@@ -1,4 +1,4 @@
import { cmdGetProxyDelay } from "@/services/cmds"; import { delayProxyByName } from "tauri-plugin-mihomo-api";
const hashKey = (name: string, group: string) => `${group ?? ""}::${name}`; const hashKey = (name: string, group: string) => `${group ?? ""}::${name}`;
@@ -106,7 +106,7 @@ class DelayManager {
// 使用Promise.race来实现超时控制 // 使用Promise.race来实现超时控制
const result = await Promise.race([ const result = await Promise.race([
cmdGetProxyDelay(name, timeout, url), delayProxyByName(name, url, timeout),
timeoutPromise, timeoutPromise,
]); ]);
@@ -210,13 +210,14 @@ class DelayManager {
formatDelay(delay: number, timeout = 10000) { formatDelay(delay: number, timeout = 10000) {
if (delay === -1) return "-"; if (delay === -1) return "-";
if (delay === -2) return "testing"; if (delay === -2) return "testing";
if (delay >= timeout) return "timeout"; if (delay === 0 || (delay >= timeout && delay <= 1e5)) return "timeout";
if (delay > 1e5) return "Error";
return `${delay}`; return `${delay}`;
} }
formatDelayColor(delay: number, timeout = 10000) { formatDelayColor(delay: number, timeout = 10000) {
if (delay < 0) return ""; if (delay < 0) return "";
if (delay >= timeout) return "error.main"; if (delay === 0 || delay >= timeout) return "error.main";
if (delay >= 10000) return "error.main"; if (delay >= 10000) return "error.main";
if (delay >= 400) return "warning.main"; if (delay >= 400) return "warning.main";
if (delay >= 250) return "primary.main"; if (delay >= 250) return "primary.main";

View File

@@ -3,7 +3,6 @@ import { create } from "zustand";
import { import {
fetchLogsViaIPC, fetchLogsViaIPC,
startLogsStreaming,
stopLogsStreaming, stopLogsStreaming,
clearLogs as clearLogsIPC, clearLogs as clearLogsIPC,
} from "@/services/ipc-log-service"; } from "@/services/ipc-log-service";
@@ -96,7 +95,7 @@ export const initGlobalLogService = (
console.log("[GlobalLog-IPC] 启用IPC流式日志服务"); console.log("[GlobalLog-IPC] 启用IPC流式日志服务");
// 启动流式监控 // 启动流式监控
startLogsStreaming(logLevel); // startLogsStreaming(logLevel);
// 立即获取一次日志 // 立即获取一次日志
fetchLogsViaIPCPeriodically(); fetchLogsViaIPCPeriodically();
@@ -152,7 +151,7 @@ export const changeLogLevel = (level: LogLevel) => {
if (enabled) { if (enabled) {
// IPC流式模式下重新启动监控 // IPC流式模式下重新启动监控
startLogsStreaming(level); // startLogsStreaming(level);
fetchLogsViaIPCPeriodically(); fetchLogsViaIPCPeriodically();
} }
}; };
@@ -180,11 +179,11 @@ export const clearGlobalLogs = () => {
}; };
// 自定义钩子,用于获取过滤后的日志数据 // 自定义钩子,用于获取过滤后的日志数据
export const useGlobalLogData = (logLevel: LogLevel = "all") => { export const useGlobalLogData = (logLevel: LogLevel = "info") => {
const logs = useGlobalLogStore((state) => state.logs); const logs = useGlobalLogStore((state) => state.logs);
// 根据当前选择的日志等级过滤日志 // 根据当前选择的日志等级过滤日志
if (logLevel === "all") { if (logLevel === "info") {
return logs; return logs;
} else { } else {
return logs.filter((log) => log.type.toLowerCase() === logLevel); return logs.filter((log) => log.type.toLowerCase() === logLevel);

View File

@@ -1,12 +1,6 @@
// IPC-based log service using Tauri commands with streaming support // IPC-based log service using Tauri commands with streaming support
import dayjs from "dayjs";
import { import { clearLogs as clearLogsCmd } from "@/services/cmds";
getClashLogs,
startLogsMonitoring,
stopLogsMonitoring,
clearLogs as clearLogsCmd,
} from "@/services/cmds";
type LogLevel = "debug" | "info" | "warning" | "error" | "all"; type LogLevel = "debug" | "info" | "warning" | "error" | "all";
@@ -21,7 +15,7 @@ interface ILogItem {
export const startLogsStreaming = async (logLevel: LogLevel = "info") => { export const startLogsStreaming = async (logLevel: LogLevel = "info") => {
try { try {
const level = logLevel === "all" ? undefined : logLevel; const level = logLevel === "all" ? undefined : logLevel;
await startLogsMonitoring(level); // await startLogsMonitoring(level);
console.log( console.log(
`[IPC-LogService] Started logs monitoring with level: ${logLevel}`, `[IPC-LogService] Started logs monitoring with level: ${logLevel}`,
); );
@@ -33,7 +27,7 @@ export const startLogsStreaming = async (logLevel: LogLevel = "info") => {
// Stop logs monitoring // Stop logs monitoring
export const stopLogsStreaming = async () => { export const stopLogsStreaming = async () => {
try { try {
await stopLogsMonitoring(); // await stopLogsMonitoring();
console.log("[IPC-LogService] Stopped logs monitoring"); console.log("[IPC-LogService] Stopped logs monitoring");
} catch (error) { } catch (error) {
console.error("[IPC-LogService] Failed to stop logs monitoring:", error); console.error("[IPC-LogService] Failed to stop logs monitoring:", error);
@@ -45,16 +39,16 @@ export const fetchLogsViaIPC = async (): Promise<ILogItem[]> => {
try { try {
// Server-side filtering handles the level via /logs?level={level} // Server-side filtering handles the level via /logs?level={level}
// We just fetch all cached logs regardless of the logLevel parameter // We just fetch all cached logs regardless of the logLevel parameter
const response = await getClashLogs(); // const response = await getClashLogs();
// The response should be in the format expected by the frontend // // The response should be in the format expected by the frontend
// Transform the logs to match the expected format // // Transform the logs to match the expected format
if (Array.isArray(response)) { // if (Array.isArray(response)) {
return response.map((log: any) => ({ // return response.map((log: any) => ({
...log, // ...log,
time: log.time || dayjs().format("HH:mm:ss"), // time: log.time || dayjs().format("HH:mm:ss"),
})); // }));
} // }
return []; return [];
} catch (error) { } catch (error) {

View File

@@ -1,11 +1,30 @@
import { createContextState } from "foxact/create-context-state"; import { createContextState } from "foxact/create-context-state";
import { useLocalStorage } from "foxact/use-local-storage"; import { useLocalStorage } from "foxact/use-local-storage";
import { LogLevel } from "tauri-plugin-mihomo-api";
const [ThemeModeProvider, useThemeMode, useSetThemeMode] = createContextState< const [ThemeModeProvider, useThemeMode, useSetThemeMode] = createContextState<
"light" | "dark" "light" | "dark"
>("light"); >("light");
export const useEnableLog = () => useLocalStorage("enable-log", false); export type LogFilter = "all" | "debug" | "info" | "warn" | "err";
interface IClashLog {
enable: boolean;
logLevel: LogLevel;
logFilter: LogFilter;
}
const defaultClashLog: IClashLog = {
enable: true,
logLevel: "info",
logFilter: "all",
};
export const useClashLog = () =>
useLocalStorage<IClashLog>("clash-log", defaultClashLog, {
serializer: JSON.stringify,
deserializer: JSON.parse,
});
// export const useEnableLog = () => useLocalStorage("enable-log", false);
interface IConnectionSetting { interface IConnectionSetting {
layout: "table" | "list"; layout: "table" | "list";