diff options
| author | Filip Wandzio <contact@philw.dev> | 2026-02-25 16:10:23 +0100 |
|---|---|---|
| committer | Filip Wandzio <contact@philw.dev> | 2026-02-25 16:10:23 +0100 |
| commit | f7b4b643ebc52a4d72d90d9adbdddc9aa0721e4a (patch) | |
| tree | c96432be342b02bc0409e5b78b6b5d54afcc7cd6 /src/minecraft/launcher.rs | |
| parent | 2e10b0713f5369f489d2ababd70108cc359c5d2d (diff) | |
| download | dml-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/launcher.rs')
| -rw-r--r-- | src/minecraft/launcher.rs | 262 |
1 files changed, 199 insertions, 63 deletions
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 @@ | |||
| 1 | use std::process::Command; | 1 | use paths::{client_jar, library_file, natives_dir}; |
| 2 | 2 | use serde::Deserialize; | |
| 3 | use log::{debug, info}; | 3 | use std::path::Path; |
| 4 | use std::process::Output; | ||
| 5 | use std::{ | ||
| 6 | path::PathBuf, | ||
| 7 | process::{Command, ExitStatus}, | ||
| 8 | }; | ||
| 9 | use McError::Process; | ||
| 4 | 10 | ||
| 5 | use crate::{ | 11 | use 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 | ||
| 12 | fn build_classpath( | 16 | #[derive(Debug, Clone, Deserialize)] |
| 13 | config: &Config, | 17 | pub 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 | |
| 22 | impl 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 | |||
| 46 | fn required_java_major(java_major: Option<u8>) -> u8 { | ||
| 47 | java_major.unwrap_or(8) | ||
| 48 | } | ||
| 49 | |||
| 50 | fn 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 | |||
| 72 | fn 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 | ||
| 35 | pub fn launch(config: &Config, version: &Version) -> Result<(), McError> { | 93 | fn 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 | |||
| 136 | pub 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 | ||
| 102 | fn library_allowed(lib: &Library) -> bool { | 183 | #[cfg(test)] |
| 103 | let rules = match &lib.rules { | 184 | mod 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 | } |
