use futures::StreamExt;
use headless_chrome::{Browser, LaunchOptions};
use regex::Regex;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use zip::ZipArchive;
#[derive(Debug, thiserror::Error)]
pub enum ServerDownloadError {
#[error("EULA and Privacy Policy not accepted")]
EulaAndPrivacyPolicyNotAccepted,
#[error("Failed to download server: {0}")]
DownloadFailed(String),
#[error("Failed to read server zip: {0}")]
ZipReadFailed(String),
#[error("Failed to create temporary file: {0}")]
TempFileCreationFailed(String),
#[error("Failed to extract server files: {0}")]
ExtractionFailed(String),
#[error("Invalid download path: {0}")]
InvalidPath(String),
#[error("Server version {0} already installed")]
ServerAlreadyInstalled(String),
}
const EULA_NOT_ACCEPTED_TEXT: &str = r#"
By proceeding, you agree to the Minecraft End User License Agreement:
https://minecraft.net/eula
and the Privacy Policy:
https://go.microsoft.com/fwlink/?LinkId=521839
If you do not agree, you must not use this software.
"#;
pub async fn download_server(
version: &str,
download_path: PathBuf,
accepted_eula_and_privacy_policy: bool,
force_reinstall: bool,
) -> Result<(), ServerDownloadError> {
if !accepted_eula_and_privacy_policy {
println!("{}", EULA_NOT_ACCEPTED_TEXT);
return Err(ServerDownloadError::EulaAndPrivacyPolicyNotAccepted);
}
let version_path = download_path.join(version);
if !force_reinstall {
if version_path.exists() {
return Err(ServerDownloadError::ServerAlreadyInstalled(
version.to_string(),
));
}
}
if !download_path.exists() {
std::fs::create_dir_all(&download_path).map_err(|e| {
ServerDownloadError::InvalidPath(format!("Failed to create directory: {}", e))
})?;
}
if !download_path.is_dir() {
return Err(ServerDownloadError::InvalidPath(
"Path must be a directory".to_string(),
));
}
println!("Downloading Bedrock Server version {}...", version);
let download_url = get_download_url(version);
let response = reqwest::get(&download_url).await.map_err(|e| {
ServerDownloadError::DownloadFailed(format!("Failed to connect to server: {}", e))
})?;
let total_size = response.content_length().unwrap_or(0);
let mut downloaded = 0;
let mut content = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| {
ServerDownloadError::DownloadFailed(format!("Failed to read chunk: {}", e))
})?;
content.extend_from_slice(&chunk);
downloaded += chunk.len() as u64;
if total_size > 0 {
let percentage = (downloaded as f64 / total_size as f64 * 100.0) as u32;
print!("\rDownloading: {}%", percentage);
std::io::stdout().flush().ok();
}
}
println!("\nDownload complete!");
println!("Extracting server files...");
let temp_zip = tempfile::NamedTempFile::new()
.map_err(|e| ServerDownloadError::TempFileCreationFailed(e.to_string()))?;
temp_zip.as_file().write_all(&content).map_err(|e| {
ServerDownloadError::ZipReadFailed(format!("Failed to write zip file: {}", e))
})?;
let mut archive = ZipArchive::new(temp_zip.as_file()).map_err(|e| {
ServerDownloadError::ExtractionFailed(format!("Failed to open zip archive: {}", e))
})?;
let total_files = archive.len();
for i in 0..total_files {
if let Ok(mut file) = archive.by_index(i) {
let outpath = version_path.join(file.name());
if file.name().ends_with('/') {
std::fs::create_dir_all(&outpath).map_err(|e| {
ServerDownloadError::ExtractionFailed(format!(
"Failed to create directory: {}",
e
))
})?;
} else {
if let Some(parent) = outpath.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
ServerDownloadError::ExtractionFailed(format!(
"Failed to create parent directory: {}",
e
))
})?;
}
let mut outfile = File::create(&outpath).map_err(|e| {
ServerDownloadError::ExtractionFailed(format!("Failed to create file: {}", e))
})?;
std::io::copy(&mut file, &mut outfile).map_err(|e| {
ServerDownloadError::ExtractionFailed(format!("Failed to extract file: {}", e))
})?;
}
print!("\rExtracting: {}/{} files", i + 1, total_files);
std::io::stdout().flush().ok();
}
}
println!("\nExtraction complete!");
Ok(())
}
fn get_download_url(version: &str) -> String {
format!(
"https://www.minecraft.net/bedrockdedicatedserver/bin-linux/bedrock-server-{}.zip",
version
)
}
pub async fn get_latest_version() -> Result<String, ServerDownloadError> {
println!("Launching headless browser...");
let launch_options = LaunchOptions {
headless: true,
args: vec![
std::ffi::OsStr::new("--disable-http2"),
std::ffi::OsStr::new(
"--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
),
std::ffi::OsStr::new("--disable-blink-features=AutomationControlled"),
std::ffi::OsStr::new("--no-sandbox"),
std::ffi::OsStr::new("--disable-dev-shm-usage"),
],
..Default::default()
};
let browser = Browser::new(launch_options).map_err(|e| {
ServerDownloadError::DownloadFailed(format!("Failed to launch browser: {}", e))
})?;
let tab = browser.new_tab().map_err(|e| {
ServerDownloadError::DownloadFailed(format!("Failed to create new tab: {}", e))
})?;
tab.navigate_to("https://minecraft.net/en-us/download/server/bedrock/")
.map_err(|e| {
ServerDownloadError::DownloadFailed(format!("Failed to navigate to page: {}", e))
})?;
println!("Waiting for page to load...");
std::thread::sleep(std::time::Duration::from_secs(5));
let result = tab
.evaluate(
r#"
// Find all links with href containing bedrock-server
const links = Array.from(document.querySelectorAll('a[href*="bedrock-server"]'));
const linuxLink = links.find(link => link.href.includes('bin-linux'));
if (linuxLink) {
linuxLink.href;
} else {
// Try to find by aria-label
const button = document.querySelector('a[aria-label="serverBedrockLinux"]');
button ? button.href : null;
}
"#,
false,
)
.map_err(|e| {
ServerDownloadError::DownloadFailed(format!("Failed to evaluate JavaScript: {}", e))
})?;
if let Some(url) = result.value {
if let Some(url_str) = url.as_str() {
println!("Found download URL: {}", url_str);
let version_re = Regex::new(r"bedrock-server-(\d+\.\d+\.\d+\.\d+)\.zip").unwrap();
if let Some(captures) = version_re.captures(url_str) {
if let Some(version) = captures.get(1) {
println!("Extracted version: {}", version.as_str());
return Ok(version.as_str().to_string());
}
}
}
}
let html = tab.get_content().map_err(|e| {
ServerDownloadError::DownloadFailed(format!("Failed to get page content: {}", e))
})?;
println!("Searching rendered HTML for version...");
let patterns = [
r"bedrock-server-(\d+\.\d+\.\d+\.\d+)\.zip",
r#"bedrockdedicatedserver/bin-linux/bedrock-server-(\d+\.\d+\.\d+\.\d+)"#,
];
for pattern in patterns {
let re = Regex::new(pattern).unwrap();
if let Some(captures) = re.captures(&html) {
if let Some(version) = captures.get(1) {
println!("Found version: {}", version.as_str());
return Ok(version.as_str().to_string());
}
}
}
Err(ServerDownloadError::DownloadFailed(
"Could not find version in download page".to_string(),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_get_latest_version() {
let result = get_latest_version().await;
match result {
Ok(version) => {
assert!(!version.is_empty(), "Version should not be empty");
let version_parts: Vec<&str> = version.split('.').collect();
assert!(
version_parts.len() >= 3,
"Version should have at least 3 parts separated by dots"
);
for part in version_parts {
assert!(
part.chars().all(char::is_numeric),
"Each version part should be numeric"
);
}
println!("Latest version: {}", version);
}
Err(e) => {
panic!("Failed to get latest version: {}", e);
}
}
}
}