feat: add Mihomo API modules and manager (#2869)

• 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.
This commit is contained in:
Tunglies 2025-03-03 19:31:44 +08:00 committed by GitHub
parent 3e53ea7209
commit 3b69465016
15 changed files with 505 additions and 4 deletions

134
src-tauri/Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -0,0 +1 @@
pub const MIHOMO_URL: &str = concat!("http://", "127.0.0.1", ":", "9097");

View File

@ -0,0 +1 @@
pub mod mihomo;

View File

@ -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;

View File

@ -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]

View File

@ -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(),
};
}
}

View File

@ -0,0 +1,5 @@
use super::common::ApiCaller;
pub struct MihomoAPICaller {
pub(crate) caller: ApiCaller<'static>,
}

View File

@ -0,0 +1,2 @@
pub mod common;
pub mod mihomo;

View File

@ -1 +1,2 @@
pub mod api;
pub mod sysinfo;

View File

@ -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<Vec<(&str, &str)>>,
) -> Result<String, String> {
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<T>(
&self,
method: &str,
path: &str,
body: Option<&str>,
headers: Option<Vec<(&str, &str)>>
) -> Result<T, String>
where
T: DeserializeOwned + Send + Sync;
fn parse_json_response<T>(json_str: &str) -> Result<T, String>
where
T: DeserializeOwned,
{
serde_json::from_str(json_str).map_err(|e| e.to_string())
}
}

View File

@ -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<RwLock<Self>> {
static INSTANCE: OnceCell<Arc<RwLock<MihomoAPICaller>>> = 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<T>(
&self,
method: &str,
path: &str,
body: Option<&str>,
headers: Option<Vec<(&str, &str)>>,
) -> Result<T, String>
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::<T>(&response)
}
}
#[allow(unused)]
impl MihomoAPICaller {
pub async fn get_proxies() -> Result<serde_json::Value, String> {
Self::new()
.read()
.call_api("GET", "/proxies", None, None)
.await
}
pub async fn get_providers_proxies() -> Result<serde_json::Value, String> {
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<serde_json::Value, String> = 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());
}
}
}

View File

@ -0,0 +1,2 @@
pub mod common;
pub mod mihomo;

View File

@ -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<RwLock<Self>> {
static INSTANCE: OnceCell<Arc<RwLock<MihomoManager>>> = 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
}
}
}

View File

@ -1 +1,3 @@
pub mod api;
pub mod sysinfo;
pub mod mihomo;