aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorFilip Wandzio <contact@philw.dev>2026-02-25 16:10:23 +0100
committerFilip Wandzio <contact@philw.dev>2026-02-25 16:10:23 +0100
commitf7b4b643ebc52a4d72d90d9adbdddc9aa0721e4a (patch)
treec96432be342b02bc0409e5b78b6b5d54afcc7cd6
parent2e10b0713f5369f489d2ababd70108cc359c5d2d (diff)
downloaddml-f7b4b643ebc52a4d72d90d9adbdddc9aa0721e4a.tar.gz
dml-f7b4b643ebc52a4d72d90d9adbdddc9aa0721e4a.zip
Feat: Refactor core download logic with concurrency and async features
Implement basic unit testing Implement automatic java executable switching based on game version Split loader module into smaller modules Implement basic documentation
Diffstat (limited to '')
-rw-r--r--.gitignore1
-rw-r--r--.run/Run dml.run.xml20
-rw-r--r--Cargo.toml4
-rw-r--r--README.md11
-rw-r--r--src/config/file.rs26
-rw-r--r--src/config/loader.rs129
-rw-r--r--src/config/mod.rs17
-rw-r--r--src/config/runtime.rs95
-rw-r--r--src/constants.rs110
-rw-r--r--src/errors.rs320
-rw-r--r--src/main.rs105
-rw-r--r--src/minecraft/downloads.rs423
-rw-r--r--src/minecraft/extraction.rs70
-rw-r--r--src/minecraft/launcher.rs262
-rw-r--r--src/minecraft/manifests.rs41
-rw-r--r--src/platform/paths.rs270
-rw-r--r--src/util/fs.rs26
-rw-r--r--src/util/sha1.rs7
18 files changed, 1560 insertions, 377 deletions
diff --git a/.gitignore b/.gitignore
index d0f7565..95a9573 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ Cargo.lock
7*.pdb 7*.pdb
8rust-project.json 8rust-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
diff --git a/Cargo.toml b/Cargo.toml
index 9c0eb1b..c72e1c6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,10 +8,8 @@ clap = { version = "4.5.54", features = ["derive"] }
8directories = "6.0.0" 8directories = "6.0.0"
9dotenvy = "0.15.7" 9dotenvy = "0.15.7"
10env_logger = "0.11.8" 10env_logger = "0.11.8"
11futures-util = "0.3.31" 11futures = "0.3.32"
12indicatif = "0.18.3"
13log = "0.4.29" 12log = "0.4.29"
14rayon = "1.11.0"
15reqwest = { version = "0.13.1", features = ["json", "stream"] } 13reqwest = { version = "0.13.1", features = ["json", "stream"] }
16serde = { version = "1.0.228", features = ["derive"] } 14serde = { version = "1.0.228", features = ["derive"] }
17serde_json = "1.0.149" 15serde_json = "1.0.149"
diff --git a/README.md b/README.md
index a9bdf0f..46c3255 100644
--- a/README.md
+++ b/README.md
@@ -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 @@
1use std::path::PathBuf;
2
3use serde::Deserialize;
4
5use crate::minecraft::launcher::JavaRuntime;
6
7#[derive(Debug, Deserialize)]
8pub 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 @@
1use std::{env::var, fs::read_to_string, path::PathBuf}; 1use std::{env, fs::read_to_string, path::PathBuf};
2
3use directories::ProjectDirs; 2use directories::ProjectDirs;
4use serde::Deserialize; 3use uuid::Uuid;
5 4
5use super::{file::FileConfig, runtime::RuntimeConfig};
6use crate::{constants::*, errors::McError}; 6use crate::{constants::*, errors::McError};
7#[allow(dead_code)] 7
8#[derive(Debug, Deserialize)] 8pub struct ConfigLoader;
9pub struct Config { 9
10 pub username: String, 10impl 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}
21impl 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}
61fn 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
3pub mod file;
11pub mod loader; 4pub mod loader;
12pub use loader::Config; 5pub mod runtime;
6
7pub use loader::ConfigLoader;
8pub use runtime::RuntimeConfig;
9
10/// Backwards-compatibility alias so existing code can keep using `Config`
11pub 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 @@
1use std::path::PathBuf;
2
3use super::file::FileConfig;
4use 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)]
18pub 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
46impl 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
3use std::time::Duration; 13use 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.
5pub const VERSION_MANIFEST_URL: &str = 20pub 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.
8pub const DOWNLOAD_RETRIES: usize = 3; 28pub 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.
9pub const DOWNLOAD_BACKOFF: Duration = Duration::from_millis(400); 34pub 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.
11pub const DEFAULT_MAX_MEMORY_MB: u32 = 4048; 41pub 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.
12pub const DEFAULT_JAVA_PATH: &str = "java"; 48pub 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.
13pub const DEFAULT_VERSION: &str = "latest"; 54pub 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.
60pub 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.
66pub 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.
72pub 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.
78pub const DEFAULT_LAUNCHER_DIR: &str = "dml";
79
80/// Default environment variable names for overriding configuration.
81pub const ENV_USERNAME: &str = "MC_USERNAME";
82pub const ENV_VERSION: &str = "MC_VERSION";
83pub const ENV_JAVA_PATH: &str = "MC_JAVA_PATH";
84pub const ENV_MAX_MEMORY_MB: &str = "MC_MAX_MEMORY_MB";
85
86/// Default project/platform constants for `directories::ProjectDirs`.
87pub const DEFAULT_COMPANY: &str = "com";
88pub const DEFAULT_PROJECT_GROUP: &str = "example";
89pub const DEFAULT_PROJECT_NAME: &str = "dml";
90
91/// Default error messages for common failures.
92pub const DEFAULT_ERR_PLATFORM_DIR: &str = "cannot determine config dir";
93pub const DEFAULT_ERR_CREATE_DIR: &str = "failed to create directory";
94
95/// Default configuration filename within the launcher directory.
96pub const DEFAULT_CONFIG_FILENAME: &str = "config.toml";
97
98/// Default username if none is provided by environment or config.
99pub const DEFAULT_USERNAME: &str = "phil";
100
101/// Default subdirectory names within the launcher directory.
102pub 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 @@
1use std::{fmt, io}; 1use std::{fmt, io};
2 2
3use fmt::{Display, Formatter, Result}; 3use fmt::{Display, Formatter, Result};
4use io::Error;
4use zip::result::ZipError; 5use 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)]
13pub enum McError { 51pub 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.
24impl Display for McError { 149impl 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
31impl 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.
163impl 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.
35impl From<reqwest::Error> for McError { 178impl 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.
39impl From<serde_json::Error> for McError { 192impl 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.
43impl From<ZipError> for McError { 206impl 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)]
219mod 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 @@
1mod constants; 1//! Main module for the DML Launcher.
2mod 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
4mod config; 34mod config;
35mod constants;
36mod errors;
5mod minecraft; 37mod minecraft;
6mod platform; 38mod platform;
7mod util; 39mod util;
8 40
9use clap::Parser; 41use clap::Parser;
10use config::Config;
11use dotenvy::dotenv; 42use dotenvy::dotenv;
12use errors::McError; 43use errors::McError;
13use log::{debug, info}; 44use log::LevelFilter::Warn;
45use reqwest::Client;
46use platform::paths::ensure_directories;
14 47
15use crate::minecraft::{ 48use 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};
55use crate::config::RuntimeConfig;
56use 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)]
22struct Cli { 65struct 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]
34async fn main() -> Result<(), McError> { 101async 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 @@
1use log::{debug, info}; 1use std::{
2use reqwest::get; 2 collections::HashMap,
3 io::{Write, stdout},
4 path::{Path, PathBuf},
5 sync::{
6 Arc,
7 atomic::{AtomicUsize, Ordering},
8 },
9};
10
11use McError::Config;
12use Ordering::SeqCst;
13use futures::stream::{FuturesUnordered, StreamExt};
14use reqwest::Response;
3use serde::Deserialize; 15use serde::Deserialize;
4use tokio::{ 16use tokio::{
5 fs::{self, File, create_dir_all}, 17 fs::{File, create_dir_all, read_to_string, write},
6 io::AsyncWriteExt, 18 io::AsyncWriteExt,
19 spawn,
20 sync::Semaphore,
21 task::{JoinError, JoinHandle},
7}; 22};
8 23
9use crate::{ 24use crate::{
10 config::Config, 25 config::RuntimeConfig,
11 errors::McError, 26 errors::McError,
12 minecraft::manifests::{Library, Version}, 27 minecraft::manifests::{AssetIndex, Library, LibraryArtifact, Version},
13 platform::paths, 28 platform::paths::{assets_directory, client_jar, library_file},
14}; 29};
15 30
31const MAX_CONCURRENT_DOWNLOADS: usize = 100;
32const PROGRESS_INITIAL_VALUE: usize = 0;
33const ASSET_URL_BASE: &str = "https://resources.download.minecraft.net/";
34const MAX_PROGRESS_PERCENT: f64 = 100.0;
35const PROGRESS_PRINT_WIDTH: usize = 3;
36const ATOMIC_ORDERING: Ordering = SeqCst;
37const DOWNLOAD_COMPLETE_MESSAGE: &str = "All downloads completed successfully!";
38const ASSET_INDEX_MISSING_ERROR: &str = "Missing asset_index for the version";
39
40impl From<JoinError> for McError {
41 fn from(error: JoinError) -> Self {
42 Config(format!("Task panicked: {}", error))
43 }
44}
45
46/// Represents a single file to download, including the URL and destination
47/// path.
48#[derive(Debug, Clone)]
49struct DownloadJob {
50 url: String,
51 destination_path: PathBuf,
52}
53
54impl DownloadJob {
55 fn already_exists(&self) -> bool { self.destination_path.exists() }
56}
57
58/// Represents a single asset entry in the Minecraft asset index.
16#[derive(Debug, Deserialize)] 59#[derive(Debug, Deserialize)]
17struct AssetObject { 60struct AssetObject {
18 hash: String, 61 hash: String,
19 // size: u64,
20} 62}
21 63
64/// The Minecraft asset index manifest.
22#[derive(Debug, Deserialize)] 65#[derive(Debug, Deserialize)]
23struct AssetIndexManifest { 66struct AssetIndexManifest {
24 objects: std::collections::HashMap<String, AssetObject>, 67 objects: HashMap<String, AssetObject>,
25} 68}
26 69
27pub async fn download_all( 70/// Download all files required to run a specific Minecraft version.
28 config: &Config, 71pub async fn download_all_files(
29 version: &Version, 72 http_client: &reqwest::Client,
73 runtime_config: &RuntimeConfig,
74 version_info: &Version,
30) -> Result<(), McError> { 75) -> Result<(), McError> {
31 download_client(config, version).await?; 76 let assets_directory_path: PathBuf =
32 download_libraries(config, &version.libraries).await?; 77 ensure_assets_directories_exist(runtime_config).await?;
33 download_assets(config, version).await?; 78 let asset_manifest: AssetIndexManifest = load_asset_index_manifest(
79 http_client,
80 &assets_directory_path,
81 version_info,
82 )
83 .await?;
84
85 let download_jobs: Vec<DownloadJob> = build_download_jobs(
86 runtime_config,
87 version_info,
88 &assets_directory_path,
89 &asset_manifest,
90 );
91
92 execute_download_jobs(http_client, download_jobs).await?;
93 println!("\n{}", DOWNLOAD_COMPLETE_MESSAGE);
94
34 Ok(()) 95 Ok(())
35} 96}
36 97
37async fn download_client( 98/// Ensure the essential assets directories exist.
38 config: &Config, 99async fn ensure_assets_directories_exist(
39 version: &Version, 100 config: &RuntimeConfig,
40) -> Result<(), McError> { 101) -> Result<PathBuf, McError> {
41 let jar_path = paths::client_jar(config, &version.id)?; 102 let assets_dir = assets_directory(config);
42 103 create_dir_all(assets_dir.join("objects")).await?;
43 if jar_path.exists() { 104 create_dir_all(assets_dir.join("indexes")).await?;
44 debug!("Client jar already exists: {}", jar_path.display()); 105 Ok(assets_dir)
45 return Ok(());
46 }
47
48 info!("Downloading client {}", version.id);
49 download_file(&version.downloads.client.url, &jar_path).await
50} 106}
51 107
52async fn download_libraries( 108/// Load the asset index manifest for the given Minecraft version.
53 config: &Config, 109async fn load_asset_index_manifest(
54 libraries: &[Library], 110 http_client: &reqwest::Client,
55) -> Result<(), McError> { 111 assets_dir: &Path,
56 for library in libraries { 112 version_info: &Version,
57 if let Some(artifact) = &library.downloads.artifact { 113) -> Result<AssetIndexManifest, McError> {
58 let library_path = paths::library_file(config, &artifact.path)?; 114 let asset_index: &AssetIndex = version_info
59 115 .asset_index
60 if !library_path.exists() { 116 .as_ref()
61 info!("Downloading library {}", artifact.path); 117 .ok_or_else(|| Config(ASSET_INDEX_MISSING_ERROR.into()))?;
62 download_file(&artifact.url, &library_path).await?;
63 }
64 }
65
66 if let Some(classifiers) = &library.downloads.classifiers {
67 for (_, native) in classifiers {
68 let native_path = paths::library_file(config, &native.path)?;
69 118
70 if native_path.exists() { 119 let index_file_path: PathBuf = assets_dir
71 continue; 120 .join("indexes")
72 } 121 .join(format!("{}.json", asset_index.id));
73 122
74 info!("Downloading native library {}", native.path); 123 if !index_file_path.exists() {
75 download_file(&native.url, &native_path).await?; 124 download_text_file(http_client, &asset_index.url, &index_file_path)
76 } 125 .await?;
77 }
78 } 126 }
79 127
80 Ok(()) 128 let json_string: String = read_to_string(index_file_path).await?;
129 Ok(serde_json::from_str(&json_string)?)
81} 130}
82 131
83async fn download_asset_index( 132fn build_download_jobs(
84 config: &Config, 133 config: &RuntimeConfig,
85 version: &Version, 134 version_info: &Version,
86) -> Result<AssetIndexManifest, McError> { 135 assets_dir: &Path,
87 let assets_dir = paths::assets_dir(config); 136 asset_manifest: &AssetIndexManifest,
88 create_dir_all(assets_dir.join("indexes")).await?; 137) -> Vec<DownloadJob> {
138 let mut jobs: Vec<DownloadJob> = Vec::new();
89 139
90 let asset_index = version.asset_index.as_ref().ok_or_else(|| { 140 add_client_download_job(&mut jobs, config, version_info);
91 McError::Config("Missing asset_index in version.json".into()) 141 add_library_download_jobs(&mut jobs, config, version_info);
92 })?; 142 add_asset_download_jobs(&mut jobs, assets_dir, asset_manifest);
93 143
94 if asset_index.id == "legacy" { 144 jobs
95 return Err(McError::Config( 145}
96 "Legacy assetIndex detected – pobierz właściwy version.json".into(),
97 ));
98 }
99 146
100 let index_path = assets_dir 147fn add_client_download_job(
101 .join("indexes") 148 jobs: &mut Vec<DownloadJob>,
102 .join(format!("{}.json", asset_index.id)); 149 config: &RuntimeConfig,
150 version_info: &Version,
151) {
152 jobs.push(DownloadJob {
153 url: version_info.downloads.client.url.clone(),
154 destination_path: client_jar(config, &version_info.id),
155 });
156}
103 157
104 if index_path.exists() { 158fn add_library_download_jobs(
105 let index_data = fs::read_to_string(&index_path).await?; 159 jobs: &mut Vec<DownloadJob>,
106 let manifest: AssetIndexManifest = serde_json::from_str(&index_data)?; 160 config: &RuntimeConfig,
107 return Ok(manifest); 161 version_info: &Version,
162) {
163 for library in &version_info.libraries {
164 add_library_artifact_job(jobs, config, library);
165 add_library_classifier_jobs(jobs, config, library);
108 } 166 }
167}
168fn add_library_artifact_job(
169 jobs: &mut Vec<DownloadJob>,
170 config: &RuntimeConfig,
171 library: &Library,
172) {
173 let artifact: &LibraryArtifact = match &library.downloads.artifact {
174 | Some(a) => a,
175 | None => return,
176 };
177
178 jobs.push(DownloadJob {
179 url: artifact.url.clone(),
180 destination_path: library_file(config, &artifact.path),
181 });
182}
109 183
110 info!("Downloading asset index {}", asset_index.id); 184fn add_library_classifier_jobs(
111 let response = get(&asset_index.url).await?; 185 jobs: &mut Vec<DownloadJob>,
112 let manifest_text = response.text().await?; 186 config: &RuntimeConfig,
113 187 library: &Library,
114 fs::write(&index_path, &manifest_text).await?; 188) {
189 let classifiers: &HashMap<String, LibraryArtifact> =
190 match &library.downloads.classifiers {
191 | Some(values) => values,
192 | None => return,
193 };
194
195 for classifier_entry in classifiers.values() {
196 jobs.push(DownloadJob {
197 url: classifier_entry.url.clone(),
198 destination_path: library_file(config, &classifier_entry.path),
199 });
200 }
201}
115 202
116 let manifest: AssetIndexManifest = serde_json::from_str(&manifest_text)?; 203fn add_asset_download_jobs(
117 Ok(manifest) 204 jobs: &mut Vec<DownloadJob>,
205 assets_dir: &Path,
206 asset_manifest: &AssetIndexManifest,
207) {
208 for asset_object in asset_manifest.objects.values() {
209 let prefix: &str = &asset_object.hash[0..2];
210 jobs.push(DownloadJob {
211 url: format!("{}{}/{}", ASSET_URL_BASE, prefix, asset_object.hash),
212 destination_path: assets_dir
213 .join("objects")
214 .join(prefix)
215 .join(&asset_object.hash),
216 });
217 }
118} 218}
119 219
120async fn download_assets( 220async fn execute_download_jobs(
121 config: &Config, 221 http_client: &reqwest::Client,
122 version: &Version, 222 download_jobs: Vec<DownloadJob>,
123) -> Result<(), McError> { 223) -> Result<(), McError> {
124 let assets_dir = paths::assets_dir(config); 224 let total_jobs_count: usize = download_jobs.len();
125 225 let completed_jobs_count: Arc<AtomicUsize> =
126 create_dir_all(assets_dir.join("objects")).await?; 226 Arc::new(AtomicUsize::new(PROGRESS_INITIAL_VALUE));
127 create_dir_all(assets_dir.join("indexes")).await?; 227 let concurrent_download_semaphore: Arc<Semaphore> =
128 228 Arc::new(Semaphore::new(MAX_CONCURRENT_DOWNLOADS));
129 let manifest = download_asset_index(config, version).await?; 229
230 let mut tasks: FuturesUnordered<JoinHandle<Result<(), McError>>> =
231 spawn_missing_download_jobs(
232 http_client,
233 download_jobs,
234 &completed_jobs_count,
235 total_jobs_count,
236 concurrent_download_semaphore,
237 );
130 238
131 for (logical_path, asset) in &manifest.objects { 239 await_download_tasks(&mut tasks).await
132 let subdir = &asset.hash[0..2]; 240}
133 let file_path = assets_dir
134 .join("objects")
135 .join(subdir)
136 .join(&asset.hash);
137 241
138 if file_path.exists() { 242fn spawn_missing_download_jobs(
243 http_client: &reqwest::Client,
244 download_jobs: Vec<DownloadJob>,
245 completed_jobs_counter: &Arc<AtomicUsize>,
246 total_jobs_count: usize,
247 concurrent_download_semaphore: Arc<Semaphore>,
248) -> FuturesUnordered<JoinHandle<Result<(), McError>>> {
249 let download_tasks: FuturesUnordered<JoinHandle<Result<(), McError>>> =
250 FuturesUnordered::new();
251
252 for job in download_jobs {
253 if job.already_exists() {
254 completed_jobs_counter.fetch_add(1, SeqCst);
255 print_download_progress(completed_jobs_counter, total_jobs_count);
139 continue; 256 continue;
140 } 257 }
141 258
142 let url = format!( 259 download_tasks.push(spawn_download_job(
143 "https://resources.download.minecraft.net/{}/{}", 260 http_client.clone(),
144 subdir, asset.hash 261 job,
145 ); 262 concurrent_download_semaphore.clone(),
146 info!("Downloading asset {} -> {}", logical_path, file_path.display()); 263 completed_jobs_counter.clone(),
147 download_file(&url, &file_path).await?; 264 total_jobs_count,
265 ));
148 } 266 }
149 267
150 if let Some(asset) = manifest.objects.get("sounds.json") { 268 download_tasks
151 let file_path = assets_dir.join("indexes").join("sounds.json"); 269}
152 if !file_path.exists() { 270
153 let subdir = &asset.hash[0..2]; 271async fn await_download_tasks(
154 let url = format!( 272 tasks: &mut FuturesUnordered<JoinHandle<Result<(), McError>>>,
155 "https://resources.download.minecraft.net/{}/{}", 273) -> Result<(), McError> {
156 subdir, asset.hash 274 while let Some(task_result) = tasks.next().await {
157 ); 275 task_result.map_err(McError::from)??;
158 info!("Downloading sounds.json");
159 download_file(&url, &file_path).await?;
160 }
161 } 276 }
277 Ok(())
278}
162 279
280fn spawn_download_job(
281 http_client: reqwest::Client,
282 download_job: DownloadJob,
283 concurrent_download_semaphore: Arc<Semaphore>,
284 completed_jobs_count: Arc<AtomicUsize>,
285 total_jobs_count: usize,
286) -> JoinHandle<Result<(), McError>> {
287 spawn(async move {
288 let _permit = concurrent_download_semaphore
289 .acquire_owned()
290 .await
291 .unwrap();
292 download_file(&http_client, &download_job).await?;
293 completed_jobs_count.fetch_add(1, SeqCst);
294 print_download_progress(&completed_jobs_count, total_jobs_count);
295 Ok(())
296 })
297}
298
299/// Print the current progress of downloads to stdout.
300///
301/// # Parameters
302/// - `completed_jobs_count`: Atomic counter of completed download jobs.
303/// - `total_jobs_count`: Total number of jobs being processed.
304fn print_download_progress(
305 completed_jobs_count: &AtomicUsize,
306 total_jobs_count: usize,
307) {
308 let completed_jobs: usize = completed_jobs_count.load(ATOMIC_ORDERING);
309 let progress_percentage: f64 = ((completed_jobs as f64
310 / total_jobs_count as f64)
311 * MAX_PROGRESS_PERCENT)
312 .min(MAX_PROGRESS_PERCENT);
313
314 print!(
315 "\rDownloading game files: {:>width$.0}%",
316 progress_percentage,
317 width = PROGRESS_PRINT_WIDTH
318 );
319 stdout().flush().unwrap();
320}
321
322async fn download_text_file(
323 http_client: &reqwest::Client,
324 file_url: &str,
325 destination_path: &PathBuf,
326) -> Result<(), McError> {
327 let text_content = http_client
328 .get(file_url)
329 .send()
330 .await?
331 .error_for_status()?
332 .text()
333 .await?;
334 write(destination_path, text_content).await?;
163 Ok(()) 335 Ok(())
164} 336}
165 337
166async fn download_file( 338async fn download_file(
167 url: &str, 339 http_client: &reqwest::Client,
168 path: &std::path::Path, 340 download_job: &DownloadJob,
169) -> Result<(), McError> { 341) -> Result<(), McError> {
170 if let Some(parent) = path.parent() { 342 if let Some(parent_dir) = download_job.destination_path.parent() {
171 create_dir_all(parent).await?; 343 create_dir_all(parent_dir).await?;
172 } 344 }
173 345
174 let response = get(url).await?; 346 let response: Response = http_client
175 let bytes = response.bytes().await?; 347 .get(&download_job.url)
176 348 .send()
177 let mut file = File::create(path).await?; 349 .await?
178 file.write_all(&bytes).await?; 350 .error_for_status()?;
351 let mut byte_stream = response.bytes_stream();
352 let mut file_handle: File =
353 File::create(&download_job.destination_path).await?;
354
355 while let Some(chunk) = byte_stream.next().await {
356 file_handle.write_all(&chunk?).await?;
357 }
179 358
180 Ok(()) 359 Ok(())
181} 360}
diff --git a/src/minecraft/extraction.rs b/src/minecraft/extraction.rs
index b58fd2e..292566f 100644
--- a/src/minecraft/extraction.rs
+++ b/src/minecraft/extraction.rs
@@ -1,11 +1,17 @@
1use std::{fs, io, path::Path}; 1use std::{
2 collections::HashMap,
3 fs,
4 fs::File,
5 io,
6 path::{Path, PathBuf},
7};
2 8
3use log::info; 9use zip::{read::ZipFile, ZipArchive};
4use zip::ZipArchive;
5 10
6use crate::{ 11use crate::{
7 errors::McError, 12 errors::McError,
8 minecraft::manifests::{Library, Version}, 13 minecraft::manifests::{LibraryArtifact, Version},
14 util::fs::library_allowed,
9}; 15};
10 16
11pub fn extract_natives( 17pub fn extract_natives(
@@ -19,8 +25,6 @@ pub fn extract_natives(
19 .join(&version.id) 25 .join(&version.id)
20 .join("natives"); 26 .join("natives");
21 27
22 info!("Extracting natives for {} into {:?}", version.id, natives_dir);
23
24 if natives_dir.exists() { 28 if natives_dir.exists() {
25 fs::remove_dir_all(&natives_dir)?; 29 fs::remove_dir_all(&natives_dir)?;
26 } 30 }
@@ -31,75 +35,51 @@ pub fn extract_natives(
31 continue; 35 continue;
32 } 36 }
33 37
34 let natives = match &lib.natives { 38 let natives: &HashMap<String, String> = match &lib.natives {
35 | Some(n) => n, 39 | Some(n) => n,
36 | None => continue, 40 | None => continue,
37 }; 41 };
38 42
39 let classifier = match natives.get("linux") { 43 let classifier: &String = match natives.get("linux") {
40 | Some(c) => c, 44 | Some(c) => c,
41 | None => continue, 45 | None => continue,
42 }; 46 };
43 47
44 let classifiers = match &lib.downloads.classifiers { 48 let classifiers: &HashMap<String, LibraryArtifact> =
45 | Some(c) => c, 49 match &lib.downloads.classifiers {
46 | None => continue, 50 | Some(c) => c,
47 }; 51 | None => continue,
52 };
48 53
49 let artifact = match classifiers.get(classifier) { 54 let artifact: &LibraryArtifact = match classifiers.get(classifier) {
50 | Some(a) => a, 55 | Some(a) => a,
51 | None => continue, 56 | None => continue,
52 }; 57 };
53 58
54 let jar_path = cfg 59 let jar_path: PathBuf = cfg
55 .data_dir 60 .data_dir
56 .join("minecraft") 61 .join("minecraft")
57 .join("libraries") 62 .join("libraries")
58 .join(&artifact.path); 63 .join(&artifact.path);
59 64
60 info!("Extracting natives from {:?}", jar_path);
61
62 extract_zip(&jar_path, &natives_dir)?; 65 extract_zip(&jar_path, &natives_dir)?;
63 } 66 }
64 67
65 Ok(()) 68 Ok(())
66} 69}
67
68fn library_allowed(lib: &Library) -> bool {
69 let rules = match &lib.rules {
70 | Some(r) => r,
71 | None => return true,
72 };
73
74 let mut allowed = false;
75
76 for rule in rules {
77 let os_match = match &rule.os {
78 | Some(os) => os.name == "linux",
79 | None => true,
80 };
81
82 if os_match {
83 allowed = rule.action == "allow";
84 }
85 }
86
87 allowed
88}
89
90fn extract_zip(jar_path: &Path, out_dir: &Path) -> Result<(), McError> { 70fn extract_zip(jar_path: &Path, out_dir: &Path) -> Result<(), McError> {
91 let file = fs::File::open(jar_path)?; 71 let file: File = File::open(jar_path)?;
92 let mut zip = ZipArchive::new(file)?; 72 let mut zip: ZipArchive<File> = ZipArchive::new(file)?;
93 73
94 for i in 0..zip.len() { 74 for i in 0..zip.len() {
95 let mut entry = zip.by_index(i)?; 75 let mut entry: ZipFile<File> = zip.by_index(i)?;
96 let name = entry.name(); 76 let name: &str = entry.name();
97 77
98 if name.starts_with("META-INF/") { 78 if name.starts_with("META-INF/") {
99 continue; 79 continue;
100 } 80 }
101 81
102 let out_path = out_dir.join(name); 82 let out_path: PathBuf = out_dir.join(name);
103 83
104 if entry.is_dir() { 84 if entry.is_dir() {
105 fs::create_dir_all(&out_path)?; 85 fs::create_dir_all(&out_path)?;
@@ -110,7 +90,7 @@ fn extract_zip(jar_path: &Path, out_dir: &Path) -> Result<(), McError> {
110 fs::create_dir_all(parent)?; 90 fs::create_dir_all(parent)?;
111 } 91 }
112 92
113 let mut out_file = fs::File::create(&out_path)?; 93 let mut out_file: File = File::create(&out_path)?;
114 io::copy(&mut entry, &mut out_file)?; 94 io::copy(&mut entry, &mut out_file)?;
115 } 95 }
116 96
diff --git a/src/minecraft/launcher.rs b/src/minecraft/launcher.rs
index 2eeaf21..3cca948 100644
--- a/src/minecraft/launcher.rs
+++ b/src/minecraft/launcher.rs
@@ -1,64 +1,104 @@
1use std::process::Command; 1use paths::{client_jar, library_file, natives_dir};
2 2use serde::Deserialize;
3use log::{debug, info}; 3use std::path::Path;
4use std::process::Output;
5use std::{
6 path::PathBuf,
7 process::{Command, ExitStatus},
8};
9use McError::Process;
4 10
5use crate::{ 11use crate::{
6 config::Config, 12 config::Config, errors::McError, minecraft::manifests::Version,
7 errors::McError, 13 platform::paths, util::fs::library_allowed,
8 minecraft::manifests::{Library, Version},
9 platform::paths,
10}; 14};
11 15
12fn build_classpath( 16#[derive(Debug, Clone, Deserialize)]
13 config: &Config, 17pub struct JavaRuntime {
14 version: &Version, 18 pub major: u8,
15) -> Result<String, McError> { 19 pub path: PathBuf,
16 let sep = if cfg!(windows) { ";" } else { ":" }; 20}
17 let mut entries = Vec::new(); 21
22impl JavaRuntime {
23 fn validate(&self) -> Result<(), McError> {
24 let output: Output = Command::new(&self.path)
25 .arg("-version")
26 .output()
27 .map_err(|e| {
28 McError::Runtime(format!(
29 "Failed to execute Java at {}: {}",
30 self.path.display(),
31 e
32 ))
33 })?;
34
35 if !output.status.success() {
36 return Err(McError::Runtime(format!(
37 "Invalid Java binary: {}",
38 self.path.display()
39 )));
40 }
41
42 Ok(())
43 }
44}
45
46fn required_java_major(java_major: Option<u8>) -> u8 {
47 java_major.unwrap_or(8)
48}
49
50fn resolve_runtime(
51 required: u8,
52 runtimes: &[JavaRuntime],
53) -> Result<PathBuf, McError> {
54 let mut candidates: Vec<&JavaRuntime> = runtimes
55 .iter()
56 .filter(|r| r.major >= required)
57 .collect();
58
59 candidates.sort_by_key(|r| r.major);
60
61 let runtime = candidates.first().ok_or_else(|| {
62 McError::Runtime(format!(
63 "No suitable Java runtime found (required: Java {})",
64 required
65 ))
66 })?;
67
68 runtime.validate()?;
69 Ok(runtime.path.clone())
70}
71
72fn build_classpath(config: &Config, version: &Version) -> String {
73 let system_separator: &str = if cfg!(windows) { ";" } else { ":" };
74 let mut entries: Vec<String> = Vec::new();
18 75
19 for library in &version.libraries { 76 for library in &version.libraries {
20 if !library_allowed(library) { 77 if !library_allowed(library) {
21 continue; 78 continue;
22 } 79 }
80
23 if let Some(artifact) = &library.downloads.artifact { 81 if let Some(artifact) = &library.downloads.artifact {
24 let path = paths::library_file(config, &artifact.path)?; 82 let path = library_file(config, &artifact.path);
25 entries.push(path.to_string_lossy().to_string()); 83 entries.push(path.to_string_lossy().to_string());
26 } 84 }
27 } 85 }
28 86
29 let client_jar = paths::client_jar(config, &version.id)?; 87 let client: PathBuf = client_jar(config, &version.id);
30 entries.push(client_jar.to_string_lossy().to_string()); 88 entries.push(client.to_string_lossy().to_string());
31 89
32 Ok(entries.join(sep)) 90 entries.join(system_separator)
33} 91}
34 92
35pub fn launch(config: &Config, version: &Version) -> Result<(), McError> { 93fn build_command(
36 let java = &config.java_path; 94 java_path: PathBuf,
37 let classpath = build_classpath(config, version)?; 95 config: &Config,
38 let natives_dir = paths::natives_dir(config, &version.id); 96 version: &Version,
39 97 natives_dir: &Path,
40 if !natives_dir.exists() { 98 classpath: String,
41 return Err(McError::Runtime(format!( 99 asset_index_id: String,
42 "Natives folder does not exist: {}", 100) -> Command {
43 natives_dir.display() 101 let mut cmd: Command = Command::new(java_path);
44 )));
45 }
46
47 let asset_index_id = version
48 .asset_index
49 .as_ref()
50 .ok_or_else(|| {
51 McError::Runtime("Missing assetIndex in version.json".into())
52 })?
53 .id
54 .clone();
55
56 info!("Launching Minecraft {}", version.id);
57 debug!("Classpath: {}", classpath);
58 debug!("Natives: {}", natives_dir.display());
59 debug!("Asset index: {}", asset_index_id);
60
61 let mut cmd = Command::new(java);
62 102
63 cmd.arg(format!("-Xmx{}M", config.max_memory_mb)) 103 cmd.arg(format!("-Xmx{}M", config.max_memory_mb))
64 .arg(format!("-Djava.library.path={}", natives_dir.display())); 104 .arg(format!("-Djava.library.path={}", natives_dir.display()));
@@ -76,11 +116,11 @@ pub fn launch(config: &Config, version: &Version) -> Result<(), McError> {
76 .arg("--version") 116 .arg("--version")
77 .arg(&version.id) 117 .arg(&version.id)
78 .arg("--gameDir") 118 .arg("--gameDir")
79 .arg(paths::game_dir(config)) 119 .arg(paths::game_directory(config))
80 .arg("--assetsDir") 120 .arg("--assetsDir")
81 .arg(paths::assets_dir(config)) 121 .arg(paths::assets_directory(config))
82 .arg("--assetIndex") 122 .arg("--assetIndex")
83 .arg(&asset_index_id) 123 .arg(asset_index_id)
84 .arg("--uuid") 124 .arg("--uuid")
85 .arg(&config.uuid) 125 .arg(&config.uuid)
86 .arg("--userProperties") 126 .arg("--userProperties")
@@ -90,32 +130,128 @@ pub fn launch(config: &Config, version: &Version) -> Result<(), McError> {
90 .arg("--userType") 130 .arg("--userType")
91 .arg("legacy"); 131 .arg("legacy");
92 132
93 let status = cmd.status()?; 133 cmd
134}
135
136pub fn launch(config: &Config, version: &Version) -> Result<(), McError> {
137 let required: u8 = required_java_major(
138 version
139 .java_version
140 .as_ref()
141 .map(|j| j.major_version),
142 );
143
144 let java_path: PathBuf = resolve_runtime(required, &config.runtimes)?;
145
146 let classpath: String = build_classpath(config, version);
147 let natives_dir: PathBuf = natives_dir(config, &version.id);
148
149 if !natives_dir.exists() {
150 return Err(McError::Runtime(format!(
151 "Natives folder does not exist: {}",
152 natives_dir.display()
153 )));
154 }
155
156 let asset_index_id = version
157 .asset_index
158 .as_ref()
159 .ok_or_else(|| {
160 McError::Runtime("Missing assetIndex in version.json".into())
161 })?
162 .id
163 .clone();
164
165 let mut cmd: Command = build_command(
166 java_path,
167 config,
168 version,
169 &natives_dir,
170 classpath,
171 asset_index_id,
172 );
173
174 let status: ExitStatus = cmd.status()?;
94 175
95 if !status.success() { 176 if !status.success() {
96 return Err(McError::Process("Minecraft exited with error".into())); 177 return Err(Process("Minecraft exited with error".into()));
97 } 178 }
98 179
99 Ok(()) 180 Ok(())
100} 181}
101 182
102fn library_allowed(lib: &Library) -> bool { 183#[cfg(test)]
103 let rules = match &lib.rules { 184mod tests {
104 | Some(r) => r, 185 use super::*;
105 | None => return true, 186 use std::path::PathBuf;
106 }; 187
188 #[test]
189 fn required_java_major_defaults_to_8() {
190 assert_eq!(required_java_major(None), 8);
191 }
192
193 #[test]
194 fn required_java_major_uses_given_value() {
195 assert_eq!(required_java_major(Some(17)), 17);
196 }
197
198 #[test]
199 fn resolve_runtime_errors_when_no_runtimes() {
200 let runtimes: Vec<JavaRuntime> = vec![];
201 assert!(resolve_runtime(17, &runtimes).is_err());
202 }
203
204 #[test]
205 fn resolve_runtime_picks_lowest_matching_major() {
206 let runtimes: Vec<JavaRuntime> = vec![
207 JavaRuntime {
208 major: 21,
209 path: PathBuf::from("/bin/true"),
210 },
211 JavaRuntime {
212 major: 17,
213 path: PathBuf::from("/bin/true"),
214 },
215 ];
216
217 let result: PathBuf = resolve_runtime(8, &runtimes).unwrap();
218 assert_eq!(result, PathBuf::from("/bin/true"));
219 }
107 220
108 let mut allowed = false; 221 #[test]
222 fn resolve_runtime_respects_required_version() {
223 let runtimes: Vec<JavaRuntime> = vec![
224 JavaRuntime {
225 major: 8,
226 path: PathBuf::from("/bin/true"),
227 },
228 JavaRuntime {
229 major: 17,
230 path: PathBuf::from("/bin/true"),
231 },
232 ];
233
234 let result: PathBuf = resolve_runtime(17, &runtimes).unwrap();
235 assert_eq!(result, PathBuf::from("/bin/true"));
236 }
109 237
110 for rule in rules { 238 #[test]
111 let os_match = match &rule.os { 239 fn validate_succeeds_for_true_binary() {
112 | Some(os) => os.name == "linux", 240 let runtime = JavaRuntime {
113 | None => true, 241 major: 17,
242 path: PathBuf::from("/bin/true"),
114 }; 243 };
115 if os_match { 244
116 allowed = rule.action == "allow"; 245 assert!(runtime.validate().is_ok());
117 }
118 } 246 }
119 247
120 allowed 248 #[test]
249 fn validate_fails_for_false_binary() {
250 let runtime = JavaRuntime {
251 major: 17,
252 path: PathBuf::from("/bin/false"),
253 };
254
255 assert!(runtime.validate().is_err());
256 }
121} 257}
diff --git a/src/minecraft/manifests.rs b/src/minecraft/manifests.rs
index 8bdec26..64e38da 100644
--- a/src/minecraft/manifests.rs
+++ b/src/minecraft/manifests.rs
@@ -1,11 +1,18 @@
1#![allow(dead_code)] 1#![allow(dead_code)]
2 2
3use std::collections::HashMap; 3use crate::{constants::VERSION_MANIFEST_URL, errors::McError};
4 4use reqwest::get;
5use reqwest;
6use serde::Deserialize; 5use serde::Deserialize;
6use serde_json::{from_str, Value};
7use std::collections::HashMap;
8use McError::Config;
7 9
8use crate::{constants::VERSION_MANIFEST_URL, errors::McError}; 10#[derive(Debug, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct JavaVersionInfo {
13 pub component: String,
14 pub major_version: u8,
15}
9 16
10#[derive(Debug, Deserialize)] 17#[derive(Debug, Deserialize)]
11pub struct Version { 18pub struct Version {
@@ -19,6 +26,10 @@ pub struct Version {
19 26
20 #[serde(rename = "assetIndex")] 27 #[serde(rename = "assetIndex")]
21 pub asset_index: Option<AssetIndex>, 28 pub asset_index: Option<AssetIndex>,
29
30 #[serde(default)]
31 #[serde(rename = "javaVersion")]
32 pub java_version: Option<JavaVersionInfo>,
22} 33}
23 34
24#[derive(Debug, Deserialize)] 35#[derive(Debug, Deserialize)]
@@ -83,15 +94,13 @@ pub struct OsRule {
83pub async fn load_version( 94pub async fn load_version(
84 cfg: &crate::config::Config, 95 cfg: &crate::config::Config,
85) -> Result<Version, McError> { 96) -> Result<Version, McError> {
86 let manifest_text = reqwest::get(VERSION_MANIFEST_URL) 97 let manifest_text = get(VERSION_MANIFEST_URL).await?.text().await?;
87 .await? 98 let root: Value = from_str(&manifest_text)?;
88 .text() 99
89 .await?;
90 let root: serde_json::Value = serde_json::from_str(&manifest_text)?;
91 let version_id = if cfg.version == "latest" { 100 let version_id = if cfg.version == "latest" {
92 root["latest"]["release"] 101 root["latest"]["release"]
93 .as_str() 102 .as_str()
94 .ok_or_else(|| McError::Config("missing latest.release".into()))? 103 .ok_or_else(|| Config("missing latest.release".into()))?
95 .to_string() 104 .to_string()
96 } else { 105 } else {
97 cfg.version.clone() 106 cfg.version.clone()
@@ -99,21 +108,19 @@ pub async fn load_version(
99 108
100 let versions = root["versions"] 109 let versions = root["versions"]
101 .as_array() 110 .as_array()
102 .ok_or_else(|| McError::Config("missing versions array".into()))?; 111 .ok_or_else(|| Config("missing versions array".into()))?;
103 112
104 let version_entry = versions 113 let version_entry = versions
105 .iter() 114 .iter()
106 .find(|v| v["id"].as_str() == Some(&version_id)) 115 .find(|v| v["id"].as_str() == Some(&version_id))
107 .ok_or_else(|| { 116 .ok_or_else(|| Config(format!("version '{}' not found", version_id)))?;
108 McError::Config(format!("version '{}' not found", version_id))
109 })?;
110 117
111 let url = version_entry["url"] 118 let url = version_entry["url"]
112 .as_str() 119 .as_str()
113 .ok_or_else(|| McError::Config("missing version url".into()))?; 120 .ok_or_else(|| Config("missing version url".into()))?;
114 121
115 let version_text = reqwest::get(url).await?.text().await?; 122 let version_text = get(url).await?.text().await?;
116 let version: Version = serde_json::from_str(&version_text)?; 123 let version: Version = from_str(&version_text)?;
117 124
118 Ok(version) 125 Ok(version)
119} 126}
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 @@
1use std::{fs::create_dir_all, path::PathBuf}; 1use std::{fs::create_dir_all, path::PathBuf};
2 2
3use crate::{config::Config, errors::McError}; 3use 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.
6pub 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.
18pub 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
10pub 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.
34pub fn assets_directory(cfg: &Config) -> PathBuf {
35 root_directory(cfg).join(directory::ASSETS)
12} 36}
13 37
14pub fn game_dir(cfg: &Config) -> PathBuf { minecraft_root(cfg) } 38/// Returns the path to the game directory (same as root directory for now).
15pub 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.
48pub 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.
78pub 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.
28pub fn version_dir(cfg: &Config, version: &str) -> PathBuf { 107pub 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
32pub 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.
124pub fn client_jar(cfg: &Config, version: &str) -> PathBuf {
125 version_dir(cfg, version).join(format!("{version}.jar"))
34} 126}
35 127
36pub 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.
140pub 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.
42pub fn natives_dir(cfg: &Config, version: &str) -> PathBuf { 158pub 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)]
168mod 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
5use tokio::fs::remove_file; 5use tokio::fs::remove_file;
6 6
7use crate::errors::McError; 7use crate::{
8 errors::McError,
9 minecraft::manifests::{Library, Rule},
10};
8 11
9pub async fn remove_if_exists(path: &Path) -> Result<(), McError> { 12pub 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
19pub 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
3use std::path::Path; 3use std::path::Path;
4 4
5use sha1::{Digest, Sha1}; 5use sha1::{Digest, Sha1, Sha1Core};
6use sha1::digest::core_api::CoreWrapper;
6use tokio::fs::read; 7use tokio::fs::read;
7 8
8use crate::errors::McError; 9use crate::errors::McError;
9 10
10pub async fn sha1_hex(path: &Path) -> Result<String, McError> { 11pub 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}