aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/config/loader.rs70
-rw-r--r--src/config/mod.rs3
-rw-r--r--src/constants.rs13
-rw-r--r--src/errors.rs34
-rw-r--r--src/main.rs60
-rw-r--r--src/minecraft/downloads.rs66
-rw-r--r--src/minecraft/extraction.rs10
-rw-r--r--src/minecraft/launcher.rs73
-rw-r--r--src/minecraft/manifests.rs80
-rw-r--r--src/minecraft/mod.rs4
-rw-r--r--src/platform/mod.rs1
-rw-r--r--src/platform/paths.rs48
-rw-r--r--src/util/fs.rs12
-rw-r--r--src/util/mod.rs2
-rw-r--r--src/util/sha1.rs14
15 files changed, 490 insertions, 0 deletions
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 @@
1use std::{env, path::PathBuf};
2
3use serde::Deserialize;
4
5use crate::{constants::*, errors::McError};
6
7#[allow(dead_code)]
8#[derive(Debug, Deserialize)]
9pub struct Config {
10 pub username: String,
11 pub uuid: String,
12 pub version: String,
13 pub java_path: String,
14 pub max_memory_mb: u32,
15 pub data_dir: PathBuf,
16 pub cache_dir: PathBuf,
17 pub config_dir: PathBuf,
18 #[serde(default)]
19 pub jvm_args: Vec<String>,
20}
21
22impl Config {
23 pub fn load() -> Result<Self, McError> {
24 let cfg_path = default_config_path()?;
25 let mut cfg: Config = if cfg_path.exists() {
26 let txt = std::fs::read_to_string(&cfg_path)?;
27 toml::from_str(&txt).map_err(|e| McError::Config(e.to_string()))?
28 } else {
29 Self::default()
30 };
31
32 if let Ok(v) = env::var("MC_USERNAME") {
33 cfg.username = v;
34 }
35 if let Ok(v) = env::var("MC_VERSION") {
36 cfg.version = v;
37 }
38 if let Ok(v) = env::var("MC_JAVA_PATH") {
39 cfg.java_path = v;
40 }
41 if let Ok(v) = env::var("MC_MAX_MEMORY_MB") {
42 cfg.max_memory_mb = v.parse().unwrap_or(cfg.max_memory_mb);
43 }
44
45 Ok(cfg)
46 }
47
48 fn default() -> Self {
49 let base =
50 directories::ProjectDirs::from("com", "example", "mccl").expect("platform dirs");
51
52 Self {
53 username: "Player".into(),
54 uuid: uuid::Uuid::new_v4().to_string(),
55 version: DEFAULT_VERSION.into(),
56 java_path: DEFAULT_JAVA_PATH.into(),
57 max_memory_mb: DEFAULT_MAX_MEMORY_MB,
58 data_dir: base.data_dir().into(),
59 cache_dir: base.cache_dir().into(),
60 config_dir: base.config_dir().into(),
61 jvm_args: vec![],
62 }
63 }
64}
65
66fn default_config_path() -> Result<PathBuf, McError> {
67 let base = directories::ProjectDirs::from("com", "example", "mccl")
68 .ok_or_else(|| McError::Config("cannot determine config dir".into()))?;
69 Ok(base.config_dir().join("config.toml"))
70}
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 @@
1pub mod loader;
2
3pub 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 @@
1#![allow(dead_code)]
2
3use std::time::Duration;
4
5pub const VERSION_MANIFEST_URL: &str =
6 "https://launchermeta.mojang.com/mc/game/version_manifest.json";
7
8pub const DOWNLOAD_RETRIES: usize = 3;
9pub const DOWNLOAD_BACKOFF: Duration = Duration::from_millis(400);
10
11pub const DEFAULT_MAX_MEMORY_MB: u32 = 2048;
12pub const DEFAULT_JAVA_PATH: &str = "java";
13pub 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 @@
1use std::{fmt, io};
2
3#[allow(dead_code)]
4#[derive(Debug)]
5pub enum McError {
6 Io(io::Error),
7 Http(reqwest::Error),
8 Json(serde_json::Error),
9 Zip(zip::result::ZipError),
10 Config(String),
11 ShaMismatch(String),
12 Process(String),
13 Runtime(String), // ← NEW
14}
15
16impl fmt::Display for McError {
17 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:?}", self) }
18}
19
20impl From<io::Error> for McError {
21 fn from(e: io::Error) -> Self { Self::Io(e) }
22}
23
24impl From<reqwest::Error> for McError {
25 fn from(e: reqwest::Error) -> Self { Self::Http(e) }
26}
27
28impl From<serde_json::Error> for McError {
29 fn from(e: serde_json::Error) -> Self { Self::Json(e) }
30}
31
32impl From<zip::result::ZipError> for McError {
33 fn from(e: zip::result::ZipError) -> Self { Self::Zip(e) }
34}
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 @@
1mod constants;
2mod errors;
3
4mod config;
5mod minecraft;
6mod platform;
7mod util;
8
9use clap::Parser;
10use config::Config;
11use errors::McError;
12use log::{debug, info};
13
14#[derive(Parser, Debug)]
15#[command(author, about, disable_version_flag = true)]
16struct Cli {
17 #[arg(long)]
18 version: Option<String>,
19
20 #[arg(long)]
21 username: Option<String>,
22
23 #[arg(long, num_args(0..), allow_hyphen_values = true)]
24 jvm_args: Vec<String>,
25}
26
27#[tokio::main]
28async fn main() -> Result<(), McError> {
29 dotenvy::dotenv().ok();
30 env_logger::init();
31
32 let cli = Cli::parse();
33 let mut config = Config::load()?;
34
35 if let Some(v) = cli.version {
36 config.version = v;
37 }
38
39 if let Some(u) = cli.username {
40 config.username = u;
41 }
42 if !cli.jvm_args.is_empty() {
43 config.jvm_args = cli.jvm_args;
44 }
45
46 info!("Final config after CLI overrides: {:?}", config);
47
48 platform::paths::ensure_dirs(&config)?;
49 info!("Using Minecraft version {}", config.version);
50
51 let version = minecraft::manifests::load_version(&config).await?;
52 info!("Loaded version manifest for: {}", version.id);
53 debug!("Main class: {}", version.main_class);
54
55 minecraft::downloads::download_all(&config, &version).await?;
56 minecraft::extraction::extract_natives(&config, &version)?;
57 minecraft::launcher::launch(&config, &version)?;
58
59 Ok(())
60}
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 @@
1use log::{debug, info};
2use tokio::{fs, io::AsyncWriteExt};
3
4use crate::{
5 config::Config,
6 errors::McError,
7 minecraft::manifests::{Library, Version},
8 platform::paths,
9};
10
11/// Download everything required to launch:
12/// - client jar
13/// - libraries
14pub async fn download_all(config: &Config, version: &Version) -> Result<(), McError> {
15 download_client(config, version).await?;
16 download_libraries(config, &version.libraries).await?;
17 Ok(())
18}
19
20async fn download_client(config: &Config, version: &Version) -> Result<(), McError> {
21 let jar_path = paths::client_jar(config, &version.id)?;
22
23 if jar_path.exists() {
24 debug!("Client jar already exists");
25 return Ok(());
26 }
27
28 info!("Downloading client {}", version.id);
29
30 download_file(&version.downloads.client.url, &jar_path).await
31}
32
33async fn download_libraries(config: &Config, libraries: &[Library]) -> Result<(), McError> {
34 for lib in libraries {
35 let Some(artifact) = &lib.downloads.artifact else {
36 continue;
37 };
38
39 let lib_path = paths::library_file(config, &artifact.path)?;
40
41 if lib_path.exists() {
42 continue;
43 }
44
45 info!("Downloading library {}", artifact.path);
46 download_file(&artifact.url, &lib_path).await?;
47 }
48
49 Ok(())
50}
51
52/* ---------------- helper ---------------- */
53
54async fn download_file(url: &str, path: &std::path::Path) -> Result<(), McError> {
55 if let Some(parent) = path.parent() {
56 fs::create_dir_all(parent).await?;
57 }
58
59 let response = reqwest::get(url).await?;
60 let bytes = response.bytes().await?;
61
62 let mut file = fs::File::create(path).await?;
63 file.write_all(&bytes).await?;
64
65 Ok(())
66}
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 @@
1use log::info;
2
3use crate::errors::McError;
4pub fn extract_natives(
5 _cfg: &crate::config::Config,
6 version: &crate::minecraft::manifests::Version,
7) -> Result<(), McError> {
8 info!("Extracting natives for {}", version.id);
9 Ok(())
10}
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 @@
1use std::process::Command;
2
3use log::{debug, info};
4
5use crate::{config::Config, errors::McError, minecraft::manifests::Version, platform::paths};
6
7/// Build the full classpath
8fn build_classpath(config: &Config, version: &Version) -> Result<String, McError> {
9 let sep = if cfg!(windows) { ";" } else { ":" };
10 let mut entries = Vec::new();
11
12 for lib in &version.libraries {
13 if let Some(artifact) = &lib.downloads.artifact {
14 let path = paths::library_file(config, &artifact.path)?;
15 entries.push(path.to_string_lossy().to_string());
16 }
17 }
18
19 let client_jar = paths::client_jar(config, &version.id)?;
20 entries.push(client_jar.to_string_lossy().to_string());
21 Ok(entries.join(sep))
22}
23
24/// Launch Minecraft
25pub fn launch(config: &Config, version: &Version) -> Result<(), McError> {
26 let java = &config.java_path;
27 let classpath = build_classpath(config, version)?;
28 let natives_dir = paths::natives_dir(config, &version.id);
29
30 if !natives_dir.exists() {
31 return Err(McError::Runtime(format!(
32 "Natives folder does not exist: {}",
33 natives_dir.display()
34 )));
35 }
36
37 info!("Launching Minecraft {}", version.id);
38 debug!("Classpath: {}", classpath);
39 debug!("Natives: {}", natives_dir.display());
40
41 let status = Command::new(java)
42 .arg(format!("-Xmx{}M", config.max_memory_mb))
43 .arg(format!("-Djava.library.path={}", natives_dir.display()))
44 .arg("-cp")
45 .arg(classpath)
46 .arg(&version.main_class)
47 .arg("--username")
48 .arg(&config.username)
49 .arg("--version")
50 .arg(&version.id)
51 .arg("--gameDir")
52 .arg(paths::minecraft_root(config))
53 .arg("--assetsDir")
54 .arg(paths::minecraft_root(config).join("assets"))
55 .arg("--assetIndex")
56 .arg(&version.id)
57 .arg("--uuid")
58 .arg(&config.uuid)
59 .arg("--userProperties")
60 .arg("{}")
61 .arg("--accessToken")
62 .arg("0")
63 .arg("--userType")
64 .arg("legacy")
65 .args(&config.jvm_args)
66 .status()?;
67
68 if !status.success() {
69 return Err(McError::Process("Minecraft exited with error".into()));
70 }
71
72 Ok(())
73}
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 @@
1#![allow(dead_code)]
2use reqwest;
3use serde::Deserialize;
4
5use crate::{constants::VERSION_MANIFEST_URL, errors::McError};
6
7#[derive(Debug, Deserialize)]
8pub struct Version {
9 pub id: String,
10
11 #[serde(rename = "mainClass")]
12 pub main_class: String,
13
14 pub downloads: Downloads,
15 pub libraries: Vec<Library>,
16}
17
18#[derive(Debug, Deserialize)]
19pub struct Downloads {
20 pub client: DownloadInfo,
21}
22
23#[derive(Debug, Deserialize)]
24pub struct DownloadInfo {
25 pub url: String,
26 pub sha1: String,
27 pub size: u64,
28}
29
30#[derive(Debug, Deserialize)]
31pub struct Library {
32 pub downloads: LibraryDownloads,
33}
34
35#[derive(Debug, Deserialize)]
36pub struct LibraryDownloads {
37 pub artifact: Option<LibraryArtifact>,
38}
39
40#[derive(Debug, Deserialize)]
41pub struct LibraryArtifact {
42 pub path: String,
43 pub url: String,
44 pub sha1: String,
45 pub size: u64,
46}
47
48pub async fn load_version(cfg: &crate::config::Config) -> Result<Version, McError> {
49 let manifest_text = reqwest::get(VERSION_MANIFEST_URL)
50 .await?
51 .text()
52 .await?;
53 let root: serde_json::Value = serde_json::from_str(&manifest_text)?;
54 let version_id = if cfg.version == "latest" {
55 root["latest"]["release"]
56 .as_str()
57 .ok_or_else(|| McError::Config("missing latest.release".into()))?
58 .to_string()
59 } else {
60 cfg.version.clone()
61 };
62
63 let versions = root["versions"]
64 .as_array()
65 .ok_or_else(|| McError::Config("missing versions array".into()))?;
66
67 let version_entry = versions
68 .iter()
69 .find(|v| v["id"].as_str() == Some(&version_id))
70 .ok_or_else(|| McError::Config(format!("version '{}' not found", version_id)))?;
71
72 let url = version_entry["url"]
73 .as_str()
74 .ok_or_else(|| McError::Config("missing version url".into()))?;
75
76 let version_text = reqwest::get(url).await?.text().await?;
77 let version: Version = serde_json::from_str(&version_text)?;
78
79 Ok(version)
80}
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 @@
1pub mod downloads;
2pub mod extraction;
3pub mod launcher;
4pub 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 @@
1use std::{fs, path::PathBuf};
2
3use directories::ProjectDirs;
4
5use crate::{config::Config, errors::McError};
6
7fn project_dirs() -> ProjectDirs {
8 ProjectDirs::from("com", "dml", "dml").expect("failed to determine project directories")
9}
10
11/// Root Minecraft directory
12pub fn minecraft_root(_cfg: &Config) -> PathBuf { project_dirs().data_dir().join("minecraft") }
13
14/* ---------------- setup ---------------- */
15
16pub fn ensure_dirs(cfg: &Config) -> Result<(), McError> {
17 let root = minecraft_root(cfg);
18
19 fs::create_dir_all(root.join("versions"))?;
20 fs::create_dir_all(root.join("libraries"))?;
21 fs::create_dir_all(root.join("assets"))?;
22
23 Ok(())
24}
25
26/* ---------------- versions ---------------- */
27
28pub fn version_dir(cfg: &Config, version: &str) -> PathBuf {
29 minecraft_root(cfg).join("versions").join(version)
30}
31
32pub fn client_jar(cfg: &Config, version: &str) -> Result<PathBuf, McError> {
33 Ok(version_dir(cfg, version).join(format!("{}.jar", version)))
34}
35
36/* ---------------- libraries ---------------- */
37
38pub fn library_file(cfg: &Config, rel_path: &str) -> Result<PathBuf, McError> {
39 Ok(minecraft_root(cfg)
40 .join("libraries")
41 .join(rel_path))
42}
43
44/* ---------------- natives ---------------- */
45
46pub fn natives_dir(cfg: &Config, version: &str) -> PathBuf {
47 version_dir(cfg, version).join("natives")
48}
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 @@
1#![allow(dead_code)]
2
3use std::path::Path;
4
5use crate::errors::McError;
6
7pub async fn remove_if_exists(path: &Path) -> Result<(), McError> {
8 if path.exists() {
9 tokio::fs::remove_file(path).await?;
10 }
11 Ok(())
12}
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 @@
1pub mod fs;
2pub 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 @@
1#![allow(dead_code)]
2
3use std::path::Path;
4
5use sha1::{Digest, Sha1};
6
7use crate::errors::McError;
8
9pub async fn sha1_hex(path: &Path) -> Result<String, McError> {
10 let data = tokio::fs::read(path).await?;
11 let mut hasher = Sha1::new();
12 hasher.update(&data);
13 Ok(format!("{:x}", hasher.finalize()))
14}