aboutsummaryrefslogtreecommitdiffstats
path: root/src/minecraft
diff options
context:
space:
mode:
authorFilip Wandzio <contact@philw.dev>2026-02-25 16:10:23 +0100
committerFilip Wandzio <contact@philw.dev>2026-02-25 16:10:23 +0100
commitf7b4b643ebc52a4d72d90d9adbdddc9aa0721e4a (patch)
treec96432be342b02bc0409e5b78b6b5d54afcc7cd6 /src/minecraft
parent2e10b0713f5369f489d2ababd70108cc359c5d2d (diff)
downloaddml-f7b4b643ebc52a4d72d90d9adbdddc9aa0721e4a.tar.gz
dml-f7b4b643ebc52a4d72d90d9adbdddc9aa0721e4a.zip
Feat: Refactor core download logic with concurrency and async features
Implement basic unit testing Implement automatic java executable switching based on game version Split loader module into smaller modules Implement basic documentation
Diffstat (limited to 'src/minecraft')
-rw-r--r--src/minecraft/downloads.rs423
-rw-r--r--src/minecraft/extraction.rs70
-rw-r--r--src/minecraft/launcher.rs262
-rw-r--r--src/minecraft/manifests.rs41
4 files changed, 549 insertions, 247 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}
diff --git a/src/minecraft/extraction.rs b/src/minecraft/extraction.rs
index b58fd2e..292566f 100644
--- a/src/minecraft/extraction.rs
+++ b/src/minecraft/extraction.rs
@@ -1,11 +1,17 @@
1use std::{fs, io, path::Path}; 1use std::{
2 collections::HashMap,
3 fs,
4 fs::File,
5 io,
6 path::{Path, PathBuf},
7};
2 8
3use log::info; 9use zip::{read::ZipFile, ZipArchive};
4use zip::ZipArchive;
5 10
6use crate::{ 11use crate::{
7 errors::McError, 12 errors::McError,
8 minecraft::manifests::{Library, Version}, 13 minecraft::manifests::{LibraryArtifact, Version},
14 util::fs::library_allowed,
9}; 15};
10 16
11pub fn extract_natives( 17pub fn extract_natives(
@@ -19,8 +25,6 @@ pub fn extract_natives(
19 .join(&version.id) 25 .join(&version.id)
20 .join("natives"); 26 .join("natives");
21 27
22 info!("Extracting natives for {} into {:?}", version.id, natives_dir);
23
24 if natives_dir.exists() { 28 if natives_dir.exists() {
25 fs::remove_dir_all(&natives_dir)?; 29 fs::remove_dir_all(&natives_dir)?;
26 } 30 }
@@ -31,75 +35,51 @@ pub fn extract_natives(
31 continue; 35 continue;
32 } 36 }
33 37
34 let natives = match &lib.natives { 38 let natives: &HashMap<String, String> = match &lib.natives {
35 | Some(n) => n, 39 | Some(n) => n,
36 | None => continue, 40 | None => continue,
37 }; 41 };
38 42
39 let classifier = match natives.get("linux") { 43 let classifier: &String = match natives.get("linux") {
40 | Some(c) => c, 44 | Some(c) => c,
41 | None => continue, 45 | None => continue,
42 }; 46 };
43 47
44 let classifiers = match &lib.downloads.classifiers { 48 let classifiers: &HashMap<String, LibraryArtifact> =
45 | Some(c) => c, 49 match &lib.downloads.classifiers {
46 | None => continue, 50 | Some(c) => c,
47 }; 51 | None => continue,
52 };
48 53
49 let artifact = match classifiers.get(classifier) { 54 let artifact: &LibraryArtifact = match classifiers.get(classifier) {
50 | Some(a) => a, 55 | Some(a) => a,
51 | None => continue, 56 | None => continue,
52 }; 57 };
53 58
54 let jar_path = cfg 59 let jar_path: PathBuf = cfg
55 .data_dir 60 .data_dir
56 .join("minecraft") 61 .join("minecraft")
57 .join("libraries") 62 .join("libraries")
58 .join(&artifact.path); 63 .join(&artifact.path);
59 64
60 info!("Extracting natives from {:?}", jar_path);
61
62 extract_zip(&jar_path, &natives_dir)?; 65 extract_zip(&jar_path, &natives_dir)?;
63 } 66 }
64 67
65 Ok(()) 68 Ok(())
66} 69}
67
68fn library_allowed(lib: &Library) -> bool {
69 let rules = match &lib.rules {
70 | Some(r) => r,
71 | None => return true,
72 };
73
74 let mut allowed = false;
75
76 for rule in rules {
77 let os_match = match &rule.os {
78 | Some(os) => os.name == "linux",
79 | None => true,
80 };
81
82 if os_match {
83 allowed = rule.action == "allow";
84 }
85 }
86
87 allowed
88}
89
90fn extract_zip(jar_path: &Path, out_dir: &Path) -> Result<(), McError> { 70fn extract_zip(jar_path: &Path, out_dir: &Path) -> Result<(), McError> {
91 let file = fs::File::open(jar_path)?; 71 let file: File = File::open(jar_path)?;
92 let mut zip = ZipArchive::new(file)?; 72 let mut zip: ZipArchive<File> = ZipArchive::new(file)?;
93 73
94 for i in 0..zip.len() { 74 for i in 0..zip.len() {
95 let mut entry = zip.by_index(i)?; 75 let mut entry: ZipFile<File> = zip.by_index(i)?;
96 let name = entry.name(); 76 let name: &str = entry.name();
97 77
98 if name.starts_with("META-INF/") { 78 if name.starts_with("META-INF/") {
99 continue; 79 continue;
100 } 80 }
101 81
102 let out_path = out_dir.join(name); 82 let out_path: PathBuf = out_dir.join(name);
103 83
104 if entry.is_dir() { 84 if entry.is_dir() {
105 fs::create_dir_all(&out_path)?; 85 fs::create_dir_all(&out_path)?;
@@ -110,7 +90,7 @@ fn extract_zip(jar_path: &Path, out_dir: &Path) -> Result<(), McError> {
110 fs::create_dir_all(parent)?; 90 fs::create_dir_all(parent)?;
111 } 91 }
112 92
113 let mut out_file = fs::File::create(&out_path)?; 93 let mut out_file: File = File::create(&out_path)?;
114 io::copy(&mut entry, &mut out_file)?; 94 io::copy(&mut entry, &mut out_file)?;
115 } 95 }
116 96
diff --git a/src/minecraft/launcher.rs b/src/minecraft/launcher.rs
index 2eeaf21..3cca948 100644
--- a/src/minecraft/launcher.rs
+++ b/src/minecraft/launcher.rs
@@ -1,64 +1,104 @@
1use std::process::Command; 1use paths::{client_jar, library_file, natives_dir};
2 2use serde::Deserialize;
3use log::{debug, info}; 3use std::path::Path;
4use std::process::Output;
5use std::{
6 path::PathBuf,
7 process::{Command, ExitStatus},
8};
9use McError::Process;
4 10
5use crate::{ 11use crate::{
6 config::Config, 12 config::Config, errors::McError, minecraft::manifests::Version,
7 errors::McError, 13 platform::paths, util::fs::library_allowed,
8 minecraft::manifests::{Library, Version},
9 platform::paths,
10}; 14};
11 15
12fn build_classpath( 16#[derive(Debug, Clone, Deserialize)]
13 config: &Config, 17pub struct JavaRuntime {
14 version: &Version, 18 pub major: u8,
15) -> Result<String, McError> { 19 pub path: PathBuf,
16 let sep = if cfg!(windows) { ";" } else { ":" }; 20}
17 let mut entries = Vec::new(); 21
22impl JavaRuntime {
23 fn validate(&self) -> Result<(), McError> {
24 let output: Output = Command::new(&self.path)
25 .arg("-version")
26 .output()
27 .map_err(|e| {
28 McError::Runtime(format!(
29 "Failed to execute Java at {}: {}",
30 self.path.display(),
31 e
32 ))
33 })?;
34
35 if !output.status.success() {
36 return Err(McError::Runtime(format!(
37 "Invalid Java binary: {}",
38 self.path.display()
39 )));
40 }
41
42 Ok(())
43 }
44}
45
46fn required_java_major(java_major: Option<u8>) -> u8 {
47 java_major.unwrap_or(8)
48}
49
50fn resolve_runtime(
51 required: u8,
52 runtimes: &[JavaRuntime],
53) -> Result<PathBuf, McError> {
54 let mut candidates: Vec<&JavaRuntime> = runtimes
55 .iter()
56 .filter(|r| r.major >= required)
57 .collect();
58
59 candidates.sort_by_key(|r| r.major);
60
61 let runtime = candidates.first().ok_or_else(|| {
62 McError::Runtime(format!(
63 "No suitable Java runtime found (required: Java {})",
64 required
65 ))
66 })?;
67
68 runtime.validate()?;
69 Ok(runtime.path.clone())
70}
71
72fn build_classpath(config: &Config, version: &Version) -> String {
73 let system_separator: &str = if cfg!(windows) { ";" } else { ":" };
74 let mut entries: Vec<String> = Vec::new();
18 75
19 for library in &version.libraries { 76 for library in &version.libraries {
20 if !library_allowed(library) { 77 if !library_allowed(library) {
21 continue; 78 continue;
22 } 79 }
80
23 if let Some(artifact) = &library.downloads.artifact { 81 if let Some(artifact) = &library.downloads.artifact {
24 let path = paths::library_file(config, &artifact.path)?; 82 let path = library_file(config, &artifact.path);
25 entries.push(path.to_string_lossy().to_string()); 83 entries.push(path.to_string_lossy().to_string());
26 } 84 }
27 } 85 }
28 86
29 let client_jar = paths::client_jar(config, &version.id)?; 87 let client: PathBuf = client_jar(config, &version.id);
30 entries.push(client_jar.to_string_lossy().to_string()); 88 entries.push(client.to_string_lossy().to_string());
31 89
32 Ok(entries.join(sep)) 90 entries.join(system_separator)
33} 91}
34 92
35pub fn launch(config: &Config, version: &Version) -> Result<(), McError> { 93fn build_command(
36 let java = &config.java_path; 94 java_path: PathBuf,
37 let classpath = build_classpath(config, version)?; 95 config: &Config,
38 let natives_dir = paths::natives_dir(config, &version.id); 96 version: &Version,
39 97 natives_dir: &Path,
40 if !natives_dir.exists() { 98 classpath: String,
41 return Err(McError::Runtime(format!( 99 asset_index_id: String,
42 "Natives folder does not exist: {}", 100) -> Command {
43 natives_dir.display() 101 let mut cmd: Command = Command::new(java_path);
44 )));
45 }
46
47 let asset_index_id = version
48 .asset_index
49 .as_ref()
50 .ok_or_else(|| {
51 McError::Runtime("Missing assetIndex in version.json".into())
52 })?
53 .id
54 .clone();
55
56 info!("Launching Minecraft {}", version.id);
57 debug!("Classpath: {}", classpath);
58 debug!("Natives: {}", natives_dir.display());
59 debug!("Asset index: {}", asset_index_id);
60
61 let mut cmd = Command::new(java);
62 102
63 cmd.arg(format!("-Xmx{}M", config.max_memory_mb)) 103 cmd.arg(format!("-Xmx{}M", config.max_memory_mb))
64 .arg(format!("-Djava.library.path={}", natives_dir.display())); 104 .arg(format!("-Djava.library.path={}", natives_dir.display()));
@@ -76,11 +116,11 @@ pub fn launch(config: &Config, version: &Version) -> Result<(), McError> {
76 .arg("--version") 116 .arg("--version")
77 .arg(&version.id) 117 .arg(&version.id)
78 .arg("--gameDir") 118 .arg("--gameDir")
79 .arg(paths::game_dir(config)) 119 .arg(paths::game_directory(config))
80 .arg("--assetsDir") 120 .arg("--assetsDir")
81 .arg(paths::assets_dir(config)) 121 .arg(paths::assets_directory(config))
82 .arg("--assetIndex") 122 .arg("--assetIndex")
83 .arg(&asset_index_id) 123 .arg(asset_index_id)
84 .arg("--uuid") 124 .arg("--uuid")
85 .arg(&config.uuid) 125 .arg(&config.uuid)
86 .arg("--userProperties") 126 .arg("--userProperties")
@@ -90,32 +130,128 @@ pub fn launch(config: &Config, version: &Version) -> Result<(), McError> {
90 .arg("--userType") 130 .arg("--userType")
91 .arg("legacy"); 131 .arg("legacy");
92 132
93 let status = cmd.status()?; 133 cmd
134}
135
136pub fn launch(config: &Config, version: &Version) -> Result<(), McError> {
137 let required: u8 = required_java_major(
138 version
139 .java_version
140 .as_ref()
141 .map(|j| j.major_version),
142 );
143
144 let java_path: PathBuf = resolve_runtime(required, &config.runtimes)?;
145
146 let classpath: String = build_classpath(config, version);
147 let natives_dir: PathBuf = natives_dir(config, &version.id);
148
149 if !natives_dir.exists() {
150 return Err(McError::Runtime(format!(
151 "Natives folder does not exist: {}",
152 natives_dir.display()
153 )));
154 }
155
156 let asset_index_id = version
157 .asset_index
158 .as_ref()
159 .ok_or_else(|| {
160 McError::Runtime("Missing assetIndex in version.json".into())
161 })?
162 .id
163 .clone();
164
165 let mut cmd: Command = build_command(
166 java_path,
167 config,
168 version,
169 &natives_dir,
170 classpath,
171 asset_index_id,
172 );
173
174 let status: ExitStatus = cmd.status()?;
94 175
95 if !status.success() { 176 if !status.success() {
96 return Err(McError::Process("Minecraft exited with error".into())); 177 return Err(Process("Minecraft exited with error".into()));
97 } 178 }
98 179
99 Ok(()) 180 Ok(())
100} 181}
101 182
102fn library_allowed(lib: &Library) -> bool { 183#[cfg(test)]
103 let rules = match &lib.rules { 184mod tests {
104 | Some(r) => r, 185 use super::*;
105 | None => return true, 186 use std::path::PathBuf;
106 }; 187
188 #[test]
189 fn required_java_major_defaults_to_8() {
190 assert_eq!(required_java_major(None), 8);
191 }
192
193 #[test]
194 fn required_java_major_uses_given_value() {
195 assert_eq!(required_java_major(Some(17)), 17);
196 }
197
198 #[test]
199 fn resolve_runtime_errors_when_no_runtimes() {
200 let runtimes: Vec<JavaRuntime> = vec![];
201 assert!(resolve_runtime(17, &runtimes).is_err());
202 }
203
204 #[test]
205 fn resolve_runtime_picks_lowest_matching_major() {
206 let runtimes: Vec<JavaRuntime> = vec![
207 JavaRuntime {
208 major: 21,
209 path: PathBuf::from("/bin/true"),
210 },
211 JavaRuntime {
212 major: 17,
213 path: PathBuf::from("/bin/true"),
214 },
215 ];
216
217 let result: PathBuf = resolve_runtime(8, &runtimes).unwrap();
218 assert_eq!(result, PathBuf::from("/bin/true"));
219 }
107 220
108 let mut allowed = false; 221 #[test]
222 fn resolve_runtime_respects_required_version() {
223 let runtimes: Vec<JavaRuntime> = vec![
224 JavaRuntime {
225 major: 8,
226 path: PathBuf::from("/bin/true"),
227 },
228 JavaRuntime {
229 major: 17,
230 path: PathBuf::from("/bin/true"),
231 },
232 ];
233
234 let result: PathBuf = resolve_runtime(17, &runtimes).unwrap();
235 assert_eq!(result, PathBuf::from("/bin/true"));
236 }
109 237
110 for rule in rules { 238 #[test]
111 let os_match = match &rule.os { 239 fn validate_succeeds_for_true_binary() {
112 | Some(os) => os.name == "linux", 240 let runtime = JavaRuntime {
113 | None => true, 241 major: 17,
242 path: PathBuf::from("/bin/true"),
114 }; 243 };
115 if os_match { 244
116 allowed = rule.action == "allow"; 245 assert!(runtime.validate().is_ok());
117 }
118 } 246 }
119 247
120 allowed 248 #[test]
249 fn validate_fails_for_false_binary() {
250 let runtime = JavaRuntime {
251 major: 17,
252 path: PathBuf::from("/bin/false"),
253 };
254
255 assert!(runtime.validate().is_err());
256 }
121} 257}
diff --git a/src/minecraft/manifests.rs b/src/minecraft/manifests.rs
index 8bdec26..64e38da 100644
--- a/src/minecraft/manifests.rs
+++ b/src/minecraft/manifests.rs
@@ -1,11 +1,18 @@
1#![allow(dead_code)] 1#![allow(dead_code)]
2 2
3use std::collections::HashMap; 3use crate::{constants::VERSION_MANIFEST_URL, errors::McError};
4 4use reqwest::get;
5use reqwest;
6use serde::Deserialize; 5use serde::Deserialize;
6use serde_json::{from_str, Value};
7use std::collections::HashMap;
8use McError::Config;
7 9
8use crate::{constants::VERSION_MANIFEST_URL, errors::McError}; 10#[derive(Debug, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct JavaVersionInfo {
13 pub component: String,
14 pub major_version: u8,
15}
9 16
10#[derive(Debug, Deserialize)] 17#[derive(Debug, Deserialize)]
11pub struct Version { 18pub struct Version {
@@ -19,6 +26,10 @@ pub struct Version {
19 26
20 #[serde(rename = "assetIndex")] 27 #[serde(rename = "assetIndex")]
21 pub asset_index: Option<AssetIndex>, 28 pub asset_index: Option<AssetIndex>,
29
30 #[serde(default)]
31 #[serde(rename = "javaVersion")]
32 pub java_version: Option<JavaVersionInfo>,
22} 33}
23 34
24#[derive(Debug, Deserialize)] 35#[derive(Debug, Deserialize)]
@@ -83,15 +94,13 @@ pub struct OsRule {
83pub async fn load_version( 94pub async fn load_version(
84 cfg: &crate::config::Config, 95 cfg: &crate::config::Config,
85) -> Result<Version, McError> { 96) -> Result<Version, McError> {
86 let manifest_text = reqwest::get(VERSION_MANIFEST_URL) 97 let manifest_text = get(VERSION_MANIFEST_URL).await?.text().await?;
87 .await? 98 let root: Value = from_str(&manifest_text)?;
88 .text() 99
89 .await?;
90 let root: serde_json::Value = serde_json::from_str(&manifest_text)?;
91 let version_id = if cfg.version == "latest" { 100 let version_id = if cfg.version == "latest" {
92 root["latest"]["release"] 101 root["latest"]["release"]
93 .as_str() 102 .as_str()
94 .ok_or_else(|| McError::Config("missing latest.release".into()))? 103 .ok_or_else(|| Config("missing latest.release".into()))?
95 .to_string() 104 .to_string()
96 } else { 105 } else {
97 cfg.version.clone() 106 cfg.version.clone()
@@ -99,21 +108,19 @@ pub async fn load_version(
99 108
100 let versions = root["versions"] 109 let versions = root["versions"]
101 .as_array() 110 .as_array()
102 .ok_or_else(|| McError::Config("missing versions array".into()))?; 111 .ok_or_else(|| Config("missing versions array".into()))?;
103 112
104 let version_entry = versions 113 let version_entry = versions
105 .iter() 114 .iter()
106 .find(|v| v["id"].as_str() == Some(&version_id)) 115 .find(|v| v["id"].as_str() == Some(&version_id))
107 .ok_or_else(|| { 116 .ok_or_else(|| Config(format!("version '{}' not found", version_id)))?;
108 McError::Config(format!("version '{}' not found", version_id))
109 })?;
110 117
111 let url = version_entry["url"] 118 let url = version_entry["url"]
112 .as_str() 119 .as_str()
113 .ok_or_else(|| McError::Config("missing version url".into()))?; 120 .ok_or_else(|| Config("missing version url".into()))?;
114 121
115 let version_text = reqwest::get(url).await?.text().await?; 122 let version_text = get(url).await?.text().await?;
116 let version: Version = serde_json::from_str(&version_text)?; 123 let version: Version = from_str(&version_text)?;
117 124
118 Ok(version) 125 Ok(version)
119} 126}