feat: full ui refresh

This commit is contained in:
zhom
2026-05-11 23:13:03 +04:00
parent 739b5e2449
commit ed3c209f35
46 changed files with 5956 additions and 1553 deletions
-404
View File
@@ -1115,149 +1115,6 @@ impl ProxyManager {
self.load_proxy_check_cache(proxy_id)
}
pub async fn fetch_proxy_from_url(
&self,
url: &str,
timeout: std::time::Duration,
) -> Result<Option<ProxySettings>, String> {
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
let response = client
.get(url)
.send()
.await
.map_err(|e| format!("Failed to fetch launch hook: {e}"))?;
if response.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(None);
}
if !response.status().is_success() {
return Err(format!("Launch hook returned status {}", response.status()));
}
let body = response
.text()
.await
.map_err(|e| format!("Failed to read launch hook response: {e}"))?;
let body = body.trim();
if body.is_empty() {
return Err("Launch hook returned empty response".to_string());
}
if let Ok(settings) = Self::parse_dynamic_proxy_json(body) {
return Ok(Some(settings));
}
match Self::parse_dynamic_proxy_text(body) {
Ok(settings) => Ok(Some(settings)),
Err(text_error) => Err(format!(
"Failed to parse launch hook response: {text_error}"
)),
}
}
// Parse JSON proxy payload: { "ip"/"host": "...", "port": ..., "username": "...", "password": "..." }
fn parse_dynamic_proxy_json(body: &str) -> Result<ProxySettings, String> {
let json: serde_json::Value =
serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))?;
let obj = json
.as_object()
.ok_or_else(|| "JSON response is not an object".to_string())?;
let raw_host = obj
.get("ip")
.or_else(|| obj.get("host"))
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing 'ip' or 'host' field in JSON response".to_string())?;
// Strip protocol prefix from host if present (e.g. "socks5://1.2.3.4" -> "1.2.3.4")
// and extract the proxy type from it if no explicit type field is provided
let (host, protocol_from_host) = if let Some(rest) = raw_host.strip_prefix("://") {
(rest.to_string(), None)
} else if let Some((proto, rest)) = raw_host.split_once("://") {
(rest.to_string(), Some(proto.to_lowercase()))
} else {
(raw_host.to_string(), None)
};
let port = obj
.get("port")
.and_then(|v| {
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
})
.ok_or_else(|| "Missing or invalid 'port' field in JSON response".to_string())?
as u16;
let proxy_type = obj
.get("type")
.or_else(|| obj.get("proxy_type"))
.or_else(|| obj.get("protocol"))
.and_then(|v| v.as_str())
.map(|s| s.to_lowercase())
.or(protocol_from_host)
.unwrap_or_else(|| "http".to_string());
let username = obj
.get("username")
.or_else(|| obj.get("user"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let password = obj
.get("password")
.or_else(|| obj.get("pass"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
Ok(ProxySettings {
proxy_type,
host,
port,
username,
password,
})
}
// Parse plain text proxy payload using the same logic as proxy import
fn parse_dynamic_proxy_text(body: &str) -> Result<ProxySettings, String> {
let line = body
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim();
if line.is_empty() {
return Err("Empty text response".to_string());
}
match Self::parse_single_proxy_line(line) {
ProxyParseResult::Parsed(parsed) => Ok(ProxySettings {
proxy_type: parsed.proxy_type,
host: parsed.host,
port: parsed.port,
username: parsed.username,
password: parsed.password,
}),
ProxyParseResult::Ambiguous {
possible_formats, ..
} => Err(format!(
"Ambiguous proxy format. Could be: {}",
possible_formats.join(" or ")
)),
ProxyParseResult::Invalid { reason, .. } => {
Err(format!("Failed to parse proxy response: {reason}"))
}
}
}
// Export all proxies as JSON
pub fn export_proxies_json(&self) -> Result<String, String> {
let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -2317,8 +2174,6 @@ mod tests {
use hyper::Response;
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
// Helper function to build donut-proxy binary for testing
async fn ensure_donut_proxy_binary() -> Result<PathBuf, Box<dyn std::error::Error>> {
@@ -3587,263 +3442,4 @@ mod tests {
delete_proxy_config(&id);
}
#[test]
fn test_parse_dynamic_proxy_json_standard_format() {
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "user1", "password": "pass1"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "1.2.3.4");
assert_eq!(result.port, 8080);
assert_eq!(result.proxy_type, "http");
assert_eq!(result.username.as_deref(), Some("user1"));
assert_eq!(result.password.as_deref(), Some("pass1"));
}
#[test]
fn test_parse_dynamic_proxy_json_host_alias() {
let body = r#"{"host": "proxy.example.com", "port": 3128}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert!(result.username.is_none());
assert!(result.password.is_none());
}
#[test]
fn test_parse_dynamic_proxy_json_user_pass_aliases() {
let body = r#"{"ip": "10.0.0.1", "port": 1080, "user": "u", "pass": "p"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.username.as_deref(), Some("u"));
assert_eq!(result.password.as_deref(), Some("p"));
}
#[test]
fn test_parse_dynamic_proxy_json_port_as_string() {
let body = r#"{"ip": "1.2.3.4", "port": "9090"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.port, 9090);
}
#[test]
fn test_parse_dynamic_proxy_json_with_proxy_type() {
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "socks5"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.proxy_type, "socks5");
let body2 = r#"{"ip": "1.2.3.4", "port": 1080, "proxy_type": "socks4"}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.proxy_type, "socks4");
// "protocol" field alias
let body3 = r#"{"ip": "1.2.3.4", "port": 1080, "protocol": "socks5"}"#;
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
assert_eq!(result3.proxy_type, "socks5");
}
#[test]
fn test_parse_dynamic_proxy_json_normalizes_case() {
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "SOCKS5"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.proxy_type, "socks5");
let body2 = r#"{"ip": "1.2.3.4", "port": 8080, "protocol": "HTTP"}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.proxy_type, "http");
}
#[test]
fn test_parse_dynamic_proxy_json_strips_protocol_from_host() {
// User's API returns "ip": "socks5://1.2.3.4" with protocol embedded in host
let body = r#"{"ip": "socks5://1.2.3.4", "port": 1080, "username": "u", "password": "p"}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert_eq!(result.host, "1.2.3.4");
assert_eq!(result.proxy_type, "socks5");
assert_eq!(result.port, 1080);
// Protocol in host should be used as proxy_type when no explicit type field
let body2 = r#"{"ip": "http://10.0.0.1", "port": 8080}"#;
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
assert_eq!(result2.host, "10.0.0.1");
assert_eq!(result2.proxy_type, "http");
// Explicit type field takes precedence over protocol in host
let body3 = r#"{"ip": "http://10.0.0.1", "port": 1080, "type": "socks5"}"#;
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
assert_eq!(result3.host, "10.0.0.1");
assert_eq!(result3.proxy_type, "socks5");
}
#[test]
fn test_parse_dynamic_proxy_json_empty_credentials_treated_as_none() {
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "", "password": ""}"#;
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
assert!(result.username.is_none());
assert!(result.password.is_none());
}
#[test]
fn test_parse_dynamic_proxy_json_missing_ip() {
let body = r#"{"port": 8080}"#;
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
assert!(err.contains("ip") || err.contains("host"));
}
#[test]
fn test_parse_dynamic_proxy_json_missing_port() {
let body = r#"{"ip": "1.2.3.4"}"#;
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
assert!(err.contains("port"));
}
#[test]
fn test_parse_dynamic_proxy_json_invalid_json() {
let err = ProxyManager::parse_dynamic_proxy_json("not json").unwrap_err();
assert!(err.contains("Invalid JSON"));
}
#[test]
fn test_parse_dynamic_proxy_json_not_object() {
let err = ProxyManager::parse_dynamic_proxy_json("[1,2,3]").unwrap_err();
assert!(err.contains("not an object"));
}
#[test]
fn test_parse_dynamic_proxy_text_host_port_user_pass() {
let body = "proxy.example.com:8080:user1:pass1";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 8080);
assert_eq!(result.username.as_deref(), Some("user1"));
assert_eq!(result.password.as_deref(), Some("pass1"));
}
#[test]
fn test_parse_dynamic_proxy_text_protocol_url_format() {
let body = "http://user:pass@proxy.example.com:3128";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert_eq!(result.proxy_type, "http");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[test]
fn test_parse_dynamic_proxy_text_with_whitespace() {
let body = " \n proxy.example.com:8080:user:pass \n ";
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 8080);
}
#[test]
fn test_parse_dynamic_proxy_text_empty() {
let err = ProxyManager::parse_dynamic_proxy_text("").unwrap_err();
assert!(err.contains("Empty"));
}
#[test]
fn test_parse_dynamic_proxy_text_whitespace_only() {
let err = ProxyManager::parse_dynamic_proxy_text(" \n \n ").unwrap_err();
assert!(err.contains("Empty"));
}
#[tokio::test]
async fn test_fetch_proxy_from_url_parses_json_response() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{"host":"proxy.example.com","port":3128,"type":"socks5","username":"user","password":"pass"}"#,
),
)
.mount(&server)
.await;
let pm = ProxyManager::new();
let result = pm
.fetch_proxy_from_url(
&format!("{}/hook", server.uri()),
Duration::from_millis(500),
)
.await
.unwrap()
.unwrap();
assert_eq!(result.host, "proxy.example.com");
assert_eq!(result.port, 3128);
assert_eq!(result.proxy_type, "socks5");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[tokio::test]
async fn test_fetch_proxy_from_url_parses_text_response() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(ResponseTemplate::new(200).set_body_string("socks5://user:pass@1.2.3.4:1080"))
.mount(&server)
.await;
let pm = ProxyManager::new();
let result = pm
.fetch_proxy_from_url(
&format!("{}/hook", server.uri()),
Duration::from_millis(500),
)
.await
.unwrap()
.unwrap();
assert_eq!(result.host, "1.2.3.4");
assert_eq!(result.port, 1080);
assert_eq!(result.proxy_type, "socks5");
assert_eq!(result.username.as_deref(), Some("user"));
assert_eq!(result.password.as_deref(), Some("pass"));
}
#[tokio::test]
async fn test_fetch_proxy_from_url_returns_none_for_no_content() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let pm = ProxyManager::new();
let result = pm
.fetch_proxy_from_url(
&format!("{}/hook", server.uri()),
Duration::from_millis(500),
)
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_fetch_proxy_from_url_respects_timeout() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hook"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(200))
.set_body_string(r#"{"host":"1.2.3.4","port":8080}"#),
)
.mount(&server)
.await;
let pm = ProxyManager::new();
let err = pm
.fetch_proxy_from_url(&format!("{}/hook", server.uri()), Duration::from_millis(50))
.await
.unwrap_err();
assert!(err.contains("Failed to fetch launch hook"));
}
}