diff options
Diffstat (limited to '')
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | .run/Run dml.run.xml | 20 | ||||
| -rw-r--r-- | Cargo.toml | 4 | ||||
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | src/config/file.rs | 26 | ||||
| -rw-r--r-- | src/config/loader.rs | 129 | ||||
| -rw-r--r-- | src/config/mod.rs | 17 | ||||
| -rw-r--r-- | src/config/runtime.rs | 95 | ||||
| -rw-r--r-- | src/constants.rs | 110 | ||||
| -rw-r--r-- | src/errors.rs | 320 | ||||
| -rw-r--r-- | src/main.rs | 105 | ||||
| -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 | ||||
| -rw-r--r-- | src/platform/paths.rs | 270 | ||||
| -rw-r--r-- | src/util/fs.rs | 26 | ||||
| -rw-r--r-- | src/util/sha1.rs | 7 |
18 files changed, 1560 insertions, 377 deletions
| @@ -7,3 +7,4 @@ Cargo.lock | |||
| 7 | *.pdb | 7 | *.pdb |
| 8 | rust-project.json | 8 | rust-project.json |
| 9 | *.png | 9 | *.png |
| 10 | .idea | ||
diff --git a/.run/Run dml.run.xml b/.run/Run dml.run.xml new file mode 100644 index 0000000..dd66d28 --- /dev/null +++ b/.run/Run dml.run.xml | |||
| @@ -0,0 +1,20 @@ | |||
| 1 | <component name="ProjectRunConfigurationManager"> | ||
| 2 | <configuration default="false" name="Run dml" type="CargoCommandRunConfiguration" factoryName="Cargo Command"> | ||
| 3 | <option name="buildProfileId" value="dev" /> | ||
| 4 | <option name="command" value="run --package dml --bin dml" /> | ||
| 5 | <option name="workingDirectory" value="file://$PROJECT_DIR$" /> | ||
| 6 | <envs /> | ||
| 7 | <option name="emulateTerminal" value="true" /> | ||
| 8 | <option name="channel" value="STABLE" /> | ||
| 9 | <option name="requiredFeatures" value="true" /> | ||
| 10 | <option name="allFeatures" value="true" /> | ||
| 11 | <option name="withSudo" value="true" /> | ||
| 12 | <option name="buildTarget" value="REMOTE" /> | ||
| 13 | <option name="backtrace" value="SHORT" /> | ||
| 14 | <option name="isRedirectInput" value="false" /> | ||
| 15 | <option name="redirectInputPath" value="" /> | ||
| 16 | <method v="2"> | ||
| 17 | <option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" /> | ||
| 18 | </method> | ||
| 19 | </configuration> | ||
| 20 | </component> \ No newline at end of file | ||
| @@ -8,10 +8,8 @@ clap = { version = "4.5.54", features = ["derive"] } | |||
| 8 | directories = "6.0.0" | 8 | directories = "6.0.0" |
| 9 | dotenvy = "0.15.7" | 9 | dotenvy = "0.15.7" |
| 10 | env_logger = "0.11.8" | 10 | env_logger = "0.11.8" |
| 11 | futures-util = "0.3.31" | 11 | futures = "0.3.32" |
| 12 | indicatif = "0.18.3" | ||
| 13 | log = "0.4.29" | 12 | log = "0.4.29" |
| 14 | rayon = "1.11.0" | ||
| 15 | reqwest = { version = "0.13.1", features = ["json", "stream"] } | 13 | reqwest = { version = "0.13.1", features = ["json", "stream"] } |
| 16 | serde = { version = "1.0.228", features = ["derive"] } | 14 | serde = { version = "1.0.228", features = ["derive"] } |
| 17 | serde_json = "1.0.149" | 15 | serde_json = "1.0.149" |
| @@ -1,14 +1,13 @@ | |||
| 1 | # Very simple minecraft launcher (early stage of development). Made to explore concurrency/async in rust. | 1 | # Very simple minecraft launcher (early stage of development). Made to explore concurrency/async in rust. And because I don't like the default launcher. |
| 2 | 2 | ||
| 3 | ## Available features | 3 | ## Available features |
| 4 | 4 | ||
| 5 | - [x] playing any version > 1.10 | 5 | - [x] playing any version >1.7 and <1.15 |
| 6 | 6 | ||
| 7 | ## Planned features | 7 | ## Planned features |
| 8 | 8 | ||
| 9 | - [] "modern" minecraft | 9 | - [ ] Concurrent asset downloading |
| 10 | 10 | ||
| 11 | - [] modloaders | 11 | - [ ] modloaders |
| 12 | |||
| 13 | - [] Concurrent asset downloading | ||
| 14 | 12 | ||
| 13 | - [ ] "modern" minecraft | ||
diff --git a/src/config/file.rs b/src/config/file.rs new file mode 100644 index 0000000..17f2cb2 --- /dev/null +++ b/src/config/file.rs | |||
| @@ -0,0 +1,26 @@ | |||
| 1 | use std::path::PathBuf; | ||
| 2 | |||
| 3 | use serde::Deserialize; | ||
| 4 | |||
| 5 | use crate::minecraft::launcher::JavaRuntime; | ||
| 6 | |||
| 7 | #[derive(Debug, Deserialize)] | ||
| 8 | pub struct FileConfig { | ||
| 9 | pub username: String, | ||
| 10 | pub uuid: String, | ||
| 11 | pub version: String, | ||
| 12 | pub max_memory_mb: u32, | ||
| 13 | |||
| 14 | #[serde(default)] | ||
| 15 | pub jvm_args: Vec<String>, | ||
| 16 | |||
| 17 | #[serde(default)] | ||
| 18 | pub runtimes: Vec<JavaRuntime>, | ||
| 19 | |||
| 20 | #[serde(default)] | ||
| 21 | pub java_path: String, | ||
| 22 | |||
| 23 | pub data_dir: PathBuf, | ||
| 24 | // pub cache_dir: PathBuf, | ||
| 25 | // pub config_dir: PathBuf, | ||
| 26 | } | ||
diff --git a/src/config/loader.rs b/src/config/loader.rs index d4b142e..47514e5 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs | |||
| @@ -1,65 +1,90 @@ | |||
| 1 | use std::{env::var, fs::read_to_string, path::PathBuf}; | 1 | use std::{env, fs::read_to_string, path::PathBuf}; |
| 2 | |||
| 3 | use directories::ProjectDirs; | 2 | use directories::ProjectDirs; |
| 4 | use serde::Deserialize; | 3 | use uuid::Uuid; |
| 5 | 4 | ||
| 5 | use super::{file::FileConfig, runtime::RuntimeConfig}; | ||
| 6 | use crate::{constants::*, errors::McError}; | 6 | use crate::{constants::*, errors::McError}; |
| 7 | #[allow(dead_code)] | 7 | |
| 8 | #[derive(Debug, Deserialize)] | 8 | pub struct ConfigLoader; |
| 9 | pub struct Config { | 9 | |
| 10 | pub username: String, | 10 | impl ConfigLoader { |
| 11 | pub uuid: String, | 11 | pub fn load(cfg_file: Option<&PathBuf>) -> Result<RuntimeConfig, McError> { |
| 12 | pub version: String, | 12 | let path = match cfg_file { |
| 13 | pub java_path: String, | 13 | | Some(p) => p.clone(), |
| 14 | pub max_memory_mb: u32, | 14 | | None => Self::default_config_path()?, |
| 15 | pub data_dir: PathBuf, | 15 | }; |
| 16 | pub cache_dir: PathBuf, | 16 | |
| 17 | pub config_dir: PathBuf, | 17 | let mut file_cfg = if path.exists() { |
| 18 | #[serde(default)] | 18 | Self::read_file(&path)? |
| 19 | pub jvm_args: Vec<String>, | ||
| 20 | } | ||
| 21 | impl Config { | ||
| 22 | pub fn load() -> Result<Self, McError> { | ||
| 23 | let cfg_path = default_config_path()?; | ||
| 24 | let mut cfg: Config = if cfg_path.exists() { | ||
| 25 | let txt = read_to_string(&cfg_path)?; | ||
| 26 | toml::from_str(&txt).map_err(|e| McError::Config(e.to_string()))? | ||
| 27 | } else { | 19 | } else { |
| 28 | Self::default() | 20 | Self::default_file_config()? |
| 29 | }; | 21 | }; |
| 30 | if let Ok(v) = var("MC_USERNAME") { | 22 | |
| 31 | cfg.username = v; | 23 | Self::apply_env_overrides(&mut file_cfg); |
| 32 | } | 24 | |
| 33 | if let Ok(v) = var("MC_VERSION") { | 25 | Ok(RuntimeConfig::from_file(file_cfg)) |
| 34 | cfg.version = v; | 26 | } |
| 35 | } | 27 | |
| 36 | if let Ok(v) = var("MC_JAVA_PATH") { | 28 | fn read_file(path: &PathBuf) -> Result<FileConfig, McError> { |
| 37 | cfg.java_path = v; | 29 | let content = read_to_string(path).map_err(|e| { |
| 38 | } | 30 | McError::Config(format!( |
| 39 | if let Ok(v) = var("MC_MAX_MEMORY_MB") { | 31 | "Failed to read config file {}: {}", |
| 40 | cfg.max_memory_mb = v.parse().unwrap_or(cfg.max_memory_mb); | 32 | path.display(), |
| 41 | } | 33 | e |
| 42 | Ok(cfg) | 34 | )) |
| 35 | })?; | ||
| 36 | |||
| 37 | toml::from_str(&content).map_err(|e| { | ||
| 38 | McError::Config(format!("Failed to parse config file: {}", e)) | ||
| 39 | }) | ||
| 43 | } | 40 | } |
| 44 | 41 | ||
| 45 | fn default() -> Self { | 42 | fn apply_env_overrides(cfg: &mut FileConfig) { |
| 46 | let base = | 43 | cfg.username = env::var(ENV_USERNAME) |
| 47 | ProjectDirs::from("com", "example", "dml").expect("platform dirs"); | 44 | .unwrap_or_else(|_| DEFAULT_USERNAME.to_string()); |
| 48 | Self { | 45 | |
| 49 | username: "Player".into(), | 46 | cfg.version = env::var(ENV_VERSION) |
| 50 | uuid: uuid::Uuid::new_v4().to_string(), | 47 | .unwrap_or_else(|_| DEFAULT_VERSION.to_string()); |
| 48 | |||
| 49 | cfg.java_path = env::var(ENV_JAVA_PATH) | ||
| 50 | .unwrap_or_else(|_| DEFAULT_JAVA_PATH.to_string()); | ||
| 51 | |||
| 52 | cfg.max_memory_mb = env::var(ENV_MAX_MEMORY_MB) | ||
| 53 | .ok() | ||
| 54 | .and_then(|v| v.parse().ok()) | ||
| 55 | .unwrap_or(DEFAULT_MAX_MEMORY_MB); | ||
| 56 | } | ||
| 57 | |||
| 58 | fn default_config_path() -> Result<PathBuf, McError> { | ||
| 59 | let base = ProjectDirs::from( | ||
| 60 | DEFAULT_COMPANY, | ||
| 61 | DEFAULT_PROJECT_GROUP, | ||
| 62 | DEFAULT_PROJECT_NAME, | ||
| 63 | ) | ||
| 64 | .ok_or_else(|| McError::Config(DEFAULT_ERR_PLATFORM_DIR.into()))?; | ||
| 65 | |||
| 66 | Ok(base.config_dir().join(DEFAULT_CONFIG_FILENAME)) | ||
| 67 | } | ||
| 68 | |||
| 69 | fn default_file_config() -> Result<FileConfig, McError> { | ||
| 70 | let base = ProjectDirs::from( | ||
| 71 | DEFAULT_COMPANY, | ||
| 72 | DEFAULT_PROJECT_GROUP, | ||
| 73 | DEFAULT_PROJECT_NAME, | ||
| 74 | ) | ||
| 75 | .ok_or_else(|| McError::Config(DEFAULT_ERR_PLATFORM_DIR.into()))?; | ||
| 76 | |||
| 77 | Ok(FileConfig { | ||
| 78 | username: DEFAULT_USERNAME.into(), | ||
| 79 | uuid: Uuid::new_v4().to_string(), | ||
| 51 | version: DEFAULT_VERSION.into(), | 80 | version: DEFAULT_VERSION.into(), |
| 52 | java_path: DEFAULT_JAVA_PATH.into(), | ||
| 53 | max_memory_mb: DEFAULT_MAX_MEMORY_MB, | 81 | max_memory_mb: DEFAULT_MAX_MEMORY_MB, |
| 82 | java_path: DEFAULT_JAVA_PATH.into(), | ||
| 54 | data_dir: base.data_dir().into(), | 83 | data_dir: base.data_dir().into(), |
| 55 | cache_dir: base.cache_dir().into(), | 84 | // cache_dir: base.cache_dir().into(), |
| 56 | config_dir: base.config_dir().into(), | 85 | // config_dir: base.config_dir().into(), |
| 57 | jvm_args: vec![], | 86 | jvm_args: vec![], |
| 58 | } | 87 | runtimes: vec![], |
| 88 | }) | ||
| 59 | } | 89 | } |
| 60 | } | 90 | } |
| 61 | fn default_config_path() -> Result<PathBuf, McError> { | ||
| 62 | let base = ProjectDirs::from("com", "example", "dml") | ||
| 63 | .ok_or_else(|| McError::Config("cannot determine config dir".into()))?; | ||
| 64 | Ok(base.config_dir().join("config.toml")) | ||
| 65 | } | ||
diff --git a/src/config/mod.rs b/src/config/mod.rs index 066154a..a375dda 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs | |||
| @@ -1,12 +1,11 @@ | |||
| 1 | //! Configuration module for the DML launcher. | 1 | //! Configuration module for the DML launcher. |
| 2 | //! | ||
| 3 | //! This module contains submodules and helpers for loading, parsing, and | ||
| 4 | //! managing configuration files and settings. It abstracts the details | ||
| 5 | //! of where configuration is stored and how it is represented in memory. | ||
| 6 | //! | ||
| 7 | //! # Submodules | ||
| 8 | //! - `loader`: Functions to load and parse configuration from disk or | ||
| 9 | //! environment variables. | ||
| 10 | 2 | ||
| 3 | pub mod file; | ||
| 11 | pub mod loader; | 4 | pub mod loader; |
| 12 | pub use loader::Config; | 5 | pub mod runtime; |
| 6 | |||
| 7 | pub use loader::ConfigLoader; | ||
| 8 | pub use runtime::RuntimeConfig; | ||
| 9 | |||
| 10 | /// Backwards-compatibility alias so existing code can keep using `Config` | ||
| 11 | pub type Config = RuntimeConfig; | ||
diff --git a/src/config/runtime.rs b/src/config/runtime.rs new file mode 100644 index 0000000..46857f0 --- /dev/null +++ b/src/config/runtime.rs | |||
| @@ -0,0 +1,95 @@ | |||
| 1 | use std::path::PathBuf; | ||
| 2 | |||
| 3 | use super::file::FileConfig; | ||
| 4 | use crate::{constants::*, minecraft::launcher::JavaRuntime}; | ||
| 5 | |||
| 6 | /// Configuration for the runtime environment used to launch Minecraft. | ||
| 7 | /// | ||
| 8 | /// The `RuntimeConfig` struct holds the configuration settings required to | ||
| 9 | /// launch Minecraft with a specific Java runtime. This includes details about | ||
| 10 | /// the user, the Minecraft version, the available Java runtimes, and JVM options. | ||
| 11 | /// | ||
| 12 | /// The `RuntimeConfig` struct is typically used by the launcher to store and | ||
| 13 | /// manage the necessary settings for running the game. It also supports | ||
| 14 | /// dynamic configuration by allowing the addition of Java runtimes and the | ||
| 15 | /// specification of system paths and arguments. | ||
| 16 | #[derive(Debug)] | ||
| 17 | #[derive(Default)] | ||
| 18 | pub struct RuntimeConfig { | ||
| 19 | /// The username of the player running Minecraft. | ||
| 20 | pub username: String, | ||
| 21 | |||
| 22 | /// The UUID (unique identifier) of the user. | ||
| 23 | pub uuid: String, | ||
| 24 | |||
| 25 | /// The version of Minecraft that the user is running. | ||
| 26 | pub version: String, | ||
| 27 | |||
| 28 | /// The maximum amount of memory (in megabytes) allocated to the Java process. | ||
| 29 | /// This setting determines how much memory the Minecraft instance can use. | ||
| 30 | pub max_memory_mb: u32, | ||
| 31 | |||
| 32 | /// A list of arguments to pass to the JVM when launching Minecraft. | ||
| 33 | /// These arguments can be used to customize the JVM's behavior, such as | ||
| 34 | /// memory settings, garbage collection options, etc. | ||
| 35 | pub jvm_args: Vec<String>, | ||
| 36 | |||
| 37 | /// A list of Java runtime environments available for Minecraft. | ||
| 38 | /// If no specific Java runtime is provided, the default one is used. | ||
| 39 | pub runtimes: Vec<JavaRuntime>, | ||
| 40 | |||
| 41 | /// The directory where Minecraft data (e.g., worlds, logs, configs) is stored. | ||
| 42 | /// This path is critical for loading and saving game data. | ||
| 43 | pub data_dir: PathBuf, | ||
| 44 | } | ||
| 45 | |||
| 46 | impl RuntimeConfig { | ||
| 47 | /// Creates a new `RuntimeConfig` instance from a `FileConfig`. | ||
| 48 | /// | ||
| 49 | /// This method takes a `FileConfig` object (typically loaded from a configuration file) | ||
| 50 | /// and constructs a `RuntimeConfig` instance. It ensures that if no runtimes are defined | ||
| 51 | /// but a Java path is provided, a default Java runtime is created based on the specified path. | ||
| 52 | /// | ||
| 53 | /// # Parameters | ||
| 54 | /// - `file`: A `FileConfig` instance containing initial configuration values for the | ||
| 55 | /// Minecraft runtime. This includes user details, Minecraft version, JVM arguments, | ||
| 56 | /// and the path to Java (if specified). | ||
| 57 | /// | ||
| 58 | /// # Returns | ||
| 59 | /// - A `RuntimeConfig` instance initialized with the values from `file`. If no runtimes | ||
| 60 | /// were provided in `file` but a Java path was given, the method will add a default Java runtime. | ||
| 61 | /// | ||
| 62 | /// # Example | ||
| 63 | /// ```rust | ||
| 64 | /// let file_config = FileConfig { | ||
| 65 | /// username: "Player1".into(), | ||
| 66 | /// uuid: "1234-5678-9101".into(), | ||
| 67 | /// version: "1.18.2".into(), | ||
| 68 | /// max_memory_mb: 2048, | ||
| 69 | /// jvm_args: vec!["-Xmx2G".into()], | ||
| 70 | /// runtimes: Vec::new(), | ||
| 71 | /// java_path: "/path/to/java".into(), | ||
| 72 | /// data_dir: "/path/to/minecraft/data".into(), | ||
| 73 | /// }; | ||
| 74 | /// | ||
| 75 | /// let runtime_config = RuntimeConfig::from_file(file_config); | ||
| 76 | /// ``` | ||
| 77 | pub fn from_file(mut file: FileConfig) -> Self { | ||
| 78 | if file.runtimes.is_empty() && !file.java_path.is_empty() { | ||
| 79 | file.runtimes.push(JavaRuntime { | ||
| 80 | major: DEFAULT_JAVA_MAJOR, | ||
| 81 | path: PathBuf::from(&file.java_path), | ||
| 82 | }); | ||
| 83 | } | ||
| 84 | |||
| 85 | Self { | ||
| 86 | username: file.username, | ||
| 87 | uuid: file.uuid, | ||
| 88 | version: file.version, | ||
| 89 | max_memory_mb: file.max_memory_mb, | ||
| 90 | jvm_args: file.jvm_args, | ||
| 91 | runtimes: file.runtimes, | ||
| 92 | data_dir: file.data_dir, | ||
| 93 | } | ||
| 94 | } | ||
| 95 | } | ||
diff --git a/src/constants.rs b/src/constants.rs index 3234fe6..72de325 100644 --- a/src/constants.rs +++ b/src/constants.rs | |||
| @@ -1,13 +1,123 @@ | |||
| 1 | #![allow(dead_code)] | 1 | #![allow(dead_code)] |
| 2 | 2 | ||
| 3 | //! Constants module for the DML Launcher. | ||
| 4 | //! | ||
| 5 | //! This module defines immutable configuration values and defaults | ||
| 6 | //! used throughout the launcher. These constants provide a single | ||
| 7 | //! source of truth for URLs, download behavior, memory settings, | ||
| 8 | //! JVM defaults, download concurrency, directory names, and version selection. | ||
| 9 | //! | ||
| 10 | //! Centralizing these constants ensures consistency across the | ||
| 11 | //! application and simplifies maintenance when defaults need to change. | ||
| 12 | |||
| 3 | use std::time::Duration; | 13 | use std::time::Duration; |
| 4 | 14 | ||
| 15 | /// URL to the Minecraft version manifest. | ||
| 16 | /// | ||
| 17 | /// This manifest contains metadata for all available Minecraft | ||
| 18 | /// releases, including release dates, type (release, snapshot), | ||
| 19 | /// and download URLs for the client and server distributions. | ||
| 5 | pub const VERSION_MANIFEST_URL: &str = | 20 | pub const VERSION_MANIFEST_URL: &str = |
| 6 | "https://launchermeta.mojang.com/mc/game/version_manifest.json"; | 21 | "https://launchermeta.mojang.com/mc/game/version_manifest.json"; |
| 7 | 22 | ||
| 23 | /// Number of times to retry a failed download. | ||
| 24 | /// | ||
| 25 | /// Used by asset and library download routines to tolerate | ||
| 26 | /// transient network failures. Downloads will be retried | ||
| 27 | /// up to this number of attempts before returning an error. | ||
| 8 | pub const DOWNLOAD_RETRIES: usize = 3; | 28 | pub const DOWNLOAD_RETRIES: usize = 3; |
| 29 | |||
| 30 | /// Duration to wait between download retries. | ||
| 31 | /// | ||
| 32 | /// Provides fixed delay for network resilience when fetching | ||
| 33 | /// game assets. Specified in milliseconds. | ||
| 9 | pub const DOWNLOAD_BACKOFF: Duration = Duration::from_millis(400); | 34 | pub const DOWNLOAD_BACKOFF: Duration = Duration::from_millis(400); |
| 10 | 35 | ||
| 36 | /// Default maximum memory allocation for the Minecraft JVM process in | ||
| 37 | /// megabytes. | ||
| 38 | /// | ||
| 39 | /// If the user does not specify memory settings via configuration | ||
| 40 | /// or JVM arguments, this value will be applied as the default heap size. | ||
| 11 | pub const DEFAULT_MAX_MEMORY_MB: u32 = 4048; | 41 | pub const DEFAULT_MAX_MEMORY_MB: u32 = 4048; |
| 42 | |||
| 43 | /// Default path to the Java executable. | ||
| 44 | /// | ||
| 45 | /// Used when launching the Minecraft client. Can be overridden | ||
| 46 | /// via configuration or environment variables if a custom Java | ||
| 47 | /// installation is required. | ||
| 12 | pub const DEFAULT_JAVA_PATH: &str = "java"; | 48 | pub const DEFAULT_JAVA_PATH: &str = "java"; |
| 49 | |||
| 50 | /// Default Minecraft version to launch when no specific version is provided. | ||
| 51 | /// | ||
| 52 | /// The launcher will resolve "latest" to the most recent stable release | ||
| 53 | /// according to the version manifest. | ||
| 13 | pub const DEFAULT_VERSION: &str = "latest"; | 54 | pub const DEFAULT_VERSION: &str = "latest"; |
| 55 | |||
| 56 | /// Default Java major version for launching Minecraft. | ||
| 57 | /// | ||
| 58 | /// This value is used when generating the JVM runtime configuration | ||
| 59 | /// for the launcher. | ||
| 60 | pub const DEFAULT_JAVA_MAJOR: u8 = 8; | ||
| 61 | |||
| 62 | /// Default number of concurrent downloads for libraries. | ||
| 63 | /// | ||
| 64 | /// Limits the number of simultaneous HTTP requests when fetching | ||
| 65 | /// Minecraft libraries to avoid saturating network or file handles. | ||
| 66 | pub const DEFAULT_LIBRARY_CONCURRENCY: usize = 20; | ||
| 67 | |||
| 68 | /// Default number of concurrent downloads for assets. | ||
| 69 | /// | ||
| 70 | /// Limits the number of simultaneous HTTP requests when fetching | ||
| 71 | /// Minecraft assets to optimize throughput without overloading the system. | ||
| 72 | pub const DEFAULT_ASSET_CONCURRENCY: usize = 20; | ||
| 73 | |||
| 74 | /// Default launcher cache directory name. | ||
| 75 | /// | ||
| 76 | /// Used as the base subdirectory for downloaded assets, libraries, | ||
| 77 | /// versions, and configuration. | ||
| 78 | pub const DEFAULT_LAUNCHER_DIR: &str = "dml"; | ||
| 79 | |||
| 80 | /// Default environment variable names for overriding configuration. | ||
| 81 | pub const ENV_USERNAME: &str = "MC_USERNAME"; | ||
| 82 | pub const ENV_VERSION: &str = "MC_VERSION"; | ||
| 83 | pub const ENV_JAVA_PATH: &str = "MC_JAVA_PATH"; | ||
| 84 | pub const ENV_MAX_MEMORY_MB: &str = "MC_MAX_MEMORY_MB"; | ||
| 85 | |||
| 86 | /// Default project/platform constants for `directories::ProjectDirs`. | ||
| 87 | pub const DEFAULT_COMPANY: &str = "com"; | ||
| 88 | pub const DEFAULT_PROJECT_GROUP: &str = "example"; | ||
| 89 | pub const DEFAULT_PROJECT_NAME: &str = "dml"; | ||
| 90 | |||
| 91 | /// Default error messages for common failures. | ||
| 92 | pub const DEFAULT_ERR_PLATFORM_DIR: &str = "cannot determine config dir"; | ||
| 93 | pub const DEFAULT_ERR_CREATE_DIR: &str = "failed to create directory"; | ||
| 94 | |||
| 95 | /// Default configuration filename within the launcher directory. | ||
| 96 | pub const DEFAULT_CONFIG_FILENAME: &str = "config.toml"; | ||
| 97 | |||
| 98 | /// Default username if none is provided by environment or config. | ||
| 99 | pub const DEFAULT_USERNAME: &str = "phil"; | ||
| 100 | |||
| 101 | /// Default subdirectory names within the launcher directory. | ||
| 102 | pub mod directory { | ||
| 103 | /// Directory for Minecraft versions. | ||
| 104 | pub const VERSIONS: &str = "versions"; | ||
| 105 | |||
| 106 | /// Directory for downloaded libraries. | ||
| 107 | pub const LIBRARIES: &str = "libraries"; | ||
| 108 | |||
| 109 | /// Directory for Minecraft assets. | ||
| 110 | pub const ASSETS: &str = "assets"; | ||
| 111 | |||
| 112 | /// Subdirectory for asset indexes. | ||
| 113 | pub const INDEXES: &str = "indexes"; | ||
| 114 | |||
| 115 | /// Subdirectory for asset objects. | ||
| 116 | pub const OBJECTS: &str = "objects"; | ||
| 117 | |||
| 118 | /// Directory for native libraries. | ||
| 119 | pub const NATIVES: &str = "natives"; | ||
| 120 | |||
| 121 | /// Directory for saved worlds. | ||
| 122 | pub const SAVES: &str = "saves"; | ||
| 123 | } | ||
diff --git a/src/errors.rs b/src/errors.rs index b98ae2d..bb9c985 100644 --- a/src/errors.rs +++ b/src/errors.rs | |||
| @@ -1,45 +1,337 @@ | |||
| 1 | use std::{fmt, io}; | 1 | use std::{fmt, io}; |
| 2 | 2 | ||
| 3 | use fmt::{Display, Formatter, Result}; | 3 | use fmt::{Display, Formatter, Result}; |
| 4 | use io::Error; | ||
| 4 | use zip::result::ZipError; | 5 | use zip::result::ZipError; |
| 5 | 6 | ||
| 6 | /// Represents all possible errors that can occur in the DML launcher. | 7 | /// Central error type for the DML launcher. |
| 7 | /// | 8 | /// |
| 8 | /// This enum centralizes error handling for the entire application, | 9 | /// # Overview |
| 9 | /// wrapping various underlying error types from I/O, HTTP requests, | 10 | /// |
| 10 | /// JSON parsing, ZIP extraction, configuration issues, and runtime errors. | 11 | /// `McError` provides a unified error abstraction for the entire |
| 12 | /// application. Instead of exposing multiple unrelated error types | ||
| 13 | /// across modules, all recoverable failures are represented through | ||
| 14 | /// this single enum. | ||
| 15 | /// | ||
| 16 | /// This design offers several advantages: | ||
| 17 | /// | ||
| 18 | /// - Simplified function signatures (`Result<T, McError>`) | ||
| 19 | /// - Seamless propagation using the `?` operator | ||
| 20 | /// - Centralized error categorization | ||
| 21 | /// - Clear separation between infrastructure errors and logical failures | ||
| 22 | /// | ||
| 23 | /// # Error Categories | ||
| 24 | /// | ||
| 25 | /// The variants fall into two broad categories: | ||
| 26 | /// | ||
| 27 | /// 1. Wrapped external errors These variants encapsulate errors originating | ||
| 28 | /// from third-party libraries or the standard library. | ||
| 29 | /// | ||
| 30 | /// 2. Application-defined errors These variants represent logical or | ||
| 31 | /// domain-specific failures unique to the launcher. | ||
| 32 | /// | ||
| 33 | /// All variants are intentionally explicit to preserve failure context | ||
| 34 | /// and avoid opaque error strings. | ||
| 35 | /// | ||
| 36 | /// # Example | ||
| 37 | /// | ||
| 38 | /// ```ignore | ||
| 39 | /// fn load_config(path: &str) -> Result<Config, McError> { | ||
| 40 | /// let data = read_to_string(path)?; | ||
| 41 | /// let config = from_str(&data)?; | ||
| 42 | /// Ok(config) | ||
| 43 | /// } | ||
| 44 | /// ``` | ||
| 45 | /// | ||
| 46 | /// In this example, both `io::Error` and `serde_json::Error` | ||
| 47 | /// automatically convert into `McError` via the provided `From` | ||
| 48 | /// implementations. | ||
| 11 | #[allow(dead_code)] | 49 | #[allow(dead_code)] |
| 12 | #[derive(Debug)] | 50 | #[derive(Debug)] |
| 13 | pub enum McError { | 51 | pub enum McError { |
| 14 | Io(io::Error), | 52 | /// Errors originating from the standard I/O subsystem. |
| 53 | /// | ||
| 54 | /// Typical causes: | ||
| 55 | /// - File system access failures | ||
| 56 | /// - Permission errors | ||
| 57 | /// - Missing files or directories | ||
| 58 | /// - Broken pipes | ||
| 59 | /// | ||
| 60 | /// Wraps: `std::io::Error` | ||
| 61 | Io(Error), | ||
| 62 | |||
| 63 | /// Errors occurring during HTTP communication. | ||
| 64 | /// | ||
| 65 | /// Typical causes: | ||
| 66 | /// - Network connectivity failures | ||
| 67 | /// - Timeouts | ||
| 68 | /// - TLS negotiation errors | ||
| 69 | /// - Invalid HTTP responses | ||
| 70 | /// | ||
| 71 | /// Wraps: `reqwest::Error` | ||
| 15 | Http(reqwest::Error), | 72 | Http(reqwest::Error), |
| 73 | |||
| 74 | /// Errors encountered while parsing or serializing JSON. | ||
| 75 | /// | ||
| 76 | /// Typical causes: | ||
| 77 | /// - Malformed configuration files | ||
| 78 | /// - Invalid API responses | ||
| 79 | /// - Schema mismatches | ||
| 80 | /// | ||
| 81 | /// Wraps: `serde_json::Error` | ||
| 16 | Json(serde_json::Error), | 82 | Json(serde_json::Error), |
| 83 | |||
| 84 | /// Errors related to ZIP archive handling. | ||
| 85 | /// | ||
| 86 | /// Typical causes: | ||
| 87 | /// - Corrupted archives | ||
| 88 | /// - Unsupported compression methods | ||
| 89 | /// - Extraction failures | ||
| 90 | /// | ||
| 91 | /// Wraps: `zip::result::ZipError` | ||
| 17 | Zip(ZipError), | 92 | Zip(ZipError), |
| 93 | |||
| 94 | /// Configuration-related failures detected by the application. | ||
| 95 | /// | ||
| 96 | /// This variant is used when configuration data is syntactically | ||
| 97 | /// valid but semantically incorrect or incomplete. | ||
| 98 | /// | ||
| 99 | /// The contained string should describe the specific issue. | ||
| 18 | Config(String), | 100 | Config(String), |
| 101 | |||
| 102 | /// Indicates a checksum verification failure. | ||
| 103 | /// | ||
| 104 | /// This is typically used when validating downloaded files | ||
| 105 | /// against an expected SHA hash. A mismatch suggests corruption, | ||
| 106 | /// incomplete transfer, or tampering. | ||
| 107 | /// | ||
| 108 | /// The contained string should identify the failing resource. | ||
| 19 | ShaMismatch(String), | 109 | ShaMismatch(String), |
| 110 | |||
| 111 | /// Represents a failure when spawning or interacting with | ||
| 112 | /// an external process. | ||
| 113 | /// | ||
| 114 | /// Typical causes: | ||
| 115 | /// - Executable not found | ||
| 116 | /// - Non-zero exit status | ||
| 117 | /// - Failed argument construction | ||
| 118 | /// | ||
| 119 | /// The contained string provides additional diagnostic detail. | ||
| 20 | Process(String), | 120 | Process(String), |
| 121 | |||
| 122 | /// A general-purpose runtime error. | ||
| 123 | /// | ||
| 124 | /// This variant is intended for unexpected logical failures | ||
| 125 | /// that do not fall into other specific categories. | ||
| 126 | /// | ||
| 127 | /// It should not be used as a catch-all replacement for | ||
| 128 | /// more precise error variants. | ||
| 21 | Runtime(String), | 129 | Runtime(String), |
| 22 | } | 130 | } |
| 23 | 131 | ||
| 132 | /// User-facing formatting implementation. | ||
| 133 | /// | ||
| 134 | /// This implementation of the `Display` trait delegates to the `Debug` representation of the enum, | ||
| 135 | /// providing a full structural view of the error. This includes all internal details such as the | ||
| 136 | /// variant type and wrapped error information, which is particularly useful during development and debugging. | ||
| 137 | /// | ||
| 138 | /// The `Debug` representation might be too verbose for production use, where more concise, user-friendly | ||
| 139 | /// error messages would be beneficial. A future enhancement may be to implement a custom `Display` formatting | ||
| 140 | /// for each error variant, providing a cleaner, more readable message for end users./// User-facing formatting implementation. | ||
| 141 | /// | ||
| 142 | /// Currently delegates to the `Debug` representation of the enum. | ||
| 143 | /// This preserves full structural information and wrapped error | ||
| 144 | /// details, which is useful during development and debugging. | ||
| 145 | /// | ||
| 146 | /// For production environments, a more refined implementation | ||
| 147 | /// may format each variant explicitly to produce cleaner, | ||
| 148 | /// user-oriented error messages. | ||
| 24 | impl Display for McError { | 149 | impl Display for McError { |
| 25 | /// Formats the error for user-friendly display. | 150 | fn fmt(&self, f: &mut Formatter<'_>) -> Result { |
| 26 | /// | 151 | write!(f, "{:?}", self) |
| 27 | /// Currently, it uses the `Debug` format for simplicity. | 152 | } |
| 28 | fn fmt(&self, f: &mut Formatter<'_>) -> Result { write!(f, "{:?}", self) } | ||
| 29 | } | 153 | } |
| 30 | 154 | ||
| 31 | impl From<io::Error> for McError { | 155 | /// Enables conversion from `std::io::Error` to `McError`. |
| 32 | fn from(e: io::Error) -> Self { Self::Io(e) } | 156 | /// |
| 157 | /// This implementation allows any `std::io::Error` (e.g., file I/O errors) to be automatically converted into | ||
| 158 | /// the `McError::Io` variant. This conversion is crucial when using the `?` operator within functions returning | ||
| 159 | /// `Result<T, McError>`, as it ensures that I/O errors are properly handled and propagated as part of the unified | ||
| 160 | /// `McError` error type. | ||
| 161 | /// | ||
| 162 | /// This reduces the need for manual conversions and simplifies error handling in functions that involve I/O operations. | ||
| 163 | impl From<Error> for McError { | ||
| 164 | fn from(e: Error) -> Self { | ||
| 165 | Self::Io(e) | ||
| 166 | } | ||
| 33 | } | 167 | } |
| 34 | 168 | ||
| 169 | /// Enables conversion from `reqwest::Error` to `McError`. | ||
| 170 | /// | ||
| 171 | /// This implementation allows `reqwest::Error` (such as errors from HTTP requests) to be seamlessly converted | ||
| 172 | /// into the `McError::Http` variant. This is particularly useful for error handling in functions that involve HTTP | ||
| 173 | /// requests, as it allows HTTP-related failures to propagate naturally into the `McError` type and be handled | ||
| 174 | /// consistently with other error types. | ||
| 175 | /// | ||
| 176 | /// This conversion makes it easy to work with `reqwest` errors without worrying about mixing error types in the | ||
| 177 | /// function signature. | ||
| 35 | impl From<reqwest::Error> for McError { | 178 | impl From<reqwest::Error> for McError { |
| 36 | fn from(e: reqwest::Error) -> Self { Self::Http(e) } | 179 | fn from(e: reqwest::Error) -> Self { |
| 180 | Self::Http(e) | ||
| 181 | } | ||
| 37 | } | 182 | } |
| 38 | 183 | ||
| 184 | /// Enables conversion from `serde_json::Error` to `McError`. | ||
| 185 | /// | ||
| 186 | /// This implementation simplifies handling JSON parsing and serialization errors by converting any `serde_json::Error` | ||
| 187 | /// (e.g., invalid JSON format, deserialization failure) into the `McError::Json` variant. This allows JSON errors | ||
| 188 | /// to be handled in the same way as other error types, enabling uniform error management across the application. | ||
| 189 | /// | ||
| 190 | /// By using this conversion, developers don't have to manually map JSON-related errors to a custom error type, | ||
| 191 | /// reducing boilerplate and improving readability. | ||
| 39 | impl From<serde_json::Error> for McError { | 192 | impl From<serde_json::Error> for McError { |
| 40 | fn from(e: serde_json::Error) -> Self { Self::Json(e) } | 193 | fn from(e: serde_json::Error) -> Self { |
| 194 | Self::Json(e) | ||
| 195 | } | ||
| 41 | } | 196 | } |
| 42 | 197 | ||
| 198 | /// Enables conversion from `ZipError` to `McError`. | ||
| 199 | /// | ||
| 200 | /// This implementation allows errors from ZIP archive operations (such as invalid or corrupted archives) to be | ||
| 201 | /// converted into the `McError::Zip` variant. This makes it easy to integrate ZIP archive operations into the | ||
| 202 | /// `McError` error handling model, ensuring consistency in how errors are propagated across the application. | ||
| 203 | /// | ||
| 204 | /// This conversion allows the use of the `?` operator for ZIP-related errors, making error handling in ZIP operations | ||
| 205 | /// as seamless as with other error types. | ||
| 43 | impl From<ZipError> for McError { | 206 | impl From<ZipError> for McError { |
| 44 | fn from(e: ZipError) -> Self { Self::Zip(e) } | 207 | fn from(e: ZipError) -> Self { |
| 208 | Self::Zip(e) | ||
| 209 | } | ||
| 210 | } | ||
| 211 | |||
| 212 | /// Tests the `Display` trait implementation for each `McError` variant. | ||
| 213 | /// | ||
| 214 | /// This test ensures that the `Display` formatting for all `McError` variants results in a non-empty string. | ||
| 215 | /// It verifies that each variant can be correctly converted to a string, even though the current implementation | ||
| 216 | /// delegates to `Debug`. This test is crucial for confirming that the `Display` implementation is functioning | ||
| 217 | /// as expected for all error variants. | ||
| 218 | #[cfg(test)] | ||
| 219 | mod tests { | ||
| 220 | use super::*; | ||
| 221 | use io::ErrorKind::Other; | ||
| 222 | use reqwest::Client; | ||
| 223 | use serde_json::{from_str, Value}; | ||
| 224 | use std::io; | ||
| 225 | use std::io::Error; | ||
| 226 | use McError::{Config, Io, Json, Process, Runtime, ShaMismatch, Zip}; | ||
| 227 | use ZipError::InvalidArchive; | ||
| 228 | |||
| 229 | fn create_error_variants() -> Vec<McError> { | ||
| 230 | vec![ | ||
| 231 | Io(Error::new(Other, "io")), | ||
| 232 | Json(from_str::<Value>("not json").unwrap_err()), | ||
| 233 | Zip(InvalidArchive("zip".into())), | ||
| 234 | Config("config".into()), | ||
| 235 | ShaMismatch("sha".into()), | ||
| 236 | Process("process".into()), | ||
| 237 | Runtime("runtime".into()), | ||
| 238 | ] | ||
| 239 | } | ||
| 240 | |||
| 241 | #[test] | ||
| 242 | fn display_formats_error_variants() { | ||
| 243 | let error_variants = create_error_variants(); | ||
| 244 | |||
| 245 | for error in error_variants { | ||
| 246 | let display_str = format!("{}", error); | ||
| 247 | assert!(!display_str.is_empty()); | ||
| 248 | } | ||
| 249 | } | ||
| 250 | |||
| 251 | /// Tests the conversion from `std::io::Error` to `McError::Io`. | ||
| 252 | /// | ||
| 253 | /// This test ensures that an I/O error of type `std::io::Error` is correctly converted to the `McError::Io` | ||
| 254 | /// variant. The test checks that the string representation of the I/O error matches the expected value and | ||
| 255 | /// verifies that the conversion process is seamless. This test is essential for confirming the correct behavior | ||
| 256 | /// of the `From<Error>` implementation for I/O errors. | ||
| 257 | #[test] | ||
| 258 | fn from_io_error() { | ||
| 259 | let io_error: Error = Error::new(Other, "oops"); | ||
| 260 | let mc_error: McError = io_error.into(); | ||
| 261 | |||
| 262 | match mc_error { | ||
| 263 | | Io(e) => assert_eq!(e.to_string(), "oops"), | ||
| 264 | | _ => panic!("Expected McError::Io, but got: {:?}", mc_error), | ||
| 265 | } | ||
| 266 | } | ||
| 267 | |||
| 268 | /// Tests the conversion from `serde_json::Error` to `McError::Json`. | ||
| 269 | /// | ||
| 270 | /// This test ensures that a `serde_json::Error`, which occurs when JSON parsing or serialization fails, | ||
| 271 | /// is correctly converted into the `McError::Json` variant. The test checks that the error is appropriately | ||
| 272 | /// transformed and can be matched against the `McError::Json` variant. | ||
| 273 | #[test] | ||
| 274 | fn from_json_error() { | ||
| 275 | let json_error: serde_json::Error = | ||
| 276 | from_str::<Value>("not json").unwrap_err(); | ||
| 277 | let mc_error: McError = json_error.into(); | ||
| 278 | |||
| 279 | assert!(matches!(mc_error, Json(_))); | ||
| 280 | } | ||
| 281 | |||
| 282 | /// Tests the conversion from `ZipError` to `McError::Zip`. | ||
| 283 | /// | ||
| 284 | /// This test ensures that a `ZipError`, typically arising from invalid ZIP archive operations, | ||
| 285 | /// is converted to the `McError::Zip` variant. This ensures that ZIP-related errors are handled | ||
| 286 | /// in the same way as other error types, maintaining consistency in error propagation across the application. | ||
| 287 | #[test] | ||
| 288 | fn from_zip_error() { | ||
| 289 | let zip_error = InvalidArchive("bad zip".into()); | ||
| 290 | let mc_error: McError = zip_error.into(); | ||
| 291 | |||
| 292 | assert!(matches!(mc_error, Zip(_))); | ||
| 293 | } | ||
| 294 | |||
| 295 | /// Tests the conversion from `reqwest::Error` to `McError::Http`. | ||
| 296 | /// | ||
| 297 | /// This test ensures that HTTP-related errors from `reqwest` (e.g., failed requests, connection issues) | ||
| 298 | /// are correctly converted into the `McError::Http` variant. It confirms that HTTP-related failures | ||
| 299 | /// are properly integrated into the unified `McError` error type and handled in the same way as other errors. | ||
| 300 | #[tokio::test] | ||
| 301 | async fn from_reqwest_error() { | ||
| 302 | let client: Client = Client::new(); | ||
| 303 | let request_error: reqwest::Error = client | ||
| 304 | .get("http://127.0.0.1:0") | ||
| 305 | .send() | ||
| 306 | .await | ||
| 307 | .unwrap_err(); | ||
| 308 | |||
| 309 | let mc_error: McError = request_error.into(); | ||
| 310 | |||
| 311 | assert!( | ||
| 312 | matches!(mc_error, McError::Http(_)), | ||
| 313 | "Expected McError::Http, got: {:?}", | ||
| 314 | mc_error | ||
| 315 | ); | ||
| 316 | } | ||
| 317 | |||
| 318 | /// Tests that `Config`, `ShaMismatch`, `Process`, and `Runtime` variants are formatted consistently. | ||
| 319 | /// | ||
| 320 | /// This test checks that the `Display` and `Debug` formats are the same for the `Config`, `ShaMismatch`, | ||
| 321 | /// `Process`, and `Runtime` variants of `McError`. The test ensures that these variants are correctly | ||
| 322 | /// formatted both for user-facing display (`Display`) and for debugging purposes (`Debug`), | ||
| 323 | /// confirming consistency between the two formats. | ||
| 324 | #[test] | ||
| 325 | fn config_sha_process_runtime_variants() { | ||
| 326 | let error_variants = vec![ | ||
| 327 | Config("bad config".into()), | ||
| 328 | ShaMismatch("bad sha".into()), | ||
| 329 | Process("failed process".into()), | ||
| 330 | Runtime("runtime error".into()), | ||
| 331 | ]; | ||
| 332 | |||
| 333 | for error in error_variants { | ||
| 334 | assert_eq!(format!("{}", error), format!("{:?}", error)); | ||
| 335 | } | ||
| 336 | } | ||
| 45 | } | 337 | } |
diff --git a/src/main.rs b/src/main.rs index e229a3e..4b32c51 100644 --- a/src/main.rs +++ b/src/main.rs | |||
| @@ -1,47 +1,116 @@ | |||
| 1 | mod constants; | 1 | //! Main module for the DML Launcher. |
| 2 | mod errors; | 2 | //! |
| 3 | //! This module contains the entry point and orchestrates the full lifecycle | ||
| 4 | //! of the launcher. It coordinates configuration loading, environment setup, | ||
| 5 | //! version management, asset downloading, native extraction, and client launch. | ||
| 6 | //! | ||
| 7 | //! # Workflow | ||
| 8 | //! | ||
| 9 | //! 1. Load environment variables from .env. | ||
| 10 | //! 2. Initialize logging with the warning level. | ||
| 11 | //! 3. Parse command-line arguments using clap. | ||
| 12 | //! 4. Load the persistent configuration file. | ||
| 13 | //! 5. Apply command-line overrides to configuration values. | ||
| 14 | //! 6. Ensure all necessary directories exist. | ||
| 15 | //! 7. Load the version manifest for the selected Minecraft version. | ||
| 16 | //! 8. Download required assets including libraries and client binaries. | ||
| 17 | //! 9. Extract native libraries for the current platform. | ||
| 18 | //! 10. Launch the Minecraft client with the configured JVM arguments and | ||
| 19 | //! session information. | ||
| 20 | //! | ||
| 21 | //! # Error Handling | ||
| 22 | //! | ||
| 23 | //! All operations that can fail propagate errors through the | ||
| 24 | //! McError type. This centralizes error management and | ||
| 25 | //! simplifies the use of the ? operator throughout the launcher. | ||
| 26 | //! | ||
| 27 | //! # Asynchronous Operations | ||
| 28 | //! | ||
| 29 | //! HTTP requests and asset downloads are performed asynchronously using | ||
| 30 | //! tokio to maximize efficiency and reduce blocking. Local file | ||
| 31 | //! operations and the client launch remain synchronous to maintain | ||
| 32 | //! consistency and avoid race conditions. | ||
| 3 | 33 | ||
| 4 | mod config; | 34 | mod config; |
| 35 | mod constants; | ||
| 36 | mod errors; | ||
| 5 | mod minecraft; | 37 | mod minecraft; |
| 6 | mod platform; | 38 | mod platform; |
| 7 | mod util; | 39 | mod util; |
| 8 | 40 | ||
| 9 | use clap::Parser; | 41 | use clap::Parser; |
| 10 | use config::Config; | ||
| 11 | use dotenvy::dotenv; | 42 | use dotenvy::dotenv; |
| 12 | use errors::McError; | 43 | use errors::McError; |
| 13 | use log::{debug, info}; | 44 | use log::LevelFilter::Warn; |
| 45 | use reqwest::Client; | ||
| 46 | use platform::paths::ensure_directories; | ||
| 14 | 47 | ||
| 15 | use crate::minecraft::{ | 48 | use crate::{ |
| 16 | downloads::download_all, extraction::extract_natives, launcher::launch, | 49 | config::ConfigLoader, |
| 17 | manifests, | 50 | minecraft::{ |
| 51 | downloads::download_all_files, extraction::extract_natives, launcher::launch, | ||
| 52 | manifests::load_version, | ||
| 53 | }, | ||
| 18 | }; | 54 | }; |
| 55 | use crate::config::RuntimeConfig; | ||
| 56 | use crate::minecraft::manifests::Version; | ||
| 19 | 57 | ||
| 58 | /// Command-line interface definition. | ||
| 59 | /// | ||
| 60 | /// Provides user-configurable options for the launcher, including | ||
| 61 | /// specifying a Minecraft version, a username, and additional | ||
| 62 | /// JVM arguments. Utilizes clap for argument parsing and validation. | ||
| 20 | #[derive(Parser, Debug)] | 63 | #[derive(Parser, Debug)] |
| 21 | #[command(author, about, disable_version_flag = true)] | 64 | #[command(author, about, disable_version_flag = true)] |
| 22 | struct Cli { | 65 | struct Cli { |
| 66 | /// Optional Minecraft version to launch. | ||
| 23 | #[arg(long)] | 67 | #[arg(long)] |
| 24 | version: Option<String>, | 68 | version: Option<String>, |
| 25 | 69 | ||
| 70 | /// Optional username for the Minecraft session. | ||
| 26 | #[arg(long)] | 71 | #[arg(long)] |
| 27 | username: Option<String>, | 72 | username: Option<String>, |
| 28 | 73 | ||
| 74 | /// Optional JVM arguments to pass to the Minecraft process. | ||
| 75 | /// | ||
| 76 | /// Supports multiple values and allows arguments that begin with hyphens. | ||
| 29 | #[arg(long, num_args(0..), allow_hyphen_values = true)] | 77 | #[arg(long, num_args(0..), allow_hyphen_values = true)] |
| 30 | jvm_args: Vec<String>, | 78 | jvm_args: Vec<String>, |
| 31 | } | 79 | } |
| 32 | 80 | ||
| 81 | /// Launcher entry point. | ||
| 82 | /// | ||
| 83 | /// This asynchronous function orchestrates the entire launch process, | ||
| 84 | /// handling configuration, directory setup, asset management, native | ||
| 85 | /// extraction, and client execution. Returns a Result<(), McError> | ||
| 86 | /// to capture and propagate all errors encountered during execution. | ||
| 87 | /// | ||
| 88 | /// # Steps | ||
| 89 | /// | ||
| 90 | /// 1. Load environment variables from .env. | ||
| 91 | /// 2. Initialize logging with a warning level filter. | ||
| 92 | /// 3. Parse CLI arguments and merge them into the configuration. | ||
| 93 | /// 4. Ensure required directories exist for game data and assets. | ||
| 94 | /// 5. Load the manifest for the specified Minecraft version. | ||
| 95 | /// 6. Build an HTTP client with HTTP/2 support for asset downloads. | ||
| 96 | /// 7. Download all required assets including libraries and client binaries. | ||
| 97 | /// 8. Extract platform-specific native libraries. | ||
| 98 | /// 9. Launch the Minecraft client using the configured JVM arguments and | ||
| 99 | /// session. | ||
| 33 | #[tokio::main] | 100 | #[tokio::main] |
| 34 | async fn main() -> Result<(), McError> { | 101 | async fn main() -> Result<(), McError> { |
| 35 | dotenv().ok(); | 102 | dotenv().ok(); |
| 36 | env_logger::init(); | ||
| 37 | 103 | ||
| 38 | let cli = Cli::parse(); | 104 | env_logger::Builder::new() |
| 39 | let mut config = Config::load()?; | 105 | .filter_level(Warn) |
| 106 | .init(); | ||
| 40 | 107 | ||
| 108 | let cli: Cli = Cli::parse(); | ||
| 109 | let mut config: RuntimeConfig = ConfigLoader::load(None)?; | ||
| 110 | |||
| 41 | if let Some(v) = cli.version { | 111 | if let Some(v) = cli.version { |
| 42 | config.version = v; | 112 | config.version = v; |
| 43 | } | 113 | } |
| 44 | |||
| 45 | if let Some(u) = cli.username { | 114 | if let Some(u) = cli.username { |
| 46 | config.username = u; | 115 | config.username = u; |
| 47 | } | 116 | } |
| @@ -49,16 +118,14 @@ async fn main() -> Result<(), McError> { | |||
| 49 | config.jvm_args = cli.jvm_args; | 118 | config.jvm_args = cli.jvm_args; |
| 50 | } | 119 | } |
| 51 | 120 | ||
| 52 | info!("Final config after CLI overrides: {:?}", config); | 121 | ensure_directories(&config)?; |
| 53 | |||
| 54 | platform::paths::ensure_dirs(&config)?; | ||
| 55 | info!("Using Minecraft version {}", config.version); | ||
| 56 | 122 | ||
| 57 | let version = manifests::load_version(&config).await?; | 123 | let version: Version = load_version(&config).await?; |
| 58 | info!("Loaded version manifest for: {}", version.id); | 124 | let client: Client = Client::builder() |
| 59 | debug!("Main class: {}", version.main_class); | 125 | .http2_prior_knowledge() |
| 126 | .build()?; | ||
| 60 | 127 | ||
| 61 | download_all(&config, &version).await?; | 128 | download_all_files(&client, &config, &version).await?; |
| 62 | extract_natives(&config, &version)?; | 129 | extract_natives(&config, &version)?; |
| 63 | launch(&config, &version)?; | 130 | launch(&config, &version)?; |
| 64 | 131 | ||
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 | } |
diff --git a/src/platform/paths.rs b/src/platform/paths.rs index b430f09..07313fd 100644 --- a/src/platform/paths.rs +++ b/src/platform/paths.rs | |||
| @@ -1,44 +1,268 @@ | |||
| 1 | use std::{fs::create_dir_all, path::PathBuf}; | 1 | use std::{fs::create_dir_all, path::PathBuf}; |
| 2 | 2 | ||
| 3 | use crate::{config::Config, errors::McError}; | 3 | use crate::{config::Config, constants::directory, errors::McError}; |
| 4 | 4 | ||
| 5 | /// ~/.local/share/dml/minecraft | 5 | /// Returns the path to the root Minecraft directory inside the launcher data folder. |
| 6 | pub fn minecraft_root(cfg: &Config) -> PathBuf { | 6 | /// |
| 7 | /// This directory acts as the root for all Minecraft-related files within the launcher data. | ||
| 8 | /// By default, it joins the `minecraft` directory under the data folder (defined by the launcher). | ||
| 9 | /// | ||
| 10 | /// The returned path does not include any specific assets, versions, or libraries directories. | ||
| 11 | /// You can use this as the base path to construct other Minecraft-related paths. | ||
| 12 | /// | ||
| 13 | /// # Parameters | ||
| 14 | /// - `cfg`: The configuration object (`Config`) containing launcher and Minecraft data folder paths. | ||
| 15 | /// | ||
| 16 | /// # Returns | ||
| 17 | /// - A `PathBuf` pointing to the root Minecraft directory. | ||
| 18 | pub fn root_directory(cfg: &Config) -> PathBuf { | ||
| 19 | // Remove DEFAULT_LAUNCHER_DIR to avoid duplicate "dml/dml" | ||
| 7 | cfg.data_dir.join("minecraft") | 20 | cfg.data_dir.join("minecraft") |
| 8 | } | 21 | } |
| 9 | 22 | ||
| 10 | pub fn assets_dir(cfg: &Config) -> PathBuf { | 23 | /// Returns the path to the Minecraft assets directory. |
| 11 | minecraft_root(cfg).join("assets") | 24 | /// |
| 25 | /// This is where Minecraft assets such as textures, sounds, and other resources are stored. | ||
| 26 | /// The assets directory is located inside the root Minecraft directory and can be used | ||
| 27 | /// for accessing game assets. | ||
| 28 | /// | ||
| 29 | /// # Parameters | ||
| 30 | /// - `cfg`: The configuration object (`Config`) that contains paths for the launcher and Minecraft data. | ||
| 31 | /// | ||
| 32 | /// # Returns | ||
| 33 | /// - A `PathBuf` pointing to the assets directory inside the root Minecraft directory. | ||
| 34 | pub fn assets_directory(cfg: &Config) -> PathBuf { | ||
| 35 | root_directory(cfg).join(directory::ASSETS) | ||
| 12 | } | 36 | } |
| 13 | 37 | ||
| 14 | pub fn game_dir(cfg: &Config) -> PathBuf { minecraft_root(cfg) } | 38 | /// Returns the path to the game directory (same as root directory for now). |
| 15 | pub fn ensure_dirs(cfg: &Config) -> Result<(), McError> { | 39 | /// |
| 16 | let root = minecraft_root(cfg); | 40 | /// The game directory currently points to the same location as the root directory. |
| 17 | create_dir_all(&root)?; | 41 | /// In future iterations, this could be adjusted if game-specific files need to be handled separately. |
| 18 | create_dir_all(root.join("versions"))?; | 42 | /// |
| 19 | create_dir_all(root.join("libraries"))?; | 43 | /// # Parameters |
| 20 | create_dir_all(assets_dir(cfg))?; | 44 | /// - `cfg`: The configuration object (`Config`) that holds the paths to the launcher and Minecraft data. |
| 21 | create_dir_all(assets_dir(cfg).join("indexes"))?; | 45 | /// |
| 22 | create_dir_all(assets_dir(cfg).join("objects"))?; | 46 | /// # Returns |
| 23 | create_dir_all(root.join("saves"))?; | 47 | /// - A `PathBuf` pointing to the game directory, which is the same as the root Minecraft directory for now. |
| 48 | pub fn game_directory(cfg: &Config) -> PathBuf { | ||
| 49 | root_directory(cfg) | ||
| 50 | } | ||
| 51 | |||
| 52 | /// Ensures that all necessary directories for Minecraft are created. | ||
| 53 | /// | ||
| 54 | /// This function checks if essential directories for Minecraft's file structure exist and creates them | ||
| 55 | /// if they do not. This includes directories for versions, libraries, assets, and saves. | ||
| 56 | /// | ||
| 57 | /// # Parameters | ||
| 58 | /// - `cfg`: The configuration object (`Config`) that contains paths for the Minecraft data and the required directories. | ||
| 59 | /// | ||
| 60 | /// # Returns | ||
| 61 | /// - `Ok(())` if all directories are created successfully. | ||
| 62 | /// - `Err(McError)` if an error occurs while creating any directory (such as permission issues). | ||
| 63 | /// | ||
| 64 | /// # Example | ||
| 65 | /// ```rust | ||
| 66 | /// let cfg = Config { /* configuration values */ }; | ||
| 67 | /// ensure_directories(&cfg)?; | ||
| 68 | /// ``` | ||
| 69 | /// | ||
| 70 | /// # Notes | ||
| 71 | /// The function creates several directories under the root Minecraft directory, including: | ||
| 72 | /// - Root directory | ||
| 73 | /// - Versions | ||
| 74 | /// - Libraries | ||
| 75 | /// - Assets (with subdirectories for `indexes` and `objects`) | ||
| 76 | /// - Saves | ||
| 77 | /// Each of these directories is essential for managing Minecraft game data and resources. | ||
| 78 | pub fn ensure_directories(cfg: &Config) -> Result<(), McError> { | ||
| 79 | let root: PathBuf = root_directory(cfg); | ||
| 80 | |||
| 81 | for dir in [ | ||
| 82 | root.clone(), | ||
| 83 | root.join(directory::VERSIONS), | ||
| 84 | root.join(directory::LIBRARIES), | ||
| 85 | assets_directory(cfg), | ||
| 86 | assets_directory(cfg).join(directory::INDEXES), | ||
| 87 | assets_directory(cfg).join(directory::OBJECTS), | ||
| 88 | root.join(directory::SAVES), | ||
| 89 | ] { | ||
| 90 | create_dir_all(dir)?; | ||
| 91 | } | ||
| 24 | 92 | ||
| 25 | Ok(()) | 93 | Ok(()) |
| 26 | } | 94 | } |
| 27 | 95 | ||
| 96 | /// Returns the path to a specific version directory inside the root Minecraft directory. | ||
| 97 | /// | ||
| 98 | /// This directory is where the game version-specific files (such as the `.jar` file) are stored. | ||
| 99 | /// The path is constructed by joining the `versions` directory with the provided version name. | ||
| 100 | /// | ||
| 101 | /// # Parameters | ||
| 102 | /// - `cfg`: The configuration object (`Config`) containing paths for the Minecraft data. | ||
| 103 | /// - `version`: The version of Minecraft (e.g., "1.18.2") to generate the path for. | ||
| 104 | /// | ||
| 105 | /// # Returns | ||
| 106 | /// - A `PathBuf` pointing to the directory for the specified version inside the root Minecraft directory. | ||
| 28 | pub fn version_dir(cfg: &Config, version: &str) -> PathBuf { | 107 | pub fn version_dir(cfg: &Config, version: &str) -> PathBuf { |
| 29 | minecraft_root(cfg).join("versions").join(version) | 108 | root_directory(cfg) |
| 109 | .join(directory::VERSIONS) | ||
| 110 | .join(version) | ||
| 30 | } | 111 | } |
| 31 | 112 | ||
| 32 | pub fn client_jar(cfg: &Config, version: &str) -> Result<PathBuf, McError> { | 113 | /// Returns the path to the client `.jar` file for a specific Minecraft version. |
| 33 | Ok(version_dir(cfg, version).join(format!("{version}.jar"))) | 114 | /// |
| 115 | /// This file is typically used to run the Minecraft client for a particular version. | ||
| 116 | /// The path is constructed by joining the version directory with the name of the `.jar` file. | ||
| 117 | /// | ||
| 118 | /// # Parameters | ||
| 119 | /// - `cfg`: The configuration object (`Config`) containing paths for the Minecraft data. | ||
| 120 | /// - `version`: The version of Minecraft (e.g., "1.18.2") to generate the path for. | ||
| 121 | /// | ||
| 122 | /// # Returns | ||
| 123 | /// - A `PathBuf` pointing to the `.jar` file for the specified version in the version directory. | ||
| 124 | pub fn client_jar(cfg: &Config, version: &str) -> PathBuf { | ||
| 125 | version_dir(cfg, version).join(format!("{version}.jar")) | ||
| 34 | } | 126 | } |
| 35 | 127 | ||
| 36 | pub fn library_file(cfg: &Config, rel_path: &str) -> Result<PathBuf, McError> { | 128 | /// Returns the path to a specific library file inside the library directory. |
| 37 | Ok(minecraft_root(cfg) | 129 | /// |
| 38 | .join("libraries") | 130 | /// This function is used to access library files required for running Minecraft. The `rel_path` |
| 39 | .join(rel_path)) | 131 | /// argument specifies the relative path inside the `libraries` directory. It is useful for handling |
| 132 | /// dependencies or loading required libraries based on the game version. | ||
| 133 | /// | ||
| 134 | /// # Parameters | ||
| 135 | /// - `cfg`: The configuration object (`Config`) that contains paths for the Minecraft data. | ||
| 136 | /// - `rel_path`: The relative path of the library file inside the `libraries` directory. | ||
| 137 | /// | ||
| 138 | /// # Returns | ||
| 139 | /// - A `PathBuf` pointing to the specified library file inside the `libraries` directory. | ||
| 140 | pub fn library_file(cfg: &Config, rel_path: &str) -> PathBuf { | ||
| 141 | root_directory(cfg) | ||
| 142 | .join(directory::LIBRARIES) | ||
| 143 | .join(rel_path) | ||
| 40 | } | 144 | } |
| 41 | 145 | ||
| 146 | /// Returns the path to the natives directory for a specific Minecraft version. | ||
| 147 | /// | ||
| 148 | /// The natives directory contains platform-specific native libraries (e.g., `.dll`, `.so`, `.dylib`) | ||
| 149 | /// that Minecraft uses to run the game. This function returns the path to the directory for the | ||
| 150 | /// specified version. | ||
| 151 | /// | ||
| 152 | /// # Parameters | ||
| 153 | /// - `cfg`: The configuration object (`Config`) containing paths for the Minecraft data. | ||
| 154 | /// - `version`: The version of Minecraft (e.g., "1.18.2") to generate the path for. | ||
| 155 | /// | ||
| 156 | /// # Returns | ||
| 157 | /// - A `PathBuf` pointing to the natives directory for the specified version. | ||
| 42 | pub fn natives_dir(cfg: &Config, version: &str) -> PathBuf { | 158 | pub fn natives_dir(cfg: &Config, version: &str) -> PathBuf { |
| 43 | version_dir(cfg, version).join("natives") | 159 | version_dir(cfg, version).join(directory::NATIVES) |
| 160 | } | ||
| 161 | |||
| 162 | // /// Path to a version’s saves directory. | ||
| 163 | // pub fn saves_dir(cfg: &Config) -> PathBuf { | ||
| 164 | // minecraft_root(cfg).join(dirs::SAVES) | ||
| 165 | // } | ||
| 166 | |||
| 167 | #[cfg(test)] | ||
| 168 | mod tests { | ||
| 169 | use super::*; | ||
| 170 | use std::path::Path; | ||
| 171 | |||
| 172 | fn test_config(tmp: &Path) -> Config { | ||
| 173 | Config { | ||
| 174 | data_dir: tmp.to_path_buf(), | ||
| 175 | ..Default::default() | ||
| 176 | } | ||
| 177 | } | ||
| 178 | |||
| 179 | #[test] | ||
| 180 | fn root_directory_is_correct() { | ||
| 181 | let tmp = tempfile::tempdir().unwrap(); | ||
| 182 | let cfg = test_config(tmp.path()); | ||
| 183 | |||
| 184 | let root = root_directory(&cfg); | ||
| 185 | assert_eq!(root, tmp.path().join("minecraft")); | ||
| 186 | } | ||
| 187 | |||
| 188 | #[test] | ||
| 189 | fn assets_directory_is_correct() { | ||
| 190 | let tmp = tempfile::tempdir().unwrap(); | ||
| 191 | let cfg = test_config(tmp.path()); | ||
| 192 | |||
| 193 | let assets = assets_directory(&cfg); | ||
| 194 | assert_eq!(assets, root_directory(&cfg).join(directory::ASSETS)); | ||
| 195 | } | ||
| 196 | |||
| 197 | #[test] | ||
| 198 | fn version_paths_are_correct() { | ||
| 199 | let tmp = tempfile::tempdir().unwrap(); | ||
| 200 | let cfg = test_config(tmp.path()); | ||
| 201 | |||
| 202 | let version = "1.20.1"; | ||
| 203 | assert_eq!( | ||
| 204 | version_dir(&cfg, version), | ||
| 205 | root_directory(&cfg) | ||
| 206 | .join(directory::VERSIONS) | ||
| 207 | .join(version) | ||
| 208 | ); | ||
| 209 | |||
| 210 | assert_eq!( | ||
| 211 | client_jar(&cfg, version), | ||
| 212 | version_dir(&cfg, version).join("1.20.1.jar") | ||
| 213 | ); | ||
| 214 | } | ||
| 215 | |||
| 216 | #[test] | ||
| 217 | fn library_file_path_is_correct() { | ||
| 218 | let tmp = tempfile::tempdir().unwrap(); | ||
| 219 | let cfg = test_config(tmp.path()); | ||
| 220 | |||
| 221 | let rel = "com/example/lib.jar"; | ||
| 222 | assert_eq!( | ||
| 223 | library_file(&cfg, rel), | ||
| 224 | root_directory(&cfg) | ||
| 225 | .join(directory::LIBRARIES) | ||
| 226 | .join(rel) | ||
| 227 | ); | ||
| 228 | } | ||
| 229 | |||
| 230 | #[test] | ||
| 231 | fn natives_dir_path_is_correct() { | ||
| 232 | let tmp = tempfile::tempdir().unwrap(); | ||
| 233 | let cfg = test_config(tmp.path()); | ||
| 234 | |||
| 235 | let version = "1.20.1"; | ||
| 236 | assert_eq!( | ||
| 237 | natives_dir(&cfg, version), | ||
| 238 | version_dir(&cfg, version).join(directory::NATIVES) | ||
| 239 | ); | ||
| 240 | } | ||
| 241 | |||
| 242 | #[test] | ||
| 243 | fn ensure_directories_creates_structure() { | ||
| 244 | let tmp = tempfile::tempdir().unwrap(); | ||
| 245 | let cfg = test_config(tmp.path()); | ||
| 246 | |||
| 247 | ensure_directories(&cfg).unwrap(); | ||
| 248 | |||
| 249 | let root = root_directory(&cfg); | ||
| 250 | |||
| 251 | let expected_dirs = [ | ||
| 252 | root.clone(), | ||
| 253 | root.join(directory::VERSIONS), | ||
| 254 | root.join(directory::LIBRARIES), | ||
| 255 | root.join(directory::ASSETS), | ||
| 256 | root.join(directory::ASSETS) | ||
| 257 | .join(directory::INDEXES), | ||
| 258 | root.join(directory::ASSETS) | ||
| 259 | .join(directory::OBJECTS), | ||
| 260 | root.join(directory::SAVES), | ||
| 261 | ]; | ||
| 262 | |||
| 263 | for dir in expected_dirs { | ||
| 264 | assert!(dir.exists(), "Missing directory: {:?}", dir); | ||
| 265 | assert!(dir.is_dir()); | ||
| 266 | } | ||
| 267 | } | ||
| 44 | } | 268 | } |
diff --git a/src/util/fs.rs b/src/util/fs.rs index 8ecd0d0..b1e9152 100644 --- a/src/util/fs.rs +++ b/src/util/fs.rs | |||
| @@ -4,7 +4,10 @@ use std::path::Path; | |||
| 4 | 4 | ||
| 5 | use tokio::fs::remove_file; | 5 | use tokio::fs::remove_file; |
| 6 | 6 | ||
| 7 | use crate::errors::McError; | 7 | use crate::{ |
| 8 | errors::McError, | ||
| 9 | minecraft::manifests::{Library, Rule}, | ||
| 10 | }; | ||
| 8 | 11 | ||
| 9 | pub async fn remove_if_exists(path: &Path) -> Result<(), McError> { | 12 | pub async fn remove_if_exists(path: &Path) -> Result<(), McError> { |
| 10 | if path.exists() { | 13 | if path.exists() { |
| @@ -12,3 +15,24 @@ pub async fn remove_if_exists(path: &Path) -> Result<(), McError> { | |||
| 12 | } | 15 | } |
| 13 | Ok(()) | 16 | Ok(()) |
| 14 | } | 17 | } |
| 18 | |||
| 19 | pub fn library_allowed(lib: &Library) -> bool { | ||
| 20 | let rules: &Vec<Rule> = match &lib.rules { | ||
| 21 | | Some(r) => r, | ||
| 22 | | None => return true, | ||
| 23 | }; | ||
| 24 | |||
| 25 | let mut allowed: bool = false; | ||
| 26 | |||
| 27 | for rule in rules { | ||
| 28 | let os_match: bool = match &rule.os { | ||
| 29 | | Some(os) => os.name == "linux", | ||
| 30 | | None => true, | ||
| 31 | }; | ||
| 32 | if os_match { | ||
| 33 | allowed = rule.action == "allow"; | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | allowed | ||
| 38 | } | ||
diff --git a/src/util/sha1.rs b/src/util/sha1.rs index 6684963..6fed18d 100644 --- a/src/util/sha1.rs +++ b/src/util/sha1.rs | |||
| @@ -2,14 +2,15 @@ | |||
| 2 | 2 | ||
| 3 | use std::path::Path; | 3 | use std::path::Path; |
| 4 | 4 | ||
| 5 | use sha1::{Digest, Sha1}; | 5 | use sha1::{Digest, Sha1, Sha1Core}; |
| 6 | use sha1::digest::core_api::CoreWrapper; | ||
| 6 | use tokio::fs::read; | 7 | use tokio::fs::read; |
| 7 | 8 | ||
| 8 | use crate::errors::McError; | 9 | use crate::errors::McError; |
| 9 | 10 | ||
| 10 | pub async fn sha1_hex(path: &Path) -> Result<String, McError> { | 11 | pub async fn sha1_hex(path: &Path) -> Result<String, McError> { |
| 11 | let data = read(path).await?; | 12 | let data: Vec<u8> = read(path).await?; |
| 12 | let mut hasher = Sha1::new(); | 13 | let mut hasher: CoreWrapper<Sha1Core> = Sha1::new(); |
| 13 | hasher.update(&data); | 14 | hasher.update(&data); |
| 14 | Ok(format!("{:x}", hasher.finalize())) | 15 | Ok(format!("{:x}", hasher.finalize())) |
| 15 | } | 16 | } |
