From 72ddd7b7704f2087a52c9c0552446682918c513b Mon Sep 17 00:00:00 2001 From: Filip Wandzio Date: Thu, 22 Jan 2026 23:14:08 +0100 Subject: Implement basic game files download logic Implement core clap arguments Respect XDG_BASE_DIR Currently library extraction is broken because it assumes every instace has it's own library folder. This should be refactored so instances share libraries Signed-off-by: Filip Wandzio --- .gitignore | 8 +++++ Cargo.toml | 22 +++++++++++++ clippy.toml | 8 +++++ rustfmt.toml | 33 +++++++++++++++++++ src/config/loader.rs | 70 +++++++++++++++++++++++++++++++++++++++ src/config/mod.rs | 3 ++ src/constants.rs | 13 ++++++++ src/errors.rs | 34 +++++++++++++++++++ src/main.rs | 60 ++++++++++++++++++++++++++++++++++ src/minecraft/downloads.rs | 66 +++++++++++++++++++++++++++++++++++++ src/minecraft/extraction.rs | 10 ++++++ src/minecraft/launcher.rs | 73 +++++++++++++++++++++++++++++++++++++++++ src/minecraft/manifests.rs | 80 +++++++++++++++++++++++++++++++++++++++++++++ src/minecraft/mod.rs | 4 +++ src/platform/mod.rs | 1 + src/platform/paths.rs | 48 +++++++++++++++++++++++++++ src/util/fs.rs | 12 +++++++ src/util/mod.rs | 2 ++ src/util/sha1.rs | 14 ++++++++ 19 files changed, 561 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 clippy.toml create mode 100644 rustfmt.toml create mode 100644 src/config/loader.rs create mode 100644 src/config/mod.rs create mode 100644 src/constants.rs create mode 100644 src/errors.rs create mode 100644 src/main.rs create mode 100644 src/minecraft/downloads.rs create mode 100644 src/minecraft/extraction.rs create mode 100644 src/minecraft/launcher.rs create mode 100644 src/minecraft/manifests.rs create mode 100644 src/minecraft/mod.rs create mode 100644 src/platform/mod.rs create mode 100644 src/platform/paths.rs create mode 100644 src/util/fs.rs create mode 100644 src/util/mod.rs create mode 100644 src/util/sha1.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6aedc81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/target +logs +debug/ +target/ +Cargo.lock +**/*.rs.bk +*.pdb +rust-project.json diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9c0eb1b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "dml" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4.5.54", features = ["derive"] } +directories = "6.0.0" +dotenvy = "0.15.7" +env_logger = "0.11.8" +futures-util = "0.3.31" +indicatif = "0.18.3" +log = "0.4.29" +rayon = "1.11.0" +reqwest = { version = "0.13.1", features = ["json", "stream"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +sha1 = "0.10.6" +tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros"] } +toml = "0.9.11" +uuid = { version = "1.19.0", features = ["v4"] } +zip = "7.2.0" diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..f688e0a --- /dev/null +++ b/clippy.toml @@ -0,0 +1,8 @@ +stack-size-threshold = 393216 +future-size-threshold = 24576 +array-size-threshold = 4096 +large-error-threshold = 256 # TODO reduce me ALARA +too-many-lines-threshold = 100 # TODO reduce me to <= 100 +excessive-nesting-threshold = 3 +type-complexity-threshold = 250 # reduce me to ~200 +cognitive-complexity-threshold = 100 # TODO reduce me ALARA diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..32fb2b2 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,33 @@ +array_width = 80 +attr_fn_like_width = 60 +chain_width = 50 +comment_width = 80 +condense_wildcard_suffixes = true +style_edition = "2024" +fn_call_width = 80 +fn_single_line = true +format_code_in_doc_comments = true +format_macro_bodies = true +format_macro_matchers = true +format_strings = true +group_imports = "StdExternalCrate" +hex_literal_case = "Upper" +imports_granularity = "Crate" +match_arm_blocks = false +match_arm_leading_pipes = "Always" +match_block_trailing_comma = true +max_width = 98 +newline_style = "Unix" +normalize_comments = false +overflow_delimited_expr = true +reorder_impl_items = true +reorder_imports = true +single_line_if_else_max_width = 60 +single_line_let_else_max_width = 80 +struct_lit_width = 40 +tab_spaces = 4 +use_field_init_shorthand = true +use_small_heuristics = "Off" +use_try_shorthand = true +wrap_comments = true +unstable_features = true diff --git a/src/config/loader.rs b/src/config/loader.rs new file mode 100644 index 0000000..81a4351 --- /dev/null +++ b/src/config/loader.rs @@ -0,0 +1,70 @@ +use std::{env, path::PathBuf}; + +use serde::Deserialize; + +use crate::{constants::*, errors::McError}; + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct Config { + pub username: String, + pub uuid: String, + pub version: String, + pub java_path: String, + pub max_memory_mb: u32, + pub data_dir: PathBuf, + pub cache_dir: PathBuf, + pub config_dir: PathBuf, + #[serde(default)] + pub jvm_args: Vec, +} + +impl Config { + pub fn load() -> Result { + let cfg_path = default_config_path()?; + let mut cfg: Config = if cfg_path.exists() { + let txt = std::fs::read_to_string(&cfg_path)?; + toml::from_str(&txt).map_err(|e| McError::Config(e.to_string()))? + } else { + Self::default() + }; + + if let Ok(v) = env::var("MC_USERNAME") { + cfg.username = v; + } + if let Ok(v) = env::var("MC_VERSION") { + cfg.version = v; + } + if let Ok(v) = env::var("MC_JAVA_PATH") { + cfg.java_path = v; + } + if let Ok(v) = env::var("MC_MAX_MEMORY_MB") { + cfg.max_memory_mb = v.parse().unwrap_or(cfg.max_memory_mb); + } + + Ok(cfg) + } + + fn default() -> Self { + let base = + directories::ProjectDirs::from("com", "example", "mccl").expect("platform dirs"); + + Self { + username: "Player".into(), + uuid: uuid::Uuid::new_v4().to_string(), + version: DEFAULT_VERSION.into(), + java_path: DEFAULT_JAVA_PATH.into(), + max_memory_mb: DEFAULT_MAX_MEMORY_MB, + data_dir: base.data_dir().into(), + cache_dir: base.cache_dir().into(), + config_dir: base.config_dir().into(), + jvm_args: vec![], + } + } +} + +fn default_config_path() -> Result { + let base = directories::ProjectDirs::from("com", "example", "mccl") + .ok_or_else(|| McError::Config("cannot determine config dir".into()))?; + Ok(base.config_dir().join("config.toml")) +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..c5fc004 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,3 @@ +pub mod loader; + +pub use loader::Config; diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..52833b2 --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,13 @@ +#![allow(dead_code)] + +use std::time::Duration; + +pub const VERSION_MANIFEST_URL: &str = + "https://launchermeta.mojang.com/mc/game/version_manifest.json"; + +pub const DOWNLOAD_RETRIES: usize = 3; +pub const DOWNLOAD_BACKOFF: Duration = Duration::from_millis(400); + +pub const DEFAULT_MAX_MEMORY_MB: u32 = 2048; +pub const DEFAULT_JAVA_PATH: &str = "java"; +pub const DEFAULT_VERSION: &str = "latest"; diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..c167733 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,34 @@ +use std::{fmt, io}; + +#[allow(dead_code)] +#[derive(Debug)] +pub enum McError { + Io(io::Error), + Http(reqwest::Error), + Json(serde_json::Error), + Zip(zip::result::ZipError), + Config(String), + ShaMismatch(String), + Process(String), + Runtime(String), // ← NEW +} + +impl fmt::Display for McError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:?}", self) } +} + +impl From for McError { + fn from(e: io::Error) -> Self { Self::Io(e) } +} + +impl From for McError { + fn from(e: reqwest::Error) -> Self { Self::Http(e) } +} + +impl From for McError { + fn from(e: serde_json::Error) -> Self { Self::Json(e) } +} + +impl From for McError { + fn from(e: zip::result::ZipError) -> Self { Self::Zip(e) } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8a01b9f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,60 @@ +mod constants; +mod errors; + +mod config; +mod minecraft; +mod platform; +mod util; + +use clap::Parser; +use config::Config; +use errors::McError; +use log::{debug, info}; + +#[derive(Parser, Debug)] +#[command(author, about, disable_version_flag = true)] +struct Cli { + #[arg(long)] + version: Option, + + #[arg(long)] + username: Option, + + #[arg(long, num_args(0..), allow_hyphen_values = true)] + jvm_args: Vec, +} + +#[tokio::main] +async fn main() -> Result<(), McError> { + dotenvy::dotenv().ok(); + env_logger::init(); + + let cli = Cli::parse(); + let mut config = Config::load()?; + + if let Some(v) = cli.version { + config.version = v; + } + + if let Some(u) = cli.username { + config.username = u; + } + if !cli.jvm_args.is_empty() { + config.jvm_args = cli.jvm_args; + } + + info!("Final config after CLI overrides: {:?}", config); + + platform::paths::ensure_dirs(&config)?; + info!("Using Minecraft version {}", config.version); + + let version = minecraft::manifests::load_version(&config).await?; + info!("Loaded version manifest for: {}", version.id); + debug!("Main class: {}", version.main_class); + + minecraft::downloads::download_all(&config, &version).await?; + minecraft::extraction::extract_natives(&config, &version)?; + minecraft::launcher::launch(&config, &version)?; + + Ok(()) +} diff --git a/src/minecraft/downloads.rs b/src/minecraft/downloads.rs new file mode 100644 index 0000000..5be5a05 --- /dev/null +++ b/src/minecraft/downloads.rs @@ -0,0 +1,66 @@ +use log::{debug, info}; +use tokio::{fs, io::AsyncWriteExt}; + +use crate::{ + config::Config, + errors::McError, + minecraft::manifests::{Library, Version}, + platform::paths, +}; + +/// Download everything required to launch: +/// - client jar +/// - libraries +pub async fn download_all(config: &Config, version: &Version) -> Result<(), McError> { + download_client(config, version).await?; + download_libraries(config, &version.libraries).await?; + Ok(()) +} + +async fn download_client(config: &Config, version: &Version) -> Result<(), McError> { + let jar_path = paths::client_jar(config, &version.id)?; + + if jar_path.exists() { + debug!("Client jar already exists"); + return Ok(()); + } + + info!("Downloading client {}", version.id); + + download_file(&version.downloads.client.url, &jar_path).await +} + +async fn download_libraries(config: &Config, libraries: &[Library]) -> Result<(), McError> { + for lib in libraries { + let Some(artifact) = &lib.downloads.artifact else { + continue; + }; + + let lib_path = paths::library_file(config, &artifact.path)?; + + if lib_path.exists() { + continue; + } + + info!("Downloading library {}", artifact.path); + download_file(&artifact.url, &lib_path).await?; + } + + Ok(()) +} + +/* ---------------- helper ---------------- */ + +async fn download_file(url: &str, path: &std::path::Path) -> Result<(), McError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await?; + } + + let response = reqwest::get(url).await?; + let bytes = response.bytes().await?; + + let mut file = fs::File::create(path).await?; + file.write_all(&bytes).await?; + + Ok(()) +} diff --git a/src/minecraft/extraction.rs b/src/minecraft/extraction.rs new file mode 100644 index 0000000..5175ee0 --- /dev/null +++ b/src/minecraft/extraction.rs @@ -0,0 +1,10 @@ +use log::info; + +use crate::errors::McError; +pub fn extract_natives( + _cfg: &crate::config::Config, + version: &crate::minecraft::manifests::Version, +) -> Result<(), McError> { + info!("Extracting natives for {}", version.id); + Ok(()) +} diff --git a/src/minecraft/launcher.rs b/src/minecraft/launcher.rs new file mode 100644 index 0000000..f7e3ecc --- /dev/null +++ b/src/minecraft/launcher.rs @@ -0,0 +1,73 @@ +use std::process::Command; + +use log::{debug, info}; + +use crate::{config::Config, errors::McError, minecraft::manifests::Version, platform::paths}; + +/// Build the full classpath +fn build_classpath(config: &Config, version: &Version) -> Result { + let sep = if cfg!(windows) { ";" } else { ":" }; + let mut entries = Vec::new(); + + for lib in &version.libraries { + if let Some(artifact) = &lib.downloads.artifact { + let path = paths::library_file(config, &artifact.path)?; + entries.push(path.to_string_lossy().to_string()); + } + } + + let client_jar = paths::client_jar(config, &version.id)?; + entries.push(client_jar.to_string_lossy().to_string()); + Ok(entries.join(sep)) +} + +/// Launch Minecraft +pub fn launch(config: &Config, version: &Version) -> Result<(), McError> { + let java = &config.java_path; + let classpath = build_classpath(config, version)?; + let natives_dir = paths::natives_dir(config, &version.id); + + if !natives_dir.exists() { + return Err(McError::Runtime(format!( + "Natives folder does not exist: {}", + natives_dir.display() + ))); + } + + info!("Launching Minecraft {}", version.id); + debug!("Classpath: {}", classpath); + debug!("Natives: {}", natives_dir.display()); + + let status = Command::new(java) + .arg(format!("-Xmx{}M", config.max_memory_mb)) + .arg(format!("-Djava.library.path={}", natives_dir.display())) + .arg("-cp") + .arg(classpath) + .arg(&version.main_class) + .arg("--username") + .arg(&config.username) + .arg("--version") + .arg(&version.id) + .arg("--gameDir") + .arg(paths::minecraft_root(config)) + .arg("--assetsDir") + .arg(paths::minecraft_root(config).join("assets")) + .arg("--assetIndex") + .arg(&version.id) + .arg("--uuid") + .arg(&config.uuid) + .arg("--userProperties") + .arg("{}") + .arg("--accessToken") + .arg("0") + .arg("--userType") + .arg("legacy") + .args(&config.jvm_args) + .status()?; + + if !status.success() { + return Err(McError::Process("Minecraft exited with error".into())); + } + + Ok(()) +} diff --git a/src/minecraft/manifests.rs b/src/minecraft/manifests.rs new file mode 100644 index 0000000..3cc59af --- /dev/null +++ b/src/minecraft/manifests.rs @@ -0,0 +1,80 @@ +#![allow(dead_code)] +use reqwest; +use serde::Deserialize; + +use crate::{constants::VERSION_MANIFEST_URL, errors::McError}; + +#[derive(Debug, Deserialize)] +pub struct Version { + pub id: String, + + #[serde(rename = "mainClass")] + pub main_class: String, + + pub downloads: Downloads, + pub libraries: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Downloads { + pub client: DownloadInfo, +} + +#[derive(Debug, Deserialize)] +pub struct DownloadInfo { + pub url: String, + pub sha1: String, + pub size: u64, +} + +#[derive(Debug, Deserialize)] +pub struct Library { + pub downloads: LibraryDownloads, +} + +#[derive(Debug, Deserialize)] +pub struct LibraryDownloads { + pub artifact: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LibraryArtifact { + pub path: String, + pub url: String, + pub sha1: String, + pub size: u64, +} + +pub async fn load_version(cfg: &crate::config::Config) -> Result { + let manifest_text = reqwest::get(VERSION_MANIFEST_URL) + .await? + .text() + .await?; + let root: serde_json::Value = serde_json::from_str(&manifest_text)?; + let version_id = if cfg.version == "latest" { + root["latest"]["release"] + .as_str() + .ok_or_else(|| McError::Config("missing latest.release".into()))? + .to_string() + } else { + cfg.version.clone() + }; + + let versions = root["versions"] + .as_array() + .ok_or_else(|| McError::Config("missing versions array".into()))?; + + let version_entry = versions + .iter() + .find(|v| v["id"].as_str() == Some(&version_id)) + .ok_or_else(|| McError::Config(format!("version '{}' not found", version_id)))?; + + let url = version_entry["url"] + .as_str() + .ok_or_else(|| McError::Config("missing version url".into()))?; + + let version_text = reqwest::get(url).await?.text().await?; + let version: Version = serde_json::from_str(&version_text)?; + + Ok(version) +} diff --git a/src/minecraft/mod.rs b/src/minecraft/mod.rs new file mode 100644 index 0000000..f1ce1f0 --- /dev/null +++ b/src/minecraft/mod.rs @@ -0,0 +1,4 @@ +pub mod downloads; +pub mod extraction; +pub mod launcher; +pub mod manifests; diff --git a/src/platform/mod.rs b/src/platform/mod.rs new file mode 100644 index 0000000..8118b29 --- /dev/null +++ b/src/platform/mod.rs @@ -0,0 +1 @@ +pub mod paths; diff --git a/src/platform/paths.rs b/src/platform/paths.rs new file mode 100644 index 0000000..47aae9a --- /dev/null +++ b/src/platform/paths.rs @@ -0,0 +1,48 @@ +use std::{fs, path::PathBuf}; + +use directories::ProjectDirs; + +use crate::{config::Config, errors::McError}; + +fn project_dirs() -> ProjectDirs { + ProjectDirs::from("com", "dml", "dml").expect("failed to determine project directories") +} + +/// Root Minecraft directory +pub fn minecraft_root(_cfg: &Config) -> PathBuf { project_dirs().data_dir().join("minecraft") } + +/* ---------------- setup ---------------- */ + +pub fn ensure_dirs(cfg: &Config) -> Result<(), McError> { + let root = minecraft_root(cfg); + + fs::create_dir_all(root.join("versions"))?; + fs::create_dir_all(root.join("libraries"))?; + fs::create_dir_all(root.join("assets"))?; + + Ok(()) +} + +/* ---------------- versions ---------------- */ + +pub fn version_dir(cfg: &Config, version: &str) -> PathBuf { + minecraft_root(cfg).join("versions").join(version) +} + +pub fn client_jar(cfg: &Config, version: &str) -> Result { + Ok(version_dir(cfg, version).join(format!("{}.jar", version))) +} + +/* ---------------- libraries ---------------- */ + +pub fn library_file(cfg: &Config, rel_path: &str) -> Result { + Ok(minecraft_root(cfg) + .join("libraries") + .join(rel_path)) +} + +/* ---------------- natives ---------------- */ + +pub fn natives_dir(cfg: &Config, version: &str) -> PathBuf { + version_dir(cfg, version).join("natives") +} diff --git a/src/util/fs.rs b/src/util/fs.rs new file mode 100644 index 0000000..b86c0d7 --- /dev/null +++ b/src/util/fs.rs @@ -0,0 +1,12 @@ +#![allow(dead_code)] + +use std::path::Path; + +use crate::errors::McError; + +pub async fn remove_if_exists(path: &Path) -> Result<(), McError> { + if path.exists() { + tokio::fs::remove_file(path).await?; + } + Ok(()) +} diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..8176b9b --- /dev/null +++ b/src/util/mod.rs @@ -0,0 +1,2 @@ +pub mod fs; +pub mod sha1; diff --git a/src/util/sha1.rs b/src/util/sha1.rs new file mode 100644 index 0000000..c5f1021 --- /dev/null +++ b/src/util/sha1.rs @@ -0,0 +1,14 @@ +#![allow(dead_code)] + +use std::path::Path; + +use sha1::{Digest, Sha1}; + +use crate::errors::McError; + +pub async fn sha1_hex(path: &Path) -> Result { + let data = tokio::fs::read(path).await?; + let mut hasher = Sha1::new(); + hasher.update(&data); + Ok(format!("{:x}", hasher.finalize())) +} -- cgit v1.2.3