aboutsummaryrefslogtreecommitdiffstats
path: root/src/minecraft/downloads.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/minecraft/downloads.rs')
-rw-r--r--src/minecraft/downloads.rs423
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 @@
1use log::{debug, info}; 1use std::{
2use reqwest::get; 2 collections::HashMap,
3 io::{Write, stdout},
4 path::{Path, PathBuf},
5 sync::{
6 Arc,
7 atomic::{AtomicUsize, Ordering},
8 },
9};
10
11use McError::Config;
12use Ordering::SeqCst;
13use futures::stream::{FuturesUnordered, StreamExt};
14use reqwest::Response;
3use serde::Deserialize; 15use serde::Deserialize;
4use tokio::{ 16use 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
9use crate::{ 24use 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
31const MAX_CONCURRENT_DOWNLOADS: usize = 100;
32const PROGRESS_INITIAL_VALUE: usize = 0;
33const ASSET_URL_BASE: &str = "https://resources.download.minecraft.net/";
34const MAX_PROGRESS_PERCENT: f64 = 100.0;
35const PROGRESS_PRINT_WIDTH: usize = 3;
36const ATOMIC_ORDERING: Ordering = SeqCst;
37const DOWNLOAD_COMPLETE_MESSAGE: &str = "All downloads completed successfully!";
38const ASSET_INDEX_MISSING_ERROR: &str = "Missing asset_index for the version";
39
40impl 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)]
49struct DownloadJob {
50 url: String,
51 destination_path: PathBuf,
52}
53
54impl 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)]
17struct AssetObject { 60struct 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)]
23struct AssetIndexManifest { 66struct AssetIndexManifest {
24 objects: std::collections::HashMap<String, AssetObject>, 67 objects: HashMap<String, AssetObject>,
25} 68}
26 69
27pub async fn download_all( 70/// Download all files required to run a specific Minecraft version.
28 config: &Config, 71pub 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
37async fn download_client( 98/// Ensure the essential assets directories exist.
38 config: &Config, 99async 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
52async fn download_libraries( 108/// Load the asset index manifest for the given Minecraft version.
53 config: &Config, 109async 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
83async fn download_asset_index( 132fn 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 147fn 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() { 158fn 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}
168fn 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); 184fn 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)?; 203fn 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
120async fn download_assets( 220async 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() { 242fn 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]; 271async 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
280fn 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.
304fn 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
322async 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
166async fn download_file( 338async 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}