use crate::core::{MihomoError, Result};
use std::path::Path;
use tokio::fs;
use tokio::io::AsyncWriteExt;
pub struct Downloader {
client: reqwest::Client,
}
impl Downloader {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
pub async fn download_version(&self, version: &str, dest: &Path) -> Result<()> {
let platform = Self::detect_platform();
let os_name = Self::get_os_name();
let extension = Self::get_file_extension();
let filename = format!("mihomo-{}-{}-{}.{}", os_name, platform, version, extension);
let url = format!(
"https://github.com/MetaCubeX/mihomo/releases/download/{}/{}",
version, filename
);
let resp = self
.client
.get(&url)
.header("User-Agent", "mihomo-rs")
.send()
.await?;
if !resp.status().is_success() {
return Err(MihomoError::version(format!(
"Failed to download version {}: HTTP {}",
version,
resp.status()
)));
}
let bytes = resp.bytes().await?;
let decompressed = if extension == "zip" {
Self::decompress_zip(&bytes)?
} else {
Self::decompress_gz(&bytes)?
};
let mut file = fs::File::create(dest).await?;
file.write_all(&decompressed).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = file.metadata().await?.permissions();
perms.set_mode(0o755);
fs::set_permissions(dest, perms).await?;
}
Ok(())
}
fn get_os_name() -> &'static str {
match std::env::consts::OS {
"linux" => "linux",
"macos" => "darwin",
"windows" => "windows",
_ => "linux",
}
}
fn detect_platform() -> String {
let arch = std::env::consts::ARCH;
match arch {
"x86_64" => "amd64",
"aarch64" => "arm64",
"arm" => "armv7",
_ => "amd64",
}
.to_string()
}
fn get_file_extension() -> &'static str {
match std::env::consts::OS {
"windows" => "zip",
_ => "gz",
}
}
fn decompress_gz(bytes: &[u8]) -> Result<Vec<u8>> {
use flate2::read::GzDecoder;
use std::io::Read;
let mut decoder = GzDecoder::new(bytes);
let mut decompressed = Vec::new();
decoder
.read_to_end(&mut decompressed)
.map_err(|e| MihomoError::version(format!("Failed to decompress gz: {}", e)))?;
Ok(decompressed)
}
fn decompress_zip(bytes: &[u8]) -> Result<Vec<u8>> {
use std::io::{Cursor, Read};
use zip::ZipArchive;
let reader = Cursor::new(bytes);
let mut archive = ZipArchive::new(reader)
.map_err(|e| MihomoError::version(format!("Failed to open zip archive: {}", e)))?;
if archive.len() != 1 {
return Err(MihomoError::version(format!(
"Expected 1 file in zip archive, found {}",
archive.len()
)));
}
let mut file = archive
.by_index(0)
.map_err(|e| MihomoError::version(format!("Failed to read zip entry: {}", e)))?;
let mut decompressed = Vec::new();
file.read_to_end(&mut decompressed)
.map_err(|e| MihomoError::version(format!("Failed to decompress zip: {}", e)))?;
Ok(decompressed)
}
}
impl Default for Downloader {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_os_name() {
let os_name = Downloader::get_os_name();
assert!(
os_name == "linux" || os_name == "darwin" || os_name == "windows",
"OS name should be linux, darwin, or windows, got: {}",
os_name
);
}
#[test]
fn test_detect_platform() {
let platform = Downloader::detect_platform();
assert!(
platform == "amd64" || platform == "arm64" || platform == "armv7",
"Platform should be amd64, arm64, or armv7, got: {}",
platform
);
}
#[test]
fn test_get_file_extension() {
let extension = Downloader::get_file_extension();
assert!(
extension == "zip" || extension == "gz",
"Extension should be zip or gz, got: {}",
extension
);
}
#[test]
#[cfg(target_os = "windows")]
fn test_windows_uses_zip() {
assert_eq!(Downloader::get_file_extension(), "zip");
assert_eq!(Downloader::get_os_name(), "windows");
}
#[test]
#[cfg(target_os = "linux")]
fn test_linux_uses_gz() {
assert_eq!(Downloader::get_file_extension(), "gz");
assert_eq!(Downloader::get_os_name(), "linux");
}
#[test]
#[cfg(target_os = "macos")]
fn test_macos_uses_gz() {
assert_eq!(Downloader::get_file_extension(), "gz");
assert_eq!(Downloader::get_os_name(), "darwin");
}
#[test]
fn test_decompress_gz() {
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;
let test_data = b"Hello, this is test data for gzip compression!";
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(test_data).unwrap();
let compressed = encoder.finish().unwrap();
let decompressed = Downloader::decompress_gz(&compressed).unwrap();
assert_eq!(decompressed, test_data);
}
#[test]
fn test_decompress_zip() {
use std::io::{Cursor, Write};
use zip::write::SimpleFileOptions;
use zip::ZipWriter;
let test_data = b"Hello, this is test data for zip compression!";
let mut zip_buffer = Cursor::new(Vec::new());
{
let mut zip = ZipWriter::new(&mut zip_buffer);
let options = SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o755);
zip.start_file("mihomo", options).unwrap();
zip.write_all(test_data).unwrap();
zip.finish().unwrap();
}
let compressed = zip_buffer.into_inner();
let decompressed = Downloader::decompress_zip(&compressed).unwrap();
assert_eq!(decompressed, test_data);
}
#[test]
fn test_decompress_zip_with_multiple_files_fails() {
use std::io::{Cursor, Write};
use zip::write::SimpleFileOptions;
use zip::ZipWriter;
let mut zip_buffer = Cursor::new(Vec::new());
{
let mut zip = ZipWriter::new(&mut zip_buffer);
let options =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
zip.start_file("file1", options).unwrap();
zip.write_all(b"File 1 content").unwrap();
zip.start_file("file2", options).unwrap();
zip.write_all(b"File 2 content").unwrap();
zip.finish().unwrap();
}
let compressed = zip_buffer.into_inner();
let result = Downloader::decompress_zip(&compressed);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Expected 1 file in zip archive, found 2"));
}
#[test]
fn test_decompress_gz_with_invalid_data() {
let invalid_data = b"This is not gzip compressed data";
let result = Downloader::decompress_gz(invalid_data);
assert!(result.is_err());
}
#[test]
fn test_decompress_zip_with_invalid_data() {
let invalid_data = b"This is not zip compressed data";
let result = Downloader::decompress_zip(invalid_data);
assert!(result.is_err());
}
#[test]
fn test_filename_format() {
let version = "v1.19.17";
let platform = Downloader::detect_platform();
let os_name = Downloader::get_os_name();
let extension = Downloader::get_file_extension();
let filename = format!("mihomo-{}-{}-{}.{}", os_name, platform, version, extension);
assert!(filename.starts_with("mihomo-"));
assert!(filename.contains(version));
assert!(filename.ends_with(".zip") || filename.ends_with(".gz"));
}
#[test]
fn test_default_downloader_constructs_client() {
let downloader = Downloader::default();
let _ = downloader.client.clone();
}
}