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 | |
| 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')
| -rw-r--r-- | src/minecraft/downloads.rs | 423 | ||||
| -rw-r--r-- | src/minecraft/extraction.rs | 70 | ||||
| -rw-r--r-- | src/minecraft/launcher.rs | 262 | ||||
| -rw-r--r-- | src/minecraft/manifests.rs | 41 |
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 @@ | |||
| 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 | } |
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 @@ | |||
| 1 | use std::{fs, io, path::Path}; | 1 | use std::{ |
| 2 | collections::HashMap, | ||
| 3 | fs, | ||
| 4 | fs::File, | ||
| 5 | io, | ||
| 6 | path::{Path, PathBuf}, | ||
| 7 | }; | ||
| 2 | 8 | ||
| 3 | use log::info; | 9 | use zip::{read::ZipFile, ZipArchive}; |
| 4 | use zip::ZipArchive; | ||
| 5 | 10 | ||
| 6 | use crate::{ | 11 | use 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 | ||
| 11 | pub fn extract_natives( | 17 | pub 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 | |||
| 68 | fn 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 | |||
| 90 | fn extract_zip(jar_path: &Path, out_dir: &Path) -> Result<(), McError> { | 70 | fn 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 @@ | |||
| 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 | } |
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 | ||
| 3 | use std::collections::HashMap; | 3 | use crate::{constants::VERSION_MANIFEST_URL, errors::McError}; |
| 4 | 4 | use reqwest::get; | |
| 5 | use reqwest; | ||
| 6 | use serde::Deserialize; | 5 | use serde::Deserialize; |
| 6 | use serde_json::{from_str, Value}; | ||
| 7 | use std::collections::HashMap; | ||
| 8 | use McError::Config; | ||
| 7 | 9 | ||
| 8 | use crate::{constants::VERSION_MANIFEST_URL, errors::McError}; | 10 | #[derive(Debug, Deserialize)] |
| 11 | #[serde(rename_all = "camelCase")] | ||
| 12 | pub struct JavaVersionInfo { | ||
| 13 | pub component: String, | ||
| 14 | pub major_version: u8, | ||
| 15 | } | ||
| 9 | 16 | ||
| 10 | #[derive(Debug, Deserialize)] | 17 | #[derive(Debug, Deserialize)] |
| 11 | pub struct Version { | 18 | pub 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 { | |||
| 83 | pub async fn load_version( | 94 | pub 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 | } |
