diff options
Diffstat (limited to '')
| -rw-r--r-- | src/minecraft/downloads.rs | 423 |
1 files changed, 301 insertions, 122 deletions
diff --git a/src/minecraft/downloads.rs b/src/minecraft/downloads.rs index 7017d3f..8df994e 100644 --- a/src/minecraft/downloads.rs +++ b/src/minecraft/downloads.rs | |||
| @@ -1,181 +1,360 @@ | |||
| 1 | use log::{debug, info}; | 1 | use std::{ |
| 2 | use reqwest::get; | 2 | collections::HashMap, |
| 3 | io::{Write, stdout}, | ||
| 4 | path::{Path, PathBuf}, | ||
| 5 | sync::{ | ||
| 6 | Arc, | ||
| 7 | atomic::{AtomicUsize, Ordering}, | ||
| 8 | }, | ||
| 9 | }; | ||
| 10 | |||
| 11 | use McError::Config; | ||
| 12 | use Ordering::SeqCst; | ||
| 13 | use futures::stream::{FuturesUnordered, StreamExt}; | ||
| 14 | use reqwest::Response; | ||
| 3 | use serde::Deserialize; | 15 | use serde::Deserialize; |
| 4 | use tokio::{ | 16 | use tokio::{ |
| 5 | fs::{self, File, create_dir_all}, | 17 | fs::{File, create_dir_all, read_to_string, write}, |
| 6 | io::AsyncWriteExt, | 18 | io::AsyncWriteExt, |
| 19 | spawn, | ||
| 20 | sync::Semaphore, | ||
| 21 | task::{JoinError, JoinHandle}, | ||
| 7 | }; | 22 | }; |
| 8 | 23 | ||
| 9 | use crate::{ | 24 | use crate::{ |
| 10 | config::Config, | 25 | config::RuntimeConfig, |
| 11 | errors::McError, | 26 | errors::McError, |
| 12 | minecraft::manifests::{Library, Version}, | 27 | minecraft::manifests::{AssetIndex, Library, LibraryArtifact, Version}, |
| 13 | platform::paths, | 28 | platform::paths::{assets_directory, client_jar, library_file}, |
| 14 | }; | 29 | }; |
| 15 | 30 | ||
| 31 | const MAX_CONCURRENT_DOWNLOADS: usize = 100; | ||
| 32 | const PROGRESS_INITIAL_VALUE: usize = 0; | ||
| 33 | const ASSET_URL_BASE: &str = "https://resources.download.minecraft.net/"; | ||
| 34 | const MAX_PROGRESS_PERCENT: f64 = 100.0; | ||
| 35 | const PROGRESS_PRINT_WIDTH: usize = 3; | ||
| 36 | const ATOMIC_ORDERING: Ordering = SeqCst; | ||
| 37 | const DOWNLOAD_COMPLETE_MESSAGE: &str = "All downloads completed successfully!"; | ||
| 38 | const ASSET_INDEX_MISSING_ERROR: &str = "Missing asset_index for the version"; | ||
| 39 | |||
| 40 | impl From<JoinError> for McError { | ||
| 41 | fn from(error: JoinError) -> Self { | ||
| 42 | Config(format!("Task panicked: {}", error)) | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | /// Represents a single file to download, including the URL and destination | ||
| 47 | /// path. | ||
| 48 | #[derive(Debug, Clone)] | ||
| 49 | struct DownloadJob { | ||
| 50 | url: String, | ||
| 51 | destination_path: PathBuf, | ||
| 52 | } | ||
| 53 | |||
| 54 | impl DownloadJob { | ||
| 55 | fn already_exists(&self) -> bool { self.destination_path.exists() } | ||
| 56 | } | ||
| 57 | |||
| 58 | /// Represents a single asset entry in the Minecraft asset index. | ||
| 16 | #[derive(Debug, Deserialize)] | 59 | #[derive(Debug, Deserialize)] |
| 17 | struct AssetObject { | 60 | struct AssetObject { |
| 18 | hash: String, | 61 | hash: String, |
| 19 | // size: u64, | ||
| 20 | } | 62 | } |
| 21 | 63 | ||
| 64 | /// The Minecraft asset index manifest. | ||
| 22 | #[derive(Debug, Deserialize)] | 65 | #[derive(Debug, Deserialize)] |
| 23 | struct AssetIndexManifest { | 66 | struct AssetIndexManifest { |
| 24 | objects: std::collections::HashMap<String, AssetObject>, | 67 | objects: HashMap<String, AssetObject>, |
| 25 | } | 68 | } |
| 26 | 69 | ||
| 27 | pub async fn download_all( | 70 | /// Download all files required to run a specific Minecraft version. |
| 28 | config: &Config, | 71 | pub async fn download_all_files( |
| 29 | version: &Version, | 72 | http_client: &reqwest::Client, |
| 73 | runtime_config: &RuntimeConfig, | ||
| 74 | version_info: &Version, | ||
| 30 | ) -> Result<(), McError> { | 75 | ) -> Result<(), McError> { |
| 31 | download_client(config, version).await?; | 76 | let assets_directory_path: PathBuf = |
| 32 | download_libraries(config, &version.libraries).await?; | 77 | ensure_assets_directories_exist(runtime_config).await?; |
| 33 | download_assets(config, version).await?; | 78 | let asset_manifest: AssetIndexManifest = load_asset_index_manifest( |
| 79 | http_client, | ||
| 80 | &assets_directory_path, | ||
| 81 | version_info, | ||
| 82 | ) | ||
| 83 | .await?; | ||
| 84 | |||
| 85 | let download_jobs: Vec<DownloadJob> = build_download_jobs( | ||
| 86 | runtime_config, | ||
| 87 | version_info, | ||
| 88 | &assets_directory_path, | ||
| 89 | &asset_manifest, | ||
| 90 | ); | ||
| 91 | |||
| 92 | execute_download_jobs(http_client, download_jobs).await?; | ||
| 93 | println!("\n{}", DOWNLOAD_COMPLETE_MESSAGE); | ||
| 94 | |||
| 34 | Ok(()) | 95 | Ok(()) |
| 35 | } | 96 | } |
| 36 | 97 | ||
| 37 | async fn download_client( | 98 | /// Ensure the essential assets directories exist. |
| 38 | config: &Config, | 99 | async fn ensure_assets_directories_exist( |
| 39 | version: &Version, | 100 | config: &RuntimeConfig, |
| 40 | ) -> Result<(), McError> { | 101 | ) -> Result<PathBuf, McError> { |
| 41 | let jar_path = paths::client_jar(config, &version.id)?; | 102 | let assets_dir = assets_directory(config); |
| 42 | 103 | create_dir_all(assets_dir.join("objects")).await?; | |
| 43 | if jar_path.exists() { | 104 | create_dir_all(assets_dir.join("indexes")).await?; |
| 44 | debug!("Client jar already exists: {}", jar_path.display()); | 105 | Ok(assets_dir) |
| 45 | return Ok(()); | ||
| 46 | } | ||
| 47 | |||
| 48 | info!("Downloading client {}", version.id); | ||
| 49 | download_file(&version.downloads.client.url, &jar_path).await | ||
| 50 | } | 106 | } |
| 51 | 107 | ||
| 52 | async fn download_libraries( | 108 | /// Load the asset index manifest for the given Minecraft version. |
| 53 | config: &Config, | 109 | async fn load_asset_index_manifest( |
| 54 | libraries: &[Library], | 110 | http_client: &reqwest::Client, |
| 55 | ) -> Result<(), McError> { | 111 | assets_dir: &Path, |
| 56 | for library in libraries { | 112 | version_info: &Version, |
| 57 | if let Some(artifact) = &library.downloads.artifact { | 113 | ) -> Result<AssetIndexManifest, McError> { |
| 58 | let library_path = paths::library_file(config, &artifact.path)?; | 114 | let asset_index: &AssetIndex = version_info |
| 59 | 115 | .asset_index | |
| 60 | if !library_path.exists() { | 116 | .as_ref() |
| 61 | info!("Downloading library {}", artifact.path); | 117 | .ok_or_else(|| Config(ASSET_INDEX_MISSING_ERROR.into()))?; |
| 62 | download_file(&artifact.url, &library_path).await?; | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | if let Some(classifiers) = &library.downloads.classifiers { | ||
| 67 | for (_, native) in classifiers { | ||
| 68 | let native_path = paths::library_file(config, &native.path)?; | ||
| 69 | 118 | ||
| 70 | if native_path.exists() { | 119 | let index_file_path: PathBuf = assets_dir |
| 71 | continue; | 120 | .join("indexes") |
| 72 | } | 121 | .join(format!("{}.json", asset_index.id)); |
| 73 | 122 | ||
| 74 | info!("Downloading native library {}", native.path); | 123 | if !index_file_path.exists() { |
| 75 | download_file(&native.url, &native_path).await?; | 124 | download_text_file(http_client, &asset_index.url, &index_file_path) |
| 76 | } | 125 | .await?; |
| 77 | } | ||
| 78 | } | 126 | } |
| 79 | 127 | ||
| 80 | Ok(()) | 128 | let json_string: String = read_to_string(index_file_path).await?; |
| 129 | Ok(serde_json::from_str(&json_string)?) | ||
| 81 | } | 130 | } |
| 82 | 131 | ||
| 83 | async fn download_asset_index( | 132 | fn build_download_jobs( |
| 84 | config: &Config, | 133 | config: &RuntimeConfig, |
| 85 | version: &Version, | 134 | version_info: &Version, |
| 86 | ) -> Result<AssetIndexManifest, McError> { | 135 | assets_dir: &Path, |
| 87 | let assets_dir = paths::assets_dir(config); | 136 | asset_manifest: &AssetIndexManifest, |
| 88 | create_dir_all(assets_dir.join("indexes")).await?; | 137 | ) -> Vec<DownloadJob> { |
| 138 | let mut jobs: Vec<DownloadJob> = Vec::new(); | ||
| 89 | 139 | ||
| 90 | let asset_index = version.asset_index.as_ref().ok_or_else(|| { | 140 | add_client_download_job(&mut jobs, config, version_info); |
| 91 | McError::Config("Missing asset_index in version.json".into()) | 141 | add_library_download_jobs(&mut jobs, config, version_info); |
| 92 | })?; | 142 | add_asset_download_jobs(&mut jobs, assets_dir, asset_manifest); |
| 93 | 143 | ||
| 94 | if asset_index.id == "legacy" { | 144 | jobs |
| 95 | return Err(McError::Config( | 145 | } |
| 96 | "Legacy assetIndex detected – pobierz właściwy version.json".into(), | ||
| 97 | )); | ||
| 98 | } | ||
| 99 | 146 | ||
| 100 | let index_path = assets_dir | 147 | fn add_client_download_job( |
| 101 | .join("indexes") | 148 | jobs: &mut Vec<DownloadJob>, |
| 102 | .join(format!("{}.json", asset_index.id)); | 149 | config: &RuntimeConfig, |
| 150 | version_info: &Version, | ||
| 151 | ) { | ||
| 152 | jobs.push(DownloadJob { | ||
| 153 | url: version_info.downloads.client.url.clone(), | ||
| 154 | destination_path: client_jar(config, &version_info.id), | ||
| 155 | }); | ||
| 156 | } | ||
| 103 | 157 | ||
| 104 | if index_path.exists() { | 158 | fn add_library_download_jobs( |
| 105 | let index_data = fs::read_to_string(&index_path).await?; | 159 | jobs: &mut Vec<DownloadJob>, |
| 106 | let manifest: AssetIndexManifest = serde_json::from_str(&index_data)?; | 160 | config: &RuntimeConfig, |
| 107 | return Ok(manifest); | 161 | version_info: &Version, |
| 162 | ) { | ||
| 163 | for library in &version_info.libraries { | ||
| 164 | add_library_artifact_job(jobs, config, library); | ||
| 165 | add_library_classifier_jobs(jobs, config, library); | ||
| 108 | } | 166 | } |
| 167 | } | ||
| 168 | fn add_library_artifact_job( | ||
| 169 | jobs: &mut Vec<DownloadJob>, | ||
| 170 | config: &RuntimeConfig, | ||
| 171 | library: &Library, | ||
| 172 | ) { | ||
| 173 | let artifact: &LibraryArtifact = match &library.downloads.artifact { | ||
| 174 | | Some(a) => a, | ||
| 175 | | None => return, | ||
| 176 | }; | ||
| 177 | |||
| 178 | jobs.push(DownloadJob { | ||
| 179 | url: artifact.url.clone(), | ||
| 180 | destination_path: library_file(config, &artifact.path), | ||
| 181 | }); | ||
| 182 | } | ||
| 109 | 183 | ||
| 110 | info!("Downloading asset index {}", asset_index.id); | 184 | fn add_library_classifier_jobs( |
| 111 | let response = get(&asset_index.url).await?; | 185 | jobs: &mut Vec<DownloadJob>, |
| 112 | let manifest_text = response.text().await?; | 186 | config: &RuntimeConfig, |
| 113 | 187 | library: &Library, | |
| 114 | fs::write(&index_path, &manifest_text).await?; | 188 | ) { |
| 189 | let classifiers: &HashMap<String, LibraryArtifact> = | ||
| 190 | match &library.downloads.classifiers { | ||
| 191 | | Some(values) => values, | ||
| 192 | | None => return, | ||
| 193 | }; | ||
| 194 | |||
| 195 | for classifier_entry in classifiers.values() { | ||
| 196 | jobs.push(DownloadJob { | ||
| 197 | url: classifier_entry.url.clone(), | ||
| 198 | destination_path: library_file(config, &classifier_entry.path), | ||
| 199 | }); | ||
| 200 | } | ||
| 201 | } | ||
| 115 | 202 | ||
| 116 | let manifest: AssetIndexManifest = serde_json::from_str(&manifest_text)?; | 203 | fn add_asset_download_jobs( |
| 117 | Ok(manifest) | 204 | jobs: &mut Vec<DownloadJob>, |
| 205 | assets_dir: &Path, | ||
| 206 | asset_manifest: &AssetIndexManifest, | ||
| 207 | ) { | ||
| 208 | for asset_object in asset_manifest.objects.values() { | ||
| 209 | let prefix: &str = &asset_object.hash[0..2]; | ||
| 210 | jobs.push(DownloadJob { | ||
| 211 | url: format!("{}{}/{}", ASSET_URL_BASE, prefix, asset_object.hash), | ||
| 212 | destination_path: assets_dir | ||
| 213 | .join("objects") | ||
| 214 | .join(prefix) | ||
| 215 | .join(&asset_object.hash), | ||
| 216 | }); | ||
| 217 | } | ||
| 118 | } | 218 | } |
| 119 | 219 | ||
| 120 | async fn download_assets( | 220 | async fn execute_download_jobs( |
| 121 | config: &Config, | 221 | http_client: &reqwest::Client, |
| 122 | version: &Version, | 222 | download_jobs: Vec<DownloadJob>, |
| 123 | ) -> Result<(), McError> { | 223 | ) -> Result<(), McError> { |
| 124 | let assets_dir = paths::assets_dir(config); | 224 | let total_jobs_count: usize = download_jobs.len(); |
| 125 | 225 | let completed_jobs_count: Arc<AtomicUsize> = | |
| 126 | create_dir_all(assets_dir.join("objects")).await?; | 226 | Arc::new(AtomicUsize::new(PROGRESS_INITIAL_VALUE)); |
| 127 | create_dir_all(assets_dir.join("indexes")).await?; | 227 | let concurrent_download_semaphore: Arc<Semaphore> = |
| 128 | 228 | Arc::new(Semaphore::new(MAX_CONCURRENT_DOWNLOADS)); | |
| 129 | let manifest = download_asset_index(config, version).await?; | 229 | |
| 230 | let mut tasks: FuturesUnordered<JoinHandle<Result<(), McError>>> = | ||
| 231 | spawn_missing_download_jobs( | ||
| 232 | http_client, | ||
| 233 | download_jobs, | ||
| 234 | &completed_jobs_count, | ||
| 235 | total_jobs_count, | ||
| 236 | concurrent_download_semaphore, | ||
| 237 | ); | ||
| 130 | 238 | ||
| 131 | for (logical_path, asset) in &manifest.objects { | 239 | await_download_tasks(&mut tasks).await |
| 132 | let subdir = &asset.hash[0..2]; | 240 | } |
| 133 | let file_path = assets_dir | ||
| 134 | .join("objects") | ||
| 135 | .join(subdir) | ||
| 136 | .join(&asset.hash); | ||
| 137 | 241 | ||
| 138 | if file_path.exists() { | 242 | fn spawn_missing_download_jobs( |
| 243 | http_client: &reqwest::Client, | ||
| 244 | download_jobs: Vec<DownloadJob>, | ||
| 245 | completed_jobs_counter: &Arc<AtomicUsize>, | ||
| 246 | total_jobs_count: usize, | ||
| 247 | concurrent_download_semaphore: Arc<Semaphore>, | ||
| 248 | ) -> FuturesUnordered<JoinHandle<Result<(), McError>>> { | ||
| 249 | let download_tasks: FuturesUnordered<JoinHandle<Result<(), McError>>> = | ||
| 250 | FuturesUnordered::new(); | ||
| 251 | |||
| 252 | for job in download_jobs { | ||
| 253 | if job.already_exists() { | ||
| 254 | completed_jobs_counter.fetch_add(1, SeqCst); | ||
| 255 | print_download_progress(completed_jobs_counter, total_jobs_count); | ||
| 139 | continue; | 256 | continue; |
| 140 | } | 257 | } |
| 141 | 258 | ||
| 142 | let url = format!( | 259 | download_tasks.push(spawn_download_job( |
| 143 | "https://resources.download.minecraft.net/{}/{}", | 260 | http_client.clone(), |
| 144 | subdir, asset.hash | 261 | job, |
| 145 | ); | 262 | concurrent_download_semaphore.clone(), |
| 146 | info!("Downloading asset {} -> {}", logical_path, file_path.display()); | 263 | completed_jobs_counter.clone(), |
| 147 | download_file(&url, &file_path).await?; | 264 | total_jobs_count, |
| 265 | )); | ||
| 148 | } | 266 | } |
| 149 | 267 | ||
| 150 | if let Some(asset) = manifest.objects.get("sounds.json") { | 268 | download_tasks |
| 151 | let file_path = assets_dir.join("indexes").join("sounds.json"); | 269 | } |
| 152 | if !file_path.exists() { | 270 | |
| 153 | let subdir = &asset.hash[0..2]; | 271 | async fn await_download_tasks( |
| 154 | let url = format!( | 272 | tasks: &mut FuturesUnordered<JoinHandle<Result<(), McError>>>, |
| 155 | "https://resources.download.minecraft.net/{}/{}", | 273 | ) -> Result<(), McError> { |
| 156 | subdir, asset.hash | 274 | while let Some(task_result) = tasks.next().await { |
| 157 | ); | 275 | task_result.map_err(McError::from)??; |
| 158 | info!("Downloading sounds.json"); | ||
| 159 | download_file(&url, &file_path).await?; | ||
| 160 | } | ||
| 161 | } | 276 | } |
| 277 | Ok(()) | ||
| 278 | } | ||
| 162 | 279 | ||
| 280 | fn spawn_download_job( | ||
| 281 | http_client: reqwest::Client, | ||
| 282 | download_job: DownloadJob, | ||
| 283 | concurrent_download_semaphore: Arc<Semaphore>, | ||
| 284 | completed_jobs_count: Arc<AtomicUsize>, | ||
| 285 | total_jobs_count: usize, | ||
| 286 | ) -> JoinHandle<Result<(), McError>> { | ||
| 287 | spawn(async move { | ||
| 288 | let _permit = concurrent_download_semaphore | ||
| 289 | .acquire_owned() | ||
| 290 | .await | ||
| 291 | .unwrap(); | ||
| 292 | download_file(&http_client, &download_job).await?; | ||
| 293 | completed_jobs_count.fetch_add(1, SeqCst); | ||
| 294 | print_download_progress(&completed_jobs_count, total_jobs_count); | ||
| 295 | Ok(()) | ||
| 296 | }) | ||
| 297 | } | ||
| 298 | |||
| 299 | /// Print the current progress of downloads to stdout. | ||
| 300 | /// | ||
| 301 | /// # Parameters | ||
| 302 | /// - `completed_jobs_count`: Atomic counter of completed download jobs. | ||
| 303 | /// - `total_jobs_count`: Total number of jobs being processed. | ||
| 304 | fn print_download_progress( | ||
| 305 | completed_jobs_count: &AtomicUsize, | ||
| 306 | total_jobs_count: usize, | ||
| 307 | ) { | ||
| 308 | let completed_jobs: usize = completed_jobs_count.load(ATOMIC_ORDERING); | ||
| 309 | let progress_percentage: f64 = ((completed_jobs as f64 | ||
| 310 | / total_jobs_count as f64) | ||
| 311 | * MAX_PROGRESS_PERCENT) | ||
| 312 | .min(MAX_PROGRESS_PERCENT); | ||
| 313 | |||
| 314 | print!( | ||
| 315 | "\rDownloading game files: {:>width$.0}%", | ||
| 316 | progress_percentage, | ||
| 317 | width = PROGRESS_PRINT_WIDTH | ||
| 318 | ); | ||
| 319 | stdout().flush().unwrap(); | ||
| 320 | } | ||
| 321 | |||
| 322 | async fn download_text_file( | ||
| 323 | http_client: &reqwest::Client, | ||
| 324 | file_url: &str, | ||
| 325 | destination_path: &PathBuf, | ||
| 326 | ) -> Result<(), McError> { | ||
| 327 | let text_content = http_client | ||
| 328 | .get(file_url) | ||
| 329 | .send() | ||
| 330 | .await? | ||
| 331 | .error_for_status()? | ||
| 332 | .text() | ||
| 333 | .await?; | ||
| 334 | write(destination_path, text_content).await?; | ||
| 163 | Ok(()) | 335 | Ok(()) |
| 164 | } | 336 | } |
| 165 | 337 | ||
| 166 | async fn download_file( | 338 | async fn download_file( |
| 167 | url: &str, | 339 | http_client: &reqwest::Client, |
| 168 | path: &std::path::Path, | 340 | download_job: &DownloadJob, |
| 169 | ) -> Result<(), McError> { | 341 | ) -> Result<(), McError> { |
| 170 | if let Some(parent) = path.parent() { | 342 | if let Some(parent_dir) = download_job.destination_path.parent() { |
| 171 | create_dir_all(parent).await?; | 343 | create_dir_all(parent_dir).await?; |
| 172 | } | 344 | } |
| 173 | 345 | ||
| 174 | let response = get(url).await?; | 346 | let response: Response = http_client |
| 175 | let bytes = response.bytes().await?; | 347 | .get(&download_job.url) |
| 176 | 348 | .send() | |
| 177 | let mut file = File::create(path).await?; | 349 | .await? |
| 178 | file.write_all(&bytes).await?; | 350 | .error_for_status()?; |
| 351 | let mut byte_stream = response.bytes_stream(); | ||
| 352 | let mut file_handle: File = | ||
| 353 | File::create(&download_job.destination_path).await?; | ||
| 354 | |||
| 355 | while let Some(chunk) = byte_stream.next().await { | ||
| 356 | file_handle.write_all(&chunk?).await?; | ||
| 357 | } | ||
| 179 | 358 | ||
| 180 | Ok(()) | 359 | Ok(()) |
| 181 | } | 360 | } |
