From 3b694650165a486f16cc7edb4d73fa82c32bfaa9 Mon Sep 17 00:00:00 2001 From: Tunglies Date: Mon, 3 Mar 2025 19:31:44 +0800 Subject: [PATCH] feat: add Mihomo API modules and manager (#2869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Introduce new API caller implementations for Mihomo in model and module layers. • Add configuration and API integration files under /src-tauri/src/config/api and /src-tauri/src/model/api. • Implement a singleton MihomoAPICaller with async API call support and integration tests. • Create a new MihomoManager module to refresh and fetch proxies from the API. • Update Cargo.lock and Cargo.toml with additional dependencies (async-trait, env_logger, mockito, tempfile, etc.) related to the Mihomo API support. --- src-tauri/Cargo.lock | 134 +++++++++++++++++++++++++- src-tauri/Cargo.toml | 6 ++ src-tauri/src/config/api/mihomo.rs | 1 + src-tauri/src/config/api/mod.rs | 1 + src-tauri/src/config/mod.rs | 3 + src-tauri/src/enhance/seq.rs | 1 + src-tauri/src/model/api/common.rs | 20 ++++ src-tauri/src/model/api/mihomo.rs | 5 + src-tauri/src/model/api/mod.rs | 2 + src-tauri/src/model/mod.rs | 3 +- src-tauri/src/module/api/common.rs | 70 ++++++++++++++ src-tauri/src/module/api/mihomo.rs | 108 +++++++++++++++++++++ src-tauri/src/module/api/mod.rs | 2 + src-tauri/src/module/mihomo.rs | 149 +++++++++++++++++++++++++++++ src-tauri/src/module/mod.rs | 4 +- 15 files changed, 505 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/config/api/mihomo.rs create mode 100644 src-tauri/src/config/api/mod.rs create mode 100644 src-tauri/src/model/api/common.rs create mode 100644 src-tauri/src/model/api/mihomo.rs create mode 100644 src-tauri/src/model/api/mod.rs create mode 100644 src-tauri/src/module/api/common.rs create mode 100644 src-tauri/src/module/api/mihomo.rs create mode 100644 src-tauri/src/module/api/mod.rs create mode 100644 src-tauri/src/module/mihomo.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c03ed2e6..c3e605b8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -116,6 +116,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "anyhow" version = "1.0.95" @@ -191,6 +241,16 @@ dependencies = [ "zbus", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -1003,6 +1063,7 @@ version = "2.1.2" dependencies = [ "aes-gcm", "anyhow", + "async-trait", "base64 0.22.1", "boa_engine", "chrono", @@ -1010,12 +1071,14 @@ dependencies = [ "delay_timer", "dirs 6.0.0", "dunce", + "env_logger", "futures", "getrandom 0.2.15", "image 0.24.9", "imageproc", "log", "log4rs", + "mockito", "nanoid", "network-interface", "once_cell", @@ -1047,6 +1110,7 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-updater", "tauri-plugin-window-state", + "tempfile", "tokio", "tokio-tungstenite 0.26.1", "url", @@ -1132,6 +1196,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + [[package]] name = "colored" version = "2.2.0" @@ -1927,6 +1997,29 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcaee3d8e3cfc3fd92428d477bc97fc29ec8716d180c0d74c643bb26166660e0" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -2924,6 +3017,7 @@ dependencies = [ "http 1.2.0", "http-body 1.0.1", "httparse", + "httpdate", "itoa 1.0.14", "pin-project-lite", "smallvec", @@ -3344,6 +3438,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itertools" version = "0.10.5" @@ -3845,6 +3945,30 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "mockito" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "652cd6d169a36eaf9d1e6bce1a221130439a966d7f27858af66a33a66e9c4ee2" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-util", + "http 1.2.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "log", + "rand 0.8.5", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + [[package]] name = "muda" version = "0.15.3" @@ -6161,6 +6285,12 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "0.3.11" @@ -7002,9 +7132,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.16.0" +version = "3.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c246215d7d24f48ae091a2902398798e05d978b24315d6efbc00ede9a8bb91" +checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230" dependencies = [ "cfg-if", "fastrand 2.3.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8d5de16e..24808a49 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -67,6 +67,7 @@ getrandom = "0.2" tokio-tungstenite = "0.26.1" futures = "0.3" sys-locale = "0.3.1" +async-trait = "0.1.86" [target.'cfg(windows)'.dependencies] runas = "=1.2.0" @@ -120,3 +121,8 @@ strip = false # 不剥离符号,保留调试信息 [lib] name = "app_lib" crate-type = ["staticlib", "cdylib", "rlib"] + +[dev-dependencies] +env_logger = "0.11.0" +mockito = "1.2.0" +tempfile = "3.17.1" diff --git a/src-tauri/src/config/api/mihomo.rs b/src-tauri/src/config/api/mihomo.rs new file mode 100644 index 00000000..46873b96 --- /dev/null +++ b/src-tauri/src/config/api/mihomo.rs @@ -0,0 +1 @@ +pub const MIHOMO_URL: &str = concat!("http://", "127.0.0.1", ":", "9097"); diff --git a/src-tauri/src/config/api/mod.rs b/src-tauri/src/config/api/mod.rs new file mode 100644 index 00000000..0f3c8057 --- /dev/null +++ b/src-tauri/src/config/api/mod.rs @@ -0,0 +1 @@ +pub mod mihomo; diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index 69b1b123..a3ddace0 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -21,3 +21,6 @@ pub const DEFAULT_PAC: &str = r#"function FindProxyForURL(url, host) { return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;"; } "#; + + +pub mod api; \ No newline at end of file diff --git a/src-tauri/src/enhance/seq.rs b/src-tauri/src/enhance/seq.rs index 396b1969..82672c93 100644 --- a/src-tauri/src/enhance/seq.rs +++ b/src-tauri/src/enhance/seq.rs @@ -85,6 +85,7 @@ pub fn use_seq(seq: SeqMap, mut config: Mapping, field: &str) -> Mapping { #[cfg(test)] mod tests { use super::*; + #[allow(unused_imports)] use serde_yaml::Value; #[test] diff --git a/src-tauri/src/model/api/common.rs b/src-tauri/src/model/api/common.rs new file mode 100644 index 00000000..cd83945c --- /dev/null +++ b/src-tauri/src/model/api/common.rs @@ -0,0 +1,20 @@ +use reqwest::Client; + +#[allow(unused)] +pub(crate) struct ApiCaller<'a> { + pub(crate) url: &'a str, + pub(crate) client: Client, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_api_caller() { + let _api_caller = ApiCaller { + url: "https://example.com", + client: Client::new(), + }; + } +} diff --git a/src-tauri/src/model/api/mihomo.rs b/src-tauri/src/model/api/mihomo.rs new file mode 100644 index 00000000..ad14be62 --- /dev/null +++ b/src-tauri/src/model/api/mihomo.rs @@ -0,0 +1,5 @@ +use super::common::ApiCaller; + +pub struct MihomoAPICaller { + pub(crate) caller: ApiCaller<'static>, +} diff --git a/src-tauri/src/model/api/mod.rs b/src-tauri/src/model/api/mod.rs new file mode 100644 index 00000000..bee8fa98 --- /dev/null +++ b/src-tauri/src/model/api/mod.rs @@ -0,0 +1,2 @@ +pub mod common; +pub mod mihomo; diff --git a/src-tauri/src/model/mod.rs b/src-tauri/src/model/mod.rs index f146b310..e11af71e 100644 --- a/src-tauri/src/model/mod.rs +++ b/src-tauri/src/model/mod.rs @@ -1 +1,2 @@ -pub mod sysinfo; \ No newline at end of file +pub mod api; +pub mod sysinfo; diff --git a/src-tauri/src/module/api/common.rs b/src-tauri/src/module/api/common.rs new file mode 100644 index 00000000..dab4cc69 --- /dev/null +++ b/src-tauri/src/module/api/common.rs @@ -0,0 +1,70 @@ +use crate::model::api::common::ApiCaller; +use async_trait::async_trait; +use reqwest::{ + header::{HeaderMap, HeaderName, HeaderValue}, + RequestBuilder, +}; +use serde::de::DeserializeOwned; + +impl<'a> ApiCaller<'a> { + pub async fn send_request( + &self, + method: &str, + path: &str, + body: Option<&str>, + headers: Option>, + ) -> Result { + let full_url = format!("{}{}", self.url, path); // 拼接完整 URL + let mut request: RequestBuilder = match method { + "GET" => self.client.get(&full_url), + "POST" => self + .client + .post(&full_url) + .body(body.unwrap_or("").to_string()), + "PUT" => self + .client + .put(&full_url) + .body(body.unwrap_or("").to_string()), + "DELETE" => self.client.delete(&full_url), + _ => return Err("Unsupported HTTP method".to_string()), + }; + + // 处理 headers + if let Some(hdrs) = headers { + let mut header_map = HeaderMap::new(); + for (key, value) in hdrs { + if let (Ok(header_name), Ok(header_value)) = ( + HeaderName::from_bytes(key.as_bytes()), + HeaderValue::from_str(value), + ) { + header_map.insert(header_name, header_value); + } + } + request = request.headers(header_map); + } + + let response = request.send().await.map_err(|e| e.to_string())?; + response.text().await.map_err(|e| e.to_string()) + } +} + +#[allow(unused)] +#[async_trait] +pub trait ApiCallerTrait: Send + Sync { + async fn call_api( + &self, + method: &str, + path: &str, + body: Option<&str>, + headers: Option> + ) -> Result + where + T: DeserializeOwned + Send + Sync; + + fn parse_json_response(json_str: &str) -> Result + where + T: DeserializeOwned, + { + serde_json::from_str(json_str).map_err(|e| e.to_string()) + } +} diff --git a/src-tauri/src/module/api/mihomo.rs b/src-tauri/src/module/api/mihomo.rs new file mode 100644 index 00000000..09b6d0fa --- /dev/null +++ b/src-tauri/src/module/api/mihomo.rs @@ -0,0 +1,108 @@ +use super::common::ApiCallerTrait; +use crate::config::api::mihomo::MIHOMO_URL; +use crate::model::api::common::ApiCaller; +use crate::model::api::mihomo::MihomoAPICaller; + +use async_trait::async_trait; +use once_cell::sync::OnceCell; +use parking_lot::RwLock; +use reqwest::Client; +use serde::de::DeserializeOwned; +use std::sync::Arc; + +impl MihomoAPICaller { + #[allow(dead_code)] + pub fn new() -> Arc> { + static INSTANCE: OnceCell>> = OnceCell::new(); + INSTANCE + .get_or_init(|| { + let client = Client::new(); + Arc::new(RwLock::new(MihomoAPICaller { + caller: ApiCaller { + url: MIHOMO_URL, + client, + }, + })) + }) + .clone() + } +} + +#[async_trait] +impl ApiCallerTrait for MihomoAPICaller { + async fn call_api( + &self, + method: &str, + path: &str, + body: Option<&str>, + headers: Option>, + ) -> Result + where + T: DeserializeOwned + Send + Sync, + { + let response = self + .caller + .send_request(method, path, body, headers) + .await + .map_err(|e| e.to_string())?; + Self::parse_json_response::(&response) + } +} + +#[allow(unused)] +impl MihomoAPICaller { + pub async fn get_proxies() -> Result { + Self::new() + .read() + .call_api("GET", "/proxies", None, None) + .await + } + + pub async fn get_providers_proxies() -> Result { + Self::new() + .read() + .call_api("GET", "/providers/proxies", None, None) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_mihomo_api_singleton() { + let mihomo_api_caller1 = MihomoAPICaller::new(); + let mihomo_api_caller2 = MihomoAPICaller::new(); + assert!(Arc::ptr_eq(&mihomo_api_caller1, &mihomo_api_caller2)); + } + + #[tokio::test] + async fn test_mihomo_api_version() { + let mihomo_caller = MihomoAPICaller::new(); + let response: Result = mihomo_caller + .read() + .call_api("GET", "/version", None, None) + .await; + assert!(response.is_ok()); + } + + #[tokio::test] + async fn test_mihomo_get_proxies() { + let response = MihomoAPICaller::get_proxies().await; + assert!(response.is_ok()); + if let Ok(proxies) = &response { + assert!(!proxies.get("proxies").is_none()); + } + } + + #[tokio::test] + async fn test_mihomo_get_providers_proxies() { + let response = MihomoAPICaller::get_providers_proxies().await; + println!("{:?}", response); + assert!(response.is_ok()); + if let Ok(providers_proxies) = &response { + assert!(!providers_proxies.get("providers").is_none()); + } + } +} diff --git a/src-tauri/src/module/api/mod.rs b/src-tauri/src/module/api/mod.rs new file mode 100644 index 00000000..bee8fa98 --- /dev/null +++ b/src-tauri/src/module/api/mod.rs @@ -0,0 +1,2 @@ +pub mod common; +pub mod mihomo; diff --git a/src-tauri/src/module/mihomo.rs b/src-tauri/src/module/mihomo.rs new file mode 100644 index 00000000..b16f2d95 --- /dev/null +++ b/src-tauri/src/module/mihomo.rs @@ -0,0 +1,149 @@ +use std::sync::Arc; +use once_cell::sync::OnceCell; +use parking_lot::RwLock; +use crate::model::api::mihomo::MihomoAPICaller; + + +#[allow(unused)] +pub struct MihomoManager { + proxies: serde_json::Value, + providers_proxies: serde_json::Value, +} + +#[allow(unused)] +impl MihomoManager { + pub fn new() -> Arc> { + static INSTANCE: OnceCell>> = OnceCell::new(); + INSTANCE + .get_or_init(|| { + Arc::new(RwLock::new(MihomoManager { + proxies: serde_json::Value::Null, + providers_proxies: serde_json::Value::Null, + })) + }) + .clone() + } + + pub fn fetch_proxies(&self) -> &serde_json::Value { + &self.proxies + } + + pub fn fetch_providers_proxies(&self) -> &serde_json::Value { + &self.providers_proxies + } + + pub async fn refresh_proxies(&mut self) { + match MihomoAPICaller::get_proxies().await { + Ok(proxies) => self.proxies = proxies, + Err(e) => log::error!("Failed to get proxies: {}", e), + } + } + + pub async fn refresh_providers_proxies(&mut self) { + match MihomoAPICaller::get_providers_proxies().await { + Ok(providers_proxies) => self.providers_proxies = providers_proxies, + Err(e) => log::error!("Failed to get providers proxies: {}", e), + } + } +} + + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn test_mihomo_manager_singleton() { + let manager1 = MihomoManager::new(); + let manager2 = MihomoManager::new(); + + assert!(Arc::ptr_eq(&manager1, &manager2), "Should return same instance"); + + let manager = manager1.read(); + assert!(manager.proxies.is_null()); + assert!(manager.providers_proxies.is_null()); + } + + #[tokio::test] + async fn test_refresh_proxies() { + let manager = MihomoManager::new(); + + // Test initial state + { + let data = manager.read(); + assert!(data.proxies.is_null()); + } + + // Test refresh + { + let mut data = manager.write(); + data.refresh_proxies().await; + // Note: Since this depends on external API call, + // we can only verify that the refresh call completes + // without panicking. For more thorough testing, + // we would need to mock the API caller. + } + } + + #[tokio::test] + async fn test_refresh_providers_proxies() { + let manager = MihomoManager::new(); + + // Test initial state + { + let data = manager.read(); + assert!(data.providers_proxies.is_null()); + } + + // Test refresh + { + let mut data = manager.write(); + data.refresh_providers_proxies().await; + // Note: Since this depends on external API call, + // we can only verify that the refresh call completes + // without panicking. For more thorough testing, + // we would need to mock the API caller. + } + } + + #[tokio::test] + async fn test_fetch_proxies() { + let manager = MihomoManager::new(); + + // Test initial state + { + let data = manager.read(); + let proxies = data.fetch_proxies(); + assert!(proxies.is_null()); + } + + // Test after refresh + { + let mut data = manager.write(); + data.refresh_proxies().await; + let _proxies = data.fetch_proxies(); + // Can only verify the method returns without panicking + // Would need API mocking for more thorough testing + } + } + + #[tokio::test] + async fn test_fetch_providers_proxies() { + let manager = MihomoManager::new(); + + // Test initial state + { + let data = manager.read(); + let providers_proxies = data.fetch_providers_proxies(); + assert!(providers_proxies.is_null()); + } + + // Test after refresh + { + let mut data = manager.write(); + data.refresh_providers_proxies().await; + let _providers_proxies = data.fetch_providers_proxies(); + // Can only verify the method returns without panicking + // Would need API mocking for more thorough testing + } + } +} \ No newline at end of file diff --git a/src-tauri/src/module/mod.rs b/src-tauri/src/module/mod.rs index f146b310..0692611c 100644 --- a/src-tauri/src/module/mod.rs +++ b/src-tauri/src/module/mod.rs @@ -1 +1,3 @@ -pub mod sysinfo; \ No newline at end of file +pub mod api; +pub mod sysinfo; +pub mod mihomo; \ No newline at end of file