diff options
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 | } |
