aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src-tauri/src/core
diff options
context:
space:
mode:
Diffstat (limited to 'src-tauri/src/core')
-rw-r--r--src-tauri/src/core/account_storage.rs29
-rw-r--r--src-tauri/src/core/auth.rs36
-rw-r--r--src-tauri/src/core/config.rs13
-rw-r--r--src-tauri/src/core/downloader.rs164
-rw-r--r--src-tauri/src/core/fabric.rs274
-rw-r--r--src-tauri/src/core/forge.rs336
-rw-r--r--src-tauri/src/core/game_version.rs47
-rw-r--r--src-tauri/src/core/java.rs24
-rw-r--r--src-tauri/src/core/manifest.rs154
-rw-r--r--src-tauri/src/core/maven.rs263
-rw-r--r--src-tauri/src/core/mod.rs4
-rw-r--r--src-tauri/src/core/rules.rs7
-rw-r--r--src-tauri/src/core/version_merge.rs244
13 files changed, 1497 insertions, 98 deletions
diff --git a/src-tauri/src/core/account_storage.rs b/src-tauri/src/core/account_storage.rs
index b8e15e1..569df7b 100644
--- a/src-tauri/src/core/account_storage.rs
+++ b/src-tauri/src/core/account_storage.rs
@@ -4,21 +4,12 @@ use std::fs;
use std::path::PathBuf;
/// Stored account data for persistence
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AccountStore {
pub accounts: Vec<StoredAccount>,
pub active_account_id: Option<String>,
}
-impl Default for AccountStore {
- fn default() -> Self {
- Self {
- accounts: Vec::new(),
- active_account_id: None,
- }
- }
-}
-
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum StoredAccount {
@@ -131,13 +122,17 @@ impl AccountStorage {
pub fn get_active_account(&self) -> Option<(StoredAccount, Option<String>)> {
let store = self.load();
if let Some(active_id) = &store.active_account_id {
- store.accounts.iter().find(|a| &a.id() == active_id).map(|a| {
- let ms_token = match a {
- StoredAccount::Microsoft(m) => m.ms_refresh_token.clone(),
- _ => None,
- };
- (a.clone(), ms_token)
- })
+ store
+ .accounts
+ .iter()
+ .find(|a| &a.id() == active_id)
+ .map(|a| {
+ let ms_token = match a {
+ StoredAccount::Microsoft(m) => m.ms_refresh_token.clone(),
+ _ => None,
+ };
+ (a.clone(), ms_token)
+ })
} else {
None
}
diff --git a/src-tauri/src/core/auth.rs b/src-tauri/src/core/auth.rs
index 624f1de..5f01a58 100644
--- a/src-tauri/src/core/auth.rs
+++ b/src-tauri/src/core/auth.rs
@@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use uuid::Uuid;
-
// Helper to create a client with a custom User-Agent
// This is critical because Microsoft's WAF often blocks requests without a valid UA
fn get_client() -> reqwest::Client {
@@ -116,7 +115,7 @@ pub async fn refresh_microsoft_token(refresh_token: &str) -> Result<TokenRespons
let resp = client
.post(url)
.header("Content-Type", "application/x-www-form-urlencoded")
- .body(serde_urlencoded::to_string(&params).map_err(|e| e.to_string())?)
+ .body(serde_urlencoded::to_string(params).map_err(|e| e.to_string())?)
.send()
.await
.map_err(|e| e.to_string())?;
@@ -142,30 +141,32 @@ pub fn is_token_expired(expires_at: i64) -> bool {
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
-
+
// Consider expired if less than 5 minutes remaining
expires_at - now < 300
}
/// Full refresh flow: refresh MS token -> Xbox -> XSTS -> Minecraft
-pub async fn refresh_full_auth(ms_refresh_token: &str) -> Result<(MicrosoftAccount, String), String> {
+pub async fn refresh_full_auth(
+ ms_refresh_token: &str,
+) -> Result<(MicrosoftAccount, String), String> {
println!("[Auth] Starting full token refresh...");
-
+
// 1. Refresh Microsoft token
let token_resp = refresh_microsoft_token(ms_refresh_token).await?;
-
+
// 2. Xbox Live Auth
let (xbl_token, uhs) = method_xbox_live(&token_resp.access_token).await?;
-
+
// 3. XSTS Auth
let xsts_token = method_xsts(&xbl_token).await?;
-
+
// 4. Minecraft Auth
let mc_token = login_minecraft(&xsts_token, &uhs).await?;
-
+
// 5. Get Profile
let profile = fetch_profile(&mc_token).await?;
-
+
// 6. Create Account
let account = MicrosoftAccount {
username: profile.name,
@@ -175,12 +176,15 @@ pub async fn refresh_full_auth(ms_refresh_token: &str) -> Result<(MicrosoftAccou
expires_at: (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
- .as_secs() + token_resp.expires_in) as i64,
+ .as_secs()
+ + token_resp.expires_in) as i64,
};
-
+
// Return new MS refresh token for storage
- let new_ms_refresh = token_resp.refresh_token.unwrap_or_else(|| ms_refresh_token.to_string());
-
+ let new_ms_refresh = token_resp
+ .refresh_token
+ .unwrap_or_else(|| ms_refresh_token.to_string());
+
Ok((account, new_ms_refresh))
}
@@ -221,7 +225,7 @@ pub async fn start_device_flow() -> Result<DeviceCodeResponse, String> {
let resp = client
.post(url)
.header("Content-Type", "application/x-www-form-urlencoded")
- .body(serde_urlencoded::to_string(&params).map_err(|e| e.to_string())?)
+ .body(serde_urlencoded::to_string(params).map_err(|e| e.to_string())?)
.send()
.await
.map_err(|e| e.to_string())?;
@@ -257,7 +261,7 @@ pub async fn exchange_code_for_token(device_code: &str) -> Result<TokenResponse,
let resp = client
.post(url)
.header("Content-Type", "application/x-www-form-urlencoded")
- .body(serde_urlencoded::to_string(&params).map_err(|e| e.to_string())?)
+ .body(serde_urlencoded::to_string(params).map_err(|e| e.to_string())?)
.send()
.await
.map_err(|e| e.to_string())?;
diff --git a/src-tauri/src/core/config.rs b/src-tauri/src/core/config.rs
index 47c5306..510b126 100644
--- a/src-tauri/src/core/config.rs
+++ b/src-tauri/src/core/config.rs
@@ -5,12 +5,19 @@ use std::sync::Mutex;
use tauri::{AppHandle, Manager};
#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default)]
pub struct LauncherConfig {
pub min_memory: u32, // in MB
pub max_memory: u32, // in MB
pub java_path: String,
pub width: u32,
pub height: u32,
+ pub download_threads: u32, // concurrent download threads (1-128)
+ pub custom_background_path: Option<String>,
+ pub enable_gpu_acceleration: bool,
+ pub enable_visual_effects: bool,
+ pub active_effect: String,
+ pub theme: String,
}
impl Default for LauncherConfig {
@@ -21,6 +28,12 @@ impl Default for LauncherConfig {
java_path: "java".to_string(),
width: 854,
height: 480,
+ download_threads: 32,
+ custom_background_path: None,
+ enable_gpu_acceleration: false,
+ enable_visual_effects: true,
+ active_effect: "constellation".to_string(),
+ theme: "dark".to_string(),
}
}
}
diff --git a/src-tauri/src/core/downloader.rs b/src-tauri/src/core/downloader.rs
index 09101c9..d33c44d 100644
--- a/src-tauri/src/core/downloader.rs
+++ b/src-tauri/src/core/downloader.rs
@@ -2,6 +2,7 @@ use futures::StreamExt;
use serde::{Deserialize, Serialize};
use sha1::Digest as Sha1Digest;
use std::path::PathBuf;
+use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::Arc;
use tauri::{Emitter, Window};
use tokio::io::AsyncWriteExt;
@@ -23,6 +24,9 @@ pub struct ProgressEvent {
pub downloaded: u64,
pub total: u64,
pub status: String, // "Downloading", "Verifying", "Finished", "Error"
+ pub completed_files: usize,
+ pub total_files: usize,
+ pub total_downloaded_bytes: u64,
}
/// calculate SHA256 hash of data
@@ -51,9 +55,96 @@ pub fn verify_checksum(data: &[u8], sha256: Option<&str>, sha1: Option<&str>) ->
true
}
-pub async fn download_files(window: Window, tasks: Vec<DownloadTask>) -> Result<(), String> {
+/// Snapshot of global progress state
+struct ProgressSnapshot {
+ completed_files: usize,
+ total_files: usize,
+ total_downloaded_bytes: u64,
+}
+
+/// Centralized progress tracking with atomic counters
+struct GlobalProgress {
+ completed_files: AtomicUsize,
+ total_downloaded_bytes: AtomicU64,
+ total_files: usize,
+}
+
+impl GlobalProgress {
+ fn new(total_files: usize) -> Self {
+ Self {
+ completed_files: AtomicUsize::new(0),
+ total_downloaded_bytes: AtomicU64::new(0),
+ total_files,
+ }
+ }
+
+ /// Get current progress snapshot without modification
+ fn snapshot(&self) -> ProgressSnapshot {
+ ProgressSnapshot {
+ completed_files: self.completed_files.load(Ordering::Relaxed),
+ total_files: self.total_files,
+ total_downloaded_bytes: self.total_downloaded_bytes.load(Ordering::Relaxed),
+ }
+ }
+
+ /// Increment completed files counter and return updated snapshot
+ fn inc_completed(&self) -> ProgressSnapshot {
+ let completed = self.completed_files.fetch_add(1, Ordering::Relaxed) + 1;
+ ProgressSnapshot {
+ completed_files: completed,
+ total_files: self.total_files,
+ total_downloaded_bytes: self.total_downloaded_bytes.load(Ordering::Relaxed),
+ }
+ }
+
+ /// Add downloaded bytes and return updated snapshot
+ fn add_bytes(&self, delta: u64) -> ProgressSnapshot {
+ let total_bytes = self
+ .total_downloaded_bytes
+ .fetch_add(delta, Ordering::Relaxed)
+ + delta;
+ ProgressSnapshot {
+ completed_files: self.completed_files.load(Ordering::Relaxed),
+ total_files: self.total_files,
+ total_downloaded_bytes: total_bytes,
+ }
+ }
+}
+
+/// Emit a progress event to the frontend
+fn emit_progress(
+ window: &Window,
+ file_name: &str,
+ status: &str,
+ downloaded: u64,
+ total: u64,
+ snapshot: &ProgressSnapshot,
+) {
+ let _ = window.emit(
+ "download-progress",
+ ProgressEvent {
+ file: file_name.to_string(),
+ downloaded,
+ total,
+ status: status.into(),
+ completed_files: snapshot.completed_files,
+ total_files: snapshot.total_files,
+ total_downloaded_bytes: snapshot.total_downloaded_bytes,
+ },
+ );
+}
+
+pub async fn download_files(
+ window: Window,
+ tasks: Vec<DownloadTask>,
+ max_concurrent: usize,
+) -> Result<(), String> {
+ // Clamp max_concurrent to a valid range (1-128) to prevent edge cases
+ let max_concurrent = max_concurrent.clamp(1, 128);
+
let client = reqwest::Client::new();
- let semaphore = Arc::new(Semaphore::new(10)); // Max 10 concurrent downloads
+ let semaphore = Arc::new(Semaphore::new(max_concurrent));
+ let progress = Arc::new(GlobalProgress::new(tasks.len()));
// Notify start (total files)
let _ = window.emit("download-start", tasks.len());
@@ -62,6 +153,7 @@ pub async fn download_files(window: Window, tasks: Vec<DownloadTask>) -> Result<
let client = client.clone();
let window = window.clone();
let semaphore = semaphore.clone();
+ let progress = progress.clone();
async move {
let _permit = semaphore.acquire().await.unwrap();
@@ -69,15 +161,7 @@ pub async fn download_files(window: Window, tasks: Vec<DownloadTask>) -> Result<
// 1. Check if file exists and verify checksum
if task.path.exists() {
- let _ = window.emit(
- "download-progress",
- ProgressEvent {
- file: file_name.clone(),
- downloaded: 0,
- total: 0,
- status: "Verifying".into(),
- },
- );
+ emit_progress(&window, &file_name, "Verifying", 0, 0, &progress.snapshot());
if task.sha256.is_some() || task.sha1.is_some() {
if let Ok(data) = tokio::fs::read(&task.path).await {
@@ -86,15 +170,21 @@ pub async fn download_files(window: Window, tasks: Vec<DownloadTask>) -> Result<
task.sha256.as_deref(),
task.sha1.as_deref(),
) {
- // Already valid
- let _ = window.emit(
- "download-progress",
- ProgressEvent {
- file: file_name.clone(),
- downloaded: 0,
- total: 0,
- status: "Skipped".into(),
- },
+ // Already valid, skip download
+ let skipped_size = tokio::fs::metadata(&task.path)
+ .await
+ .map(|m| m.len())
+ .unwrap_or(0);
+ if skipped_size > 0 {
+ let _ = progress.add_bytes(skipped_size);
+ }
+ emit_progress(
+ &window,
+ &file_name,
+ "Skipped",
+ 0,
+ 0,
+ &progress.inc_completed(),
);
return Ok(());
}
@@ -123,14 +213,14 @@ pub async fn download_files(window: Window, tasks: Vec<DownloadTask>) -> Result<
return Err(format!("Write error: {}", e));
}
downloaded += chunk.len() as u64;
- let _ = window.emit(
- "download-progress",
- ProgressEvent {
- file: file_name.clone(),
- downloaded,
- total: total_size,
- status: "Downloading".into(),
- },
+ let snapshot = progress.add_bytes(chunk.len() as u64);
+ emit_progress(
+ &window,
+ &file_name,
+ "Downloading",
+ downloaded,
+ total_size,
+ &snapshot,
);
}
Ok(None) => break,
@@ -141,23 +231,21 @@ pub async fn download_files(window: Window, tasks: Vec<DownloadTask>) -> Result<
Err(e) => return Err(format!("Request error: {}", e)),
}
- let _ = window.emit(
- "download-progress",
- ProgressEvent {
- file: file_name.clone(),
- downloaded: 0,
- total: 0,
- status: "Finished".into(),
- },
+ emit_progress(
+ &window,
+ &file_name,
+ "Finished",
+ 0,
+ 0,
+ &progress.inc_completed(),
);
-
Ok(())
}
});
// Buffer unordered to run concurrently
tasks_stream
- .buffer_unordered(10)
+ .buffer_unordered(max_concurrent)
.collect::<Vec<Result<(), String>>>()
.await;
diff --git a/src-tauri/src/core/fabric.rs b/src-tauri/src/core/fabric.rs
new file mode 100644
index 0000000..fd38f41
--- /dev/null
+++ b/src-tauri/src/core/fabric.rs
@@ -0,0 +1,274 @@
+//! Fabric Loader support module.
+//!
+//! This module provides functionality to:
+//! - Fetch available Fabric loader versions from the Fabric Meta API
+//! - Generate version JSON files for Fabric-enabled Minecraft versions
+//! - Install Fabric loader for a specific Minecraft version
+
+use serde::{Deserialize, Serialize};
+use std::error::Error;
+use std::path::PathBuf;
+
+const FABRIC_META_URL: &str = "https://meta.fabricmc.net/v2";
+
+/// Represents a Fabric loader version from the Meta API.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct FabricLoaderVersion {
+ pub separator: String,
+ pub build: i32,
+ pub maven: String,
+ pub version: String,
+ pub stable: bool,
+}
+
+/// Represents a Fabric intermediary mapping version.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct FabricIntermediaryVersion {
+ pub maven: String,
+ pub version: String,
+ pub stable: bool,
+}
+
+/// Represents a combined loader + intermediary version entry.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct FabricLoaderEntry {
+ pub loader: FabricLoaderVersion,
+ pub intermediary: FabricIntermediaryVersion,
+ #[serde(rename = "launcherMeta")]
+ pub launcher_meta: FabricLauncherMeta,
+}
+
+/// Launcher metadata from Fabric Meta API.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct FabricLauncherMeta {
+ pub version: i32,
+ pub libraries: FabricLibraries,
+ #[serde(rename = "mainClass")]
+ pub main_class: FabricMainClass,
+}
+
+/// Libraries required by Fabric loader.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct FabricLibraries {
+ pub client: Vec<FabricLibrary>,
+ pub common: Vec<FabricLibrary>,
+ pub server: Vec<FabricLibrary>,
+}
+
+/// A single Fabric library dependency.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct FabricLibrary {
+ pub name: String,
+ pub url: Option<String>,
+}
+
+/// Main class configuration for Fabric.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct FabricMainClass {
+ pub client: String,
+ pub server: String,
+}
+
+/// Represents a Minecraft version supported by Fabric.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct FabricGameVersion {
+ pub version: String,
+ pub stable: bool,
+}
+
+/// Information about an installed Fabric version.
+#[derive(Debug, Serialize, Clone)]
+pub struct InstalledFabricVersion {
+ pub id: String,
+ pub minecraft_version: String,
+ pub loader_version: String,
+ pub path: PathBuf,
+}
+
+/// Fetch all Minecraft versions supported by Fabric.
+///
+/// # Returns
+/// A list of game versions that have Fabric intermediary mappings available.
+pub async fn fetch_supported_game_versions(
+) -> Result<Vec<FabricGameVersion>, Box<dyn Error + Send + Sync>> {
+ let url = format!("{}/versions/game", FABRIC_META_URL);
+ let resp = reqwest::get(&url)
+ .await?
+ .json::<Vec<FabricGameVersion>>()
+ .await?;
+ Ok(resp)
+}
+
+/// Fetch all available Fabric loader versions.
+///
+/// # Returns
+/// A list of all Fabric loader versions, ordered by build number (newest first).
+pub async fn fetch_loader_versions(
+) -> Result<Vec<FabricLoaderVersion>, Box<dyn Error + Send + Sync>> {
+ let url = format!("{}/versions/loader", FABRIC_META_URL);
+ let resp = reqwest::get(&url)
+ .await?
+ .json::<Vec<FabricLoaderVersion>>()
+ .await?;
+ Ok(resp)
+}
+
+/// Fetch Fabric loader versions available for a specific Minecraft version.
+///
+/// # Arguments
+/// * `game_version` - The Minecraft version (e.g., "1.20.4")
+///
+/// # Returns
+/// A list of loader entries with full metadata for the specified game version.
+pub async fn fetch_loaders_for_game_version(
+ game_version: &str,
+) -> Result<Vec<FabricLoaderEntry>, Box<dyn Error + Send + Sync>> {
+ let url = format!("{}/versions/loader/{}", FABRIC_META_URL, game_version);
+ let resp = reqwest::get(&url)
+ .await?
+ .json::<Vec<FabricLoaderEntry>>()
+ .await?;
+ Ok(resp)
+}
+
+/// Fetch the version JSON profile for a specific Fabric loader + game version combination.
+///
+/// # Arguments
+/// * `game_version` - The Minecraft version (e.g., "1.20.4")
+/// * `loader_version` - The Fabric loader version (e.g., "0.15.6")
+///
+/// # Returns
+/// The raw version JSON as a `serde_json::Value` that can be saved to the versions directory.
+pub async fn fetch_version_profile(
+ game_version: &str,
+ loader_version: &str,
+) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
+ let url = format!(
+ "{}/versions/loader/{}/{}/profile/json",
+ FABRIC_META_URL, game_version, loader_version
+ );
+ let resp = reqwest::get(&url)
+ .await?
+ .json::<serde_json::Value>()
+ .await?;
+ Ok(resp)
+}
+
+/// Generate the version ID for a Fabric installation.
+///
+/// # Arguments
+/// * `game_version` - The Minecraft version
+/// * `loader_version` - The Fabric loader version
+///
+/// # Returns
+/// The version ID string (e.g., "fabric-loader-0.15.6-1.20.4")
+pub fn generate_version_id(game_version: &str, loader_version: &str) -> String {
+ format!("fabric-loader-{}-{}", loader_version, game_version)
+}
+
+/// Install Fabric loader for a specific Minecraft version.
+///
+/// This creates the version JSON file in the versions directory.
+/// The actual library downloads happen during game launch.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+/// * `game_version` - The Minecraft version (e.g., "1.20.4")
+/// * `loader_version` - The Fabric loader version (e.g., "0.15.6")
+///
+/// # Returns
+/// Information about the installed version.
+pub async fn install_fabric(
+ game_dir: &PathBuf,
+ game_version: &str,
+ loader_version: &str,
+) -> Result<InstalledFabricVersion, Box<dyn Error + Send + Sync>> {
+ // Fetch the version profile from Fabric Meta
+ let profile = fetch_version_profile(game_version, loader_version).await?;
+
+ // Get the version ID from the profile or generate it
+ let version_id = profile
+ .get("id")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| generate_version_id(game_version, loader_version));
+
+ // Create the version directory
+ let version_dir = game_dir.join("versions").join(&version_id);
+ tokio::fs::create_dir_all(&version_dir).await?;
+
+ // Write the version JSON
+ let json_path = version_dir.join(format!("{}.json", version_id));
+ let json_content = serde_json::to_string_pretty(&profile)?;
+ tokio::fs::write(&json_path, json_content).await?;
+
+ Ok(InstalledFabricVersion {
+ id: version_id,
+ minecraft_version: game_version.to_string(),
+ loader_version: loader_version.to_string(),
+ path: json_path,
+ })
+}
+
+/// Check if Fabric is installed for a specific version combination.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+/// * `game_version` - The Minecraft version
+/// * `loader_version` - The Fabric loader version
+///
+/// # Returns
+/// `true` if the version JSON exists, `false` otherwise.
+pub fn is_fabric_installed(game_dir: &PathBuf, game_version: &str, loader_version: &str) -> bool {
+ let version_id = generate_version_id(game_version, loader_version);
+ let json_path = game_dir
+ .join("versions")
+ .join(&version_id)
+ .join(format!("{}.json", version_id));
+ json_path.exists()
+}
+
+/// List all installed Fabric versions in the game directory.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+///
+/// # Returns
+/// A list of installed Fabric version IDs.
+pub async fn list_installed_fabric_versions(
+ game_dir: &PathBuf,
+) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
+ let versions_dir = game_dir.join("versions");
+ let mut installed = Vec::new();
+
+ if !versions_dir.exists() {
+ return Ok(installed);
+ }
+
+ let mut entries = tokio::fs::read_dir(&versions_dir).await?;
+ while let Some(entry) = entries.next_entry().await? {
+ let name = entry.file_name().to_string_lossy().to_string();
+ if name.starts_with("fabric-loader-") {
+ // Verify the JSON file exists
+ let json_path = entry.path().join(format!("{}.json", name));
+ if json_path.exists() {
+ installed.push(name);
+ }
+ }
+ }
+
+ Ok(installed)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_generate_version_id() {
+ assert_eq!(
+ generate_version_id("1.20.4", "0.15.6"),
+ "fabric-loader-0.15.6-1.20.4"
+ );
+ }
+}
diff --git a/src-tauri/src/core/forge.rs b/src-tauri/src/core/forge.rs
new file mode 100644
index 0000000..0f17bcc
--- /dev/null
+++ b/src-tauri/src/core/forge.rs
@@ -0,0 +1,336 @@
+//! Forge Loader support module.
+//!
+//! This module provides functionality to:
+//! - Fetch available Forge versions from the Forge promotions API
+//! - Install Forge loader for a specific Minecraft version
+//!
+//! Note: Forge installation is more complex than Fabric, especially for versions 1.13+.
+//! This implementation focuses on the basic JSON generation approach.
+//! For full Forge 1.13+ support, processor execution would need to be implemented.
+
+use serde::{Deserialize, Serialize};
+use std::error::Error;
+use std::path::PathBuf;
+
+const FORGE_PROMOTIONS_URL: &str =
+ "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json";
+const FORGE_MAVEN_URL: &str = "https://maven.minecraftforge.net/";
+
+/// Represents a Forge version entry.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct ForgeVersion {
+ pub version: String,
+ pub minecraft_version: String,
+ #[serde(default)]
+ pub recommended: bool,
+ #[serde(default)]
+ pub latest: bool,
+}
+
+/// Forge promotions response from the API.
+#[derive(Debug, Deserialize)]
+struct ForgePromotions {
+ promos: std::collections::HashMap<String, String>,
+}
+
+/// Information about an installed Forge version.
+#[derive(Debug, Serialize, Clone)]
+pub struct InstalledForgeVersion {
+ pub id: String,
+ pub minecraft_version: String,
+ pub forge_version: String,
+ pub path: PathBuf,
+}
+
+/// Fetch all Minecraft versions supported by Forge.
+///
+/// # Returns
+/// A list of Minecraft version strings that have Forge available.
+pub async fn fetch_supported_game_versions() -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
+ let promos = fetch_promotions().await?;
+
+ let mut versions: Vec<String> = promos
+ .promos
+ .keys()
+ .filter_map(|key| {
+ // Keys are like "1.20.4-latest", "1.20.4-recommended"
+ let parts: Vec<&str> = key.split('-').collect();
+ if parts.len() >= 2 {
+ Some(parts[0].to_string())
+ } else {
+ None
+ }
+ })
+ .collect();
+
+ // Deduplicate and sort
+ versions.sort();
+ versions.dedup();
+ versions.reverse(); // Newest first
+
+ Ok(versions)
+}
+
+/// Fetch Forge promotions data.
+async fn fetch_promotions() -> Result<ForgePromotions, Box<dyn Error + Send + Sync>> {
+ let resp = reqwest::get(FORGE_PROMOTIONS_URL)
+ .await?
+ .json::<ForgePromotions>()
+ .await?;
+ Ok(resp)
+}
+
+/// Fetch available Forge versions for a specific Minecraft version.
+///
+/// # Arguments
+/// * `game_version` - The Minecraft version (e.g., "1.20.4")
+///
+/// # Returns
+/// A list of Forge versions available for the specified game version.
+pub async fn fetch_forge_versions(
+ game_version: &str,
+) -> Result<Vec<ForgeVersion>, Box<dyn Error + Send + Sync>> {
+ let promos = fetch_promotions().await?;
+ let mut versions = Vec::new();
+
+ // Look for both latest and recommended
+ let latest_key = format!("{}-latest", game_version);
+ let recommended_key = format!("{}-recommended", game_version);
+
+ if let Some(latest) = promos.promos.get(&latest_key) {
+ versions.push(ForgeVersion {
+ version: latest.clone(),
+ minecraft_version: game_version.to_string(),
+ recommended: false,
+ latest: true,
+ });
+ }
+
+ if let Some(recommended) = promos.promos.get(&recommended_key) {
+ // Don't duplicate if recommended == latest
+ if !versions.iter().any(|v| v.version == *recommended) {
+ versions.push(ForgeVersion {
+ version: recommended.clone(),
+ minecraft_version: game_version.to_string(),
+ recommended: true,
+ latest: false,
+ });
+ } else {
+ // Mark the existing one as both
+ if let Some(v) = versions.iter_mut().find(|v| v.version == *recommended) {
+ v.recommended = true;
+ }
+ }
+ }
+
+ Ok(versions)
+}
+
+/// Generate the version ID for a Forge installation.
+///
+/// # Arguments
+/// * `game_version` - The Minecraft version
+/// * `forge_version` - The Forge version
+///
+/// # Returns
+/// The version ID string (e.g., "1.20.4-forge-49.0.38")
+pub fn generate_version_id(game_version: &str, forge_version: &str) -> String {
+ format!("{}-forge-{}", game_version, forge_version)
+}
+
+/// Install Forge for a specific Minecraft version.
+///
+/// Note: This creates a basic version JSON. For Forge 1.13+, the full installation
+/// requires running the Forge installer processors, which is not yet implemented.
+/// This basic implementation works for legacy Forge versions (<1.13) and creates
+/// the structure needed for modern Forge (libraries will need to be downloaded
+/// separately).
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+/// * `game_version` - The Minecraft version (e.g., "1.20.4")
+/// * `forge_version` - The Forge version (e.g., "49.0.38")
+///
+/// # Returns
+/// Information about the installed version.
+pub async fn install_forge(
+ game_dir: &PathBuf,
+ game_version: &str,
+ forge_version: &str,
+) -> Result<InstalledForgeVersion, Box<dyn Error + Send + Sync>> {
+ let version_id = generate_version_id(game_version, forge_version);
+
+ // Create basic version JSON structure
+ // Note: This is a simplified version. Full Forge installation requires
+ // downloading the installer and running processors.
+ let version_json = create_forge_version_json(game_version, forge_version)?;
+
+ // Create the version directory
+ let version_dir = game_dir.join("versions").join(&version_id);
+ tokio::fs::create_dir_all(&version_dir).await?;
+
+ // Write the version JSON
+ let json_path = version_dir.join(format!("{}.json", version_id));
+ let json_content = serde_json::to_string_pretty(&version_json)?;
+ tokio::fs::write(&json_path, json_content).await?;
+
+ Ok(InstalledForgeVersion {
+ id: version_id,
+ minecraft_version: game_version.to_string(),
+ forge_version: forge_version.to_string(),
+ path: json_path,
+ })
+}
+
+/// Create a basic Forge version JSON.
+///
+/// This creates a minimal version JSON that inherits from vanilla and adds
+/// the Forge libraries. For full functionality with Forge 1.13+, the installer
+/// would need to be run to patch the game.
+fn create_forge_version_json(
+ game_version: &str,
+ forge_version: &str,
+) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
+ let version_id = generate_version_id(game_version, forge_version);
+ let forge_maven_coord = format!(
+ "net.minecraftforge:forge:{}-{}",
+ game_version, forge_version
+ );
+
+ // Determine main class based on version
+ // Forge 1.13+ uses different launchers
+ let (main_class, libraries) = if is_modern_forge(game_version) {
+ // Modern Forge (1.13+) uses cpw.mods.bootstraplauncher
+ (
+ "cpw.mods.bootstraplauncher.BootstrapLauncher".to_string(),
+ vec![
+ create_library_entry(&forge_maven_coord, Some(FORGE_MAVEN_URL)),
+ create_library_entry(
+ &format!(
+ "net.minecraftforge:forge:{}-{}:universal",
+ game_version, forge_version
+ ),
+ Some(FORGE_MAVEN_URL),
+ ),
+ ],
+ )
+ } else {
+ // Legacy Forge uses LaunchWrapper
+ (
+ "net.minecraft.launchwrapper.Launch".to_string(),
+ vec![
+ create_library_entry(&forge_maven_coord, Some(FORGE_MAVEN_URL)),
+ create_library_entry("net.minecraft:launchwrapper:1.12", None),
+ ],
+ )
+ };
+
+ let json = serde_json::json!({
+ "id": version_id,
+ "inheritsFrom": game_version,
+ "type": "release",
+ "mainClass": main_class,
+ "libraries": libraries,
+ "arguments": {
+ "game": [],
+ "jvm": []
+ }
+ });
+
+ Ok(json)
+}
+
+/// Create a library entry for the version JSON.
+fn create_library_entry(name: &str, maven_url: Option<&str>) -> serde_json::Value {
+ let mut entry = serde_json::json!({
+ "name": name
+ });
+
+ if let Some(url) = maven_url {
+ entry["url"] = serde_json::Value::String(url.to_string());
+ }
+
+ entry
+}
+
+/// Check if the Minecraft version uses modern Forge (1.13+).
+fn is_modern_forge(game_version: &str) -> bool {
+ let parts: Vec<&str> = game_version.split('.').collect();
+ if parts.len() >= 2 {
+ if let (Ok(major), Ok(minor)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
+ return major > 1 || (major == 1 && minor >= 13);
+ }
+ }
+ false
+}
+
+/// Check if Forge is installed for a specific version combination.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+/// * `game_version` - The Minecraft version
+/// * `forge_version` - The Forge version
+///
+/// # Returns
+/// `true` if the version JSON exists, `false` otherwise.
+pub fn is_forge_installed(game_dir: &PathBuf, game_version: &str, forge_version: &str) -> bool {
+ let version_id = generate_version_id(game_version, forge_version);
+ let json_path = game_dir
+ .join("versions")
+ .join(&version_id)
+ .join(format!("{}.json", version_id));
+ json_path.exists()
+}
+
+/// List all installed Forge versions in the game directory.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+///
+/// # Returns
+/// A list of installed Forge version IDs.
+pub async fn list_installed_forge_versions(
+ game_dir: &PathBuf,
+) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
+ let versions_dir = game_dir.join("versions");
+ let mut installed = Vec::new();
+
+ if !versions_dir.exists() {
+ return Ok(installed);
+ }
+
+ let mut entries = tokio::fs::read_dir(&versions_dir).await?;
+ while let Some(entry) = entries.next_entry().await? {
+ let name = entry.file_name().to_string_lossy().to_string();
+ if name.contains("-forge-") {
+ // Verify the JSON file exists
+ let json_path = entry.path().join(format!("{}.json", name));
+ if json_path.exists() {
+ installed.push(name);
+ }
+ }
+ }
+
+ Ok(installed)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_generate_version_id() {
+ assert_eq!(
+ generate_version_id("1.20.4", "49.0.38"),
+ "1.20.4-forge-49.0.38"
+ );
+ }
+
+ #[test]
+ fn test_is_modern_forge() {
+ assert!(!is_modern_forge("1.12.2"));
+ assert!(is_modern_forge("1.13"));
+ assert!(is_modern_forge("1.20.4"));
+ assert!(is_modern_forge("1.21"));
+ }
+}
diff --git a/src-tauri/src/core/game_version.rs b/src-tauri/src/core/game_version.rs
index 572882f..c62e232 100644
--- a/src-tauri/src/core/game_version.rs
+++ b/src-tauri/src/core/game_version.rs
@@ -1,11 +1,15 @@
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
-#[derive(Debug, Deserialize)]
+/// Represents a Minecraft version JSON, supporting both vanilla and modded (Fabric/Forge) formats.
+/// Modded versions use `inheritsFrom` to reference a parent vanilla version.
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct GameVersion {
pub id: String,
- pub downloads: Downloads,
+ /// Optional for mod loaders that inherit from vanilla
+ pub downloads: Option<Downloads>,
+ /// Optional for mod loaders that inherit from vanilla
#[serde(rename = "assetIndex")]
- pub asset_index: AssetIndex,
+ pub asset_index: Option<AssetIndex>,
pub libraries: Vec<Library>,
#[serde(rename = "mainClass")]
pub main_class: String,
@@ -14,66 +18,77 @@ pub struct GameVersion {
pub arguments: Option<Arguments>,
#[serde(rename = "javaVersion")]
pub java_version: Option<JavaVersion>,
+ /// For mod loaders: the vanilla version this inherits from
+ #[serde(rename = "inheritsFrom")]
+ pub inherits_from: Option<String>,
+ /// Fabric/Forge may specify a custom assets version
+ pub assets: Option<String>,
+ /// Release type (release, snapshot, old_beta, etc.)
+ #[serde(rename = "type")]
+ pub version_type: Option<String>,
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Downloads {
pub client: DownloadArtifact,
pub server: Option<DownloadArtifact>,
}
-#[derive(Debug, Deserialize, Clone)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct DownloadArtifact {
- pub sha1: String,
- pub size: u64,
+ pub sha1: Option<String>,
+ pub size: Option<u64>,
pub url: String,
pub path: Option<String>,
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AssetIndex {
pub id: String,
pub sha1: String,
pub size: u64,
pub url: String,
#[serde(rename = "totalSize")]
- pub total_size: u64,
+ pub total_size: Option<u64>,
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Library {
pub downloads: Option<LibraryDownloads>,
pub name: String,
pub rules: Option<Vec<Rule>>,
pub natives: Option<serde_json::Value>,
+ /// Maven repository URL for mod loader libraries
+ pub url: Option<String>,
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Rule {
pub action: String, // "allow" or "disallow"
pub os: Option<OsRule>,
+ pub features: Option<serde_json::Value>, // Feature-based rules (e.g., is_demo_user, has_quick_plays_support)
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct OsRule {
pub name: Option<String>, // "linux", "osx", "windows"
pub version: Option<String>, // Regex
pub arch: Option<String>, // "x86"
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LibraryDownloads {
pub artifact: Option<DownloadArtifact>,
pub classifiers: Option<serde_json::Value>, // Complex, simplifying for now
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Arguments {
pub game: Option<serde_json::Value>,
pub jvm: Option<serde_json::Value>,
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct JavaVersion {
pub component: String,
#[serde(rename = "majorVersion")]
diff --git a/src-tauri/src/core/java.rs b/src-tauri/src/core/java.rs
index a622d60..b223cd2 100644
--- a/src-tauri/src/core/java.rs
+++ b/src-tauri/src/core/java.rs
@@ -356,7 +356,10 @@ pub fn detect_java_installations() -> Vec<JavaInstallation> {
for candidate in candidates {
if let Some(java) = check_java_installation(&candidate) {
// Avoid duplicates
- if !installations.iter().any(|j: &JavaInstallation| j.path == java.path) {
+ if !installations
+ .iter()
+ .any(|j: &JavaInstallation| j.path == java.path)
+ {
installations.push(java);
}
}
@@ -460,7 +463,9 @@ fn get_java_candidates() -> Vec<PathBuf> {
if homebrew_arm.exists() {
if let Ok(entries) = std::fs::read_dir(&homebrew_arm) {
for entry in entries.flatten() {
- let java_path = entry.path().join("libexec/openjdk.jdk/Contents/Home/bin/java");
+ let java_path = entry
+ .path()
+ .join("libexec/openjdk.jdk/Contents/Home/bin/java");
if java_path.exists() {
candidates.push(java_path);
}
@@ -472,8 +477,10 @@ fn get_java_candidates() -> Vec<PathBuf> {
#[cfg(target_os = "windows")]
{
// Windows Java paths
- let program_files = std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".to_string());
- let program_files_x86 = std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| "C:\\Program Files (x86)".to_string());
+ let program_files =
+ std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".to_string());
+ let program_files_x86 = std::env::var("ProgramFiles(x86)")
+ .unwrap_or_else(|_| "C:\\Program Files (x86)".to_string());
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_default();
let win_paths = [
@@ -525,14 +532,11 @@ fn get_java_candidates() -> Vec<PathBuf> {
/// Check a specific Java installation and get its version info
fn check_java_installation(path: &PathBuf) -> Option<JavaInstallation> {
- let output = Command::new(path)
- .arg("-version")
- .output()
- .ok()?;
+ let output = Command::new(path).arg("-version").output().ok()?;
// Java outputs version info to stderr
let version_output = String::from_utf8_lossy(&output.stderr);
-
+
// Parse version string (e.g., "openjdk version \"17.0.1\"" or "java version \"1.8.0_301\"")
let version = parse_version_string(&version_output)?;
let is_64bit = version_output.contains("64-Bit");
@@ -579,7 +583,7 @@ fn parse_java_version(version: &str) -> u32 {
/// Get the best Java for a specific Minecraft version
pub fn get_recommended_java(required_major_version: Option<u64>) -> Option<JavaInstallation> {
let installations = detect_java_installations();
-
+
if let Some(required) = required_major_version {
// Find exact match or higher
installations.into_iter().find(|java| {
diff --git a/src-tauri/src/core/manifest.rs b/src-tauri/src/core/manifest.rs
index 11ebc5a..bae87c9 100644
--- a/src-tauri/src/core/manifest.rs
+++ b/src-tauri/src/core/manifest.rs
@@ -1,5 +1,8 @@
use serde::{Deserialize, Serialize};
use std::error::Error;
+use std::path::PathBuf;
+
+use crate::core::game_version::GameVersion;
#[derive(Debug, Deserialize, Serialize)]
pub struct VersionManifest {
@@ -24,8 +27,157 @@ pub struct Version {
pub release_time: String,
}
-pub async fn fetch_version_manifest() -> Result<VersionManifest, Box<dyn Error>> {
+pub async fn fetch_version_manifest() -> Result<VersionManifest, Box<dyn Error + Send + Sync>> {
let url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
let resp = reqwest::get(url).await?.json::<VersionManifest>().await?;
Ok(resp)
}
+
+/// Load a version JSON from the local versions directory.
+///
+/// This is used for loading both vanilla and modded versions that have been
+/// previously downloaded or installed.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+/// * `version_id` - The version ID to load
+///
+/// # Returns
+/// The parsed `GameVersion` if found, or an error if not found.
+pub async fn load_local_version(
+ game_dir: &PathBuf,
+ version_id: &str,
+) -> Result<GameVersion, Box<dyn Error + Send + Sync>> {
+ let json_path = game_dir
+ .join("versions")
+ .join(version_id)
+ .join(format!("{}.json", version_id));
+
+ if !json_path.exists() {
+ return Err(format!("Version {} not found locally", version_id).into());
+ }
+
+ let content = tokio::fs::read_to_string(&json_path).await?;
+ let version: GameVersion = serde_json::from_str(&content)?;
+ Ok(version)
+}
+
+/// Fetch a version JSON from Mojang's servers.
+///
+/// # Arguments
+/// * `version_id` - The version ID to fetch
+///
+/// # Returns
+/// The parsed `GameVersion` from Mojang's API.
+pub async fn fetch_vanilla_version(
+ version_id: &str,
+) -> Result<GameVersion, Box<dyn Error + Send + Sync>> {
+ // First, get the manifest to find the version URL
+ let manifest = fetch_version_manifest().await?;
+
+ let version_entry = manifest
+ .versions
+ .iter()
+ .find(|v| v.id == version_id)
+ .ok_or_else(|| format!("Version {} not found in manifest", version_id))?;
+
+ // Fetch the actual version JSON
+ let resp = reqwest::get(&version_entry.url)
+ .await?
+ .json::<GameVersion>()
+ .await?;
+
+ Ok(resp)
+}
+
+/// Load a version, checking local first, then fetching from remote if needed.
+///
+/// For modded versions (those with `inheritsFrom`), this will also resolve
+/// the inheritance chain.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+/// * `version_id` - The version ID to load
+///
+/// # Returns
+/// A fully resolved `GameVersion` ready for launching.
+pub async fn load_version(
+ game_dir: &PathBuf,
+ version_id: &str,
+) -> Result<GameVersion, Box<dyn Error + Send + Sync>> {
+ // Try loading from local first
+ let mut version = match load_local_version(game_dir, version_id).await {
+ Ok(v) => v,
+ Err(_) => {
+ // Not found locally, try fetching from Mojang
+ fetch_vanilla_version(version_id).await?
+ }
+ };
+
+ // If this version inherits from another, resolve the inheritance iteratively
+ while let Some(parent_id) = version.inherits_from.clone() {
+ // Load the parent version
+ let parent = match load_local_version(game_dir, &parent_id).await {
+ Ok(v) => v,
+ Err(_) => fetch_vanilla_version(&parent_id).await?,
+ };
+
+ // Merge child into parent
+ version = crate::core::version_merge::merge_versions(version, parent);
+ }
+
+ Ok(version)
+}
+
+/// Save a version JSON to the local versions directory.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+/// * `version` - The version to save
+///
+/// # Returns
+/// The path where the JSON was saved.
+pub async fn save_local_version(
+ game_dir: &PathBuf,
+ version: &GameVersion,
+) -> Result<PathBuf, Box<dyn Error + Send + Sync>> {
+ let version_dir = game_dir.join("versions").join(&version.id);
+ tokio::fs::create_dir_all(&version_dir).await?;
+
+ let json_path = version_dir.join(format!("{}.json", version.id));
+ let content = serde_json::to_string_pretty(version)?;
+ tokio::fs::write(&json_path, content).await?;
+
+ Ok(json_path)
+}
+
+/// List all locally installed versions.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+///
+/// # Returns
+/// A list of version IDs found in the versions directory.
+pub async fn list_local_versions(
+ game_dir: &PathBuf,
+) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
+ let versions_dir = game_dir.join("versions");
+ let mut versions = Vec::new();
+
+ if !versions_dir.exists() {
+ return Ok(versions);
+ }
+
+ let mut entries = tokio::fs::read_dir(&versions_dir).await?;
+ while let Some(entry) = entries.next_entry().await? {
+ if entry.file_type().await?.is_dir() {
+ let name = entry.file_name().to_string_lossy().to_string();
+ let json_path = entry.path().join(format!("{}.json", name));
+ if json_path.exists() {
+ versions.push(name);
+ }
+ }
+ }
+
+ Ok(versions)
+}
diff --git a/src-tauri/src/core/maven.rs b/src-tauri/src/core/maven.rs
new file mode 100644
index 0000000..8c89768
--- /dev/null
+++ b/src-tauri/src/core/maven.rs
@@ -0,0 +1,263 @@
+//! Maven coordinate parsing and URL construction utilities.
+//!
+//! Mod loaders like Fabric and Forge specify libraries using Maven coordinates
+//! (e.g., `net.fabricmc:fabric-loader:0.14.21`) instead of direct download URLs.
+//! This module provides utilities to parse these coordinates and construct
+//! download URLs for various Maven repositories.
+
+use std::path::PathBuf;
+
+/// Known Maven repository URLs for mod loaders
+pub const MAVEN_CENTRAL: &str = "https://repo1.maven.org/maven2/";
+pub const FABRIC_MAVEN: &str = "https://maven.fabricmc.net/";
+pub const FORGE_MAVEN: &str = "https://maven.minecraftforge.net/";
+pub const MOJANG_LIBRARIES: &str = "https://libraries.minecraft.net/";
+
+/// Represents a parsed Maven coordinate.
+///
+/// Maven coordinates follow the format: `group:artifact:version[:classifier][@extension]`
+/// Examples:
+/// - `net.fabricmc:fabric-loader:0.14.21`
+/// - `org.lwjgl:lwjgl:3.3.1:natives-linux`
+/// - `com.example:artifact:1.0@zip`
+#[derive(Debug, Clone, PartialEq)]
+pub struct MavenCoordinate {
+ pub group: String,
+ pub artifact: String,
+ pub version: String,
+ pub classifier: Option<String>,
+ pub extension: String,
+}
+
+impl MavenCoordinate {
+ /// Parse a Maven coordinate string.
+ ///
+ /// # Arguments
+ /// * `coord` - A string in the format `group:artifact:version[:classifier][@extension]`
+ ///
+ /// # Returns
+ /// * `Some(MavenCoordinate)` if parsing succeeds
+ /// * `None` if the format is invalid
+ ///
+ /// # Examples
+ /// ```
+ /// let coord = MavenCoordinate::parse("net.fabricmc:fabric-loader:0.14.21").unwrap();
+ /// assert_eq!(coord.group, "net.fabricmc");
+ /// assert_eq!(coord.artifact, "fabric-loader");
+ /// assert_eq!(coord.version, "0.14.21");
+ /// ```
+ pub fn parse(coord: &str) -> Option<Self> {
+ // Handle extension suffix (e.g., @zip)
+ let (coord_part, extension) = if let Some(at_idx) = coord.rfind('@') {
+ let ext = &coord[at_idx + 1..];
+ let base = &coord[..at_idx];
+ (base, ext.to_string())
+ } else {
+ (coord, "jar".to_string())
+ };
+
+ let parts: Vec<&str> = coord_part.split(':').collect();
+
+ match parts.len() {
+ 3 => Some(MavenCoordinate {
+ group: parts[0].to_string(),
+ artifact: parts[1].to_string(),
+ version: parts[2].to_string(),
+ classifier: None,
+ extension,
+ }),
+ 4 => Some(MavenCoordinate {
+ group: parts[0].to_string(),
+ artifact: parts[1].to_string(),
+ version: parts[2].to_string(),
+ classifier: Some(parts[3].to_string()),
+ extension,
+ }),
+ _ => None,
+ }
+ }
+
+ /// Get the relative path for this artifact in a Maven repository.
+ ///
+ /// # Returns
+ /// The path as `group/artifact/version/artifact-version[-classifier].extension`
+ ///
+ /// # Examples
+ /// ```
+ /// let coord = MavenCoordinate::parse("net.fabricmc:fabric-loader:0.14.21").unwrap();
+ /// assert_eq!(coord.to_path(), "net/fabricmc/fabric-loader/0.14.21/fabric-loader-0.14.21.jar");
+ /// ```
+ pub fn to_path(&self) -> String {
+ let group_path = self.group.replace('.', "/");
+ let filename = match &self.classifier {
+ Some(classifier) => {
+ format!(
+ "{}-{}-{}.{}",
+ self.artifact, self.version, classifier, self.extension
+ )
+ }
+ None => {
+ format!("{}-{}.{}", self.artifact, self.version, self.extension)
+ }
+ };
+
+ format!(
+ "{}/{}/{}/{}",
+ group_path, self.artifact, self.version, filename
+ )
+ }
+
+ /// Get the local file path for storing this artifact.
+ ///
+ /// # Arguments
+ /// * `libraries_dir` - The base libraries directory
+ ///
+ /// # Returns
+ /// The full path where the library should be stored
+ pub fn to_local_path(&self, libraries_dir: &PathBuf) -> PathBuf {
+ let rel_path = self.to_path();
+ libraries_dir.join(rel_path.replace('/', std::path::MAIN_SEPARATOR_STR))
+ }
+
+ /// Construct the full download URL for this artifact.
+ ///
+ /// # Arguments
+ /// * `base_url` - The Maven repository base URL (e.g., `https://maven.fabricmc.net/`)
+ ///
+ /// # Returns
+ /// The full URL to download the artifact
+ pub fn to_url(&self, base_url: &str) -> String {
+ let base = base_url.trim_end_matches('/');
+ format!("{}/{}", base, self.to_path())
+ }
+}
+
+/// Resolve the download URL for a library.
+///
+/// This function handles both:
+/// 1. Libraries with explicit download URLs (vanilla Minecraft)
+/// 2. Libraries with only Maven coordinates (Fabric/Forge)
+///
+/// # Arguments
+/// * `name` - The Maven coordinate string
+/// * `explicit_url` - An explicit download URL if provided in the library JSON
+/// * `maven_url` - A custom Maven repository URL from the library JSON
+///
+/// # Returns
+/// The resolved download URL
+pub fn resolve_library_url(
+ name: &str,
+ explicit_url: Option<&str>,
+ maven_url: Option<&str>,
+) -> Option<String> {
+ // If there's an explicit URL, use it
+ if let Some(url) = explicit_url {
+ return Some(url.to_string());
+ }
+
+ // Parse the Maven coordinate
+ let coord = MavenCoordinate::parse(name)?;
+
+ // Determine the base Maven URL
+ let base_url = maven_url.unwrap_or_else(|| {
+ // Guess the repository based on group
+ if coord.group.starts_with("net.fabricmc") {
+ FABRIC_MAVEN
+ } else if coord.group.starts_with("net.minecraftforge")
+ || coord.group.starts_with("cpw.mods")
+ {
+ FORGE_MAVEN
+ } else {
+ MOJANG_LIBRARIES
+ }
+ });
+
+ Some(coord.to_url(base_url))
+}
+
+/// Get the local storage path for a library.
+///
+/// # Arguments
+/// * `name` - The Maven coordinate string
+/// * `libraries_dir` - The base libraries directory
+///
+/// # Returns
+/// The path where the library should be stored
+pub fn get_library_path(name: &str, libraries_dir: &PathBuf) -> Option<PathBuf> {
+ let coord = MavenCoordinate::parse(name)?;
+ Some(coord.to_local_path(libraries_dir))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_simple_coordinate() {
+ let coord = MavenCoordinate::parse("net.fabricmc:fabric-loader:0.14.21").unwrap();
+ assert_eq!(coord.group, "net.fabricmc");
+ assert_eq!(coord.artifact, "fabric-loader");
+ assert_eq!(coord.version, "0.14.21");
+ assert_eq!(coord.classifier, None);
+ assert_eq!(coord.extension, "jar");
+ }
+
+ #[test]
+ fn test_parse_with_classifier() {
+ let coord = MavenCoordinate::parse("org.lwjgl:lwjgl:3.3.1:natives-linux").unwrap();
+ assert_eq!(coord.group, "org.lwjgl");
+ assert_eq!(coord.artifact, "lwjgl");
+ assert_eq!(coord.version, "3.3.1");
+ assert_eq!(coord.classifier, Some("natives-linux".to_string()));
+ assert_eq!(coord.extension, "jar");
+ }
+
+ #[test]
+ fn test_parse_with_extension() {
+ let coord = MavenCoordinate::parse("com.example:artifact:1.0@zip").unwrap();
+ assert_eq!(coord.extension, "zip");
+ }
+
+ #[test]
+ fn test_to_path() {
+ let coord = MavenCoordinate::parse("net.fabricmc:fabric-loader:0.14.21").unwrap();
+ assert_eq!(
+ coord.to_path(),
+ "net/fabricmc/fabric-loader/0.14.21/fabric-loader-0.14.21.jar"
+ );
+ }
+
+ #[test]
+ fn test_to_path_with_classifier() {
+ let coord = MavenCoordinate::parse("org.lwjgl:lwjgl:3.3.1:natives-linux").unwrap();
+ assert_eq!(
+ coord.to_path(),
+ "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-linux.jar"
+ );
+ }
+
+ #[test]
+ fn test_to_url() {
+ let coord = MavenCoordinate::parse("net.fabricmc:fabric-loader:0.14.21").unwrap();
+ assert_eq!(
+ coord.to_url(FABRIC_MAVEN),
+ "https://maven.fabricmc.net/net/fabricmc/fabric-loader/0.14.21/fabric-loader-0.14.21.jar"
+ );
+ }
+
+ #[test]
+ fn test_resolve_library_url_explicit() {
+ let url = resolve_library_url(
+ "net.fabricmc:fabric-loader:0.14.21",
+ Some("https://example.com/lib.jar"),
+ None,
+ );
+ assert_eq!(url, Some("https://example.com/lib.jar".to_string()));
+ }
+
+ #[test]
+ fn test_resolve_library_url_fabric() {
+ let url = resolve_library_url("net.fabricmc:fabric-loader:0.14.21", None, None);
+ assert!(url.unwrap().starts_with(FABRIC_MAVEN));
+ }
+}
diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs
index 475a304..3c09a76 100644
--- a/src-tauri/src/core/mod.rs
+++ b/src-tauri/src/core/mod.rs
@@ -2,7 +2,11 @@ pub mod account_storage;
pub mod auth;
pub mod config;
pub mod downloader;
+pub mod fabric;
+pub mod forge;
pub mod game_version;
pub mod java;
pub mod manifest;
+pub mod maven;
pub mod rules;
+pub mod version_merge;
diff --git a/src-tauri/src/core/rules.rs b/src-tauri/src/core/rules.rs
index 877982a..71abda5 100644
--- a/src-tauri/src/core/rules.rs
+++ b/src-tauri/src/core/rules.rs
@@ -47,6 +47,13 @@ pub fn is_library_allowed(rules: &Option<Vec<Rule>>) -> bool {
}
fn rule_matches(rule: &Rule) -> bool {
+ // Feature-based rules (e.g., is_demo_user, has_quick_plays_support, is_quick_play_singleplayer)
+ // are not implemented in this launcher, so we return false for any rule that has features.
+ // This prevents adding arguments like --demo, --quickPlayPath, --quickPlaySingleplayer, etc.
+ if rule.features.is_some() {
+ return false;
+ }
+
match &rule.os {
None => true, // No OS condition means it applies to all
Some(os_rule) => {
diff --git a/src-tauri/src/core/version_merge.rs b/src-tauri/src/core/version_merge.rs
new file mode 100644
index 0000000..fe6b3cd
--- /dev/null
+++ b/src-tauri/src/core/version_merge.rs
@@ -0,0 +1,244 @@
+//! Version merging utilities for mod loaders.
+//!
+//! Mod loaders like Fabric and Forge create "partial" version JSON files that
+//! inherit from vanilla Minecraft versions via the `inheritsFrom` field.
+//! This module provides functionality to merge these partial versions with
+//! their parent versions to create a complete, launchable version profile.
+
+use crate::core::game_version::{Arguments, GameVersion};
+use std::error::Error;
+
+/// Merge a child version (mod loader) with its parent version (vanilla).
+///
+/// The merging follows these rules:
+/// 1. Child's `mainClass` overrides parent's
+/// 2. Child's libraries are prepended to parent's (mod loader classes take priority)
+/// 3. Arguments are merged (child's additions come after parent's)
+/// 4. Parent provides `downloads`, `assetIndex`, `javaVersion` if child doesn't have them
+///
+/// # Arguments
+/// * `child` - The mod loader version (e.g., Fabric)
+/// * `parent` - The vanilla Minecraft version
+///
+/// # Returns
+/// A merged `GameVersion` that can be used for launching.
+pub fn merge_versions(child: GameVersion, parent: GameVersion) -> GameVersion {
+ // Libraries: child libraries first (mod loader takes priority in classpath)
+ let mut merged_libraries = child.libraries;
+ merged_libraries.extend(parent.libraries);
+
+ // Arguments: merge both game and JVM arguments
+ let merged_arguments = merge_arguments(child.arguments, parent.arguments);
+
+ GameVersion {
+ id: child.id,
+ // Use child's downloads if present, otherwise parent's
+ downloads: child.downloads.or(parent.downloads),
+ // Use child's asset_index if present, otherwise parent's
+ asset_index: child.asset_index.or(parent.asset_index),
+ libraries: merged_libraries,
+ // Child's main class always takes priority (this is the mod loader entry point)
+ main_class: child.main_class,
+ // Prefer child's minecraft_arguments, fall back to parent's
+ minecraft_arguments: child.minecraft_arguments.or(parent.minecraft_arguments),
+ arguments: merged_arguments,
+ // Use child's java_version if specified, otherwise parent's
+ java_version: child.java_version.or(parent.java_version),
+ // Clear inheritsFrom since we've now merged
+ inherits_from: None,
+ // Use child's assets field if present, otherwise parent's
+ assets: child.assets.or(parent.assets),
+ // Use parent's version type if child doesn't specify
+ version_type: child.version_type.or(parent.version_type),
+ }
+}
+
+/// Merge argument objects from child and parent versions.
+///
+/// Both game and JVM arguments are merged, with parent arguments coming first
+/// and child arguments appended (child can add additional arguments).
+fn merge_arguments(child: Option<Arguments>, parent: Option<Arguments>) -> Option<Arguments> {
+ match (child, parent) {
+ (None, None) => None,
+ (Some(c), None) => Some(c),
+ (None, Some(p)) => Some(p),
+ (Some(c), Some(p)) => Some(Arguments {
+ game: merge_json_arrays(p.game, c.game),
+ jvm: merge_json_arrays(p.jvm, c.jvm),
+ }),
+ }
+}
+
+/// Merge two JSON arrays (used for arguments).
+///
+/// Parent array comes first, child array is appended.
+fn merge_json_arrays(
+ parent: Option<serde_json::Value>,
+ child: Option<serde_json::Value>,
+) -> Option<serde_json::Value> {
+ match (parent, child) {
+ (None, None) => None,
+ (Some(p), None) => Some(p),
+ (None, Some(c)) => Some(c),
+ (Some(p), Some(c)) => {
+ if let (serde_json::Value::Array(mut p_arr), serde_json::Value::Array(c_arr)) =
+ (p.clone(), c.clone())
+ {
+ p_arr.extend(c_arr);
+ Some(serde_json::Value::Array(p_arr))
+ } else {
+ // If they're not arrays, prefer child
+ Some(c)
+ }
+ }
+ }
+}
+
+/// Check if a version requires inheritance resolution.
+///
+/// # Arguments
+/// * `version` - The version to check
+///
+/// # Returns
+/// `true` if the version has an `inheritsFrom` field that needs resolution.
+pub fn needs_inheritance_resolution(version: &GameVersion) -> bool {
+ version.inherits_from.is_some()
+}
+
+/// Recursively resolve version inheritance.
+///
+/// This function resolves the entire inheritance chain by loading parent versions
+/// and merging them until a version without `inheritsFrom` is found.
+///
+/// # Arguments
+/// * `version` - The starting version (e.g., a Fabric version)
+/// * `version_loader` - A function that loads a version by ID
+///
+/// # Returns
+/// A fully merged `GameVersion` with all inheritance resolved.
+pub async fn resolve_inheritance<F, Fut>(
+ version: GameVersion,
+ version_loader: F,
+) -> Result<GameVersion, Box<dyn Error + Send + Sync>>
+where
+ F: Fn(String) -> Fut,
+ Fut: std::future::Future<Output = Result<GameVersion, Box<dyn Error + Send + Sync>>>,
+{
+ let mut current = version;
+
+ // Keep resolving until we have no more inheritance
+ while let Some(parent_id) = current.inherits_from.clone() {
+ let parent = version_loader(parent_id).await?;
+ current = merge_versions(current, parent);
+ }
+
+ Ok(current)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::core::game_version::{DownloadArtifact, Downloads, Library};
+
+ fn create_test_library(name: &str) -> Library {
+ Library {
+ name: name.to_string(),
+ downloads: None,
+ rules: None,
+ natives: None,
+ url: None,
+ }
+ }
+
+ #[test]
+ fn test_merge_libraries_order() {
+ let child = GameVersion {
+ id: "fabric-1.20.4".to_string(),
+ downloads: None,
+ asset_index: None,
+ libraries: vec![create_test_library("fabric:loader:1.0")],
+ main_class: "net.fabricmc.loader.launch.knot.KnotClient".to_string(),
+ minecraft_arguments: None,
+ arguments: None,
+ java_version: None,
+ inherits_from: Some("1.20.4".to_string()),
+ assets: None,
+ version_type: None,
+ };
+
+ let parent = GameVersion {
+ id: "1.20.4".to_string(),
+ downloads: Some(Downloads {
+ client: DownloadArtifact {
+ sha1: Some("abc".to_string()),
+ size: Some(1000),
+ url: "https://example.com/client.jar".to_string(),
+ path: None,
+ },
+ server: None,
+ }),
+ asset_index: None,
+ libraries: vec![create_test_library("net.minecraft:client:1.20.4")],
+ main_class: "net.minecraft.client.main.Main".to_string(),
+ minecraft_arguments: None,
+ arguments: None,
+ java_version: None,
+ inherits_from: None,
+ assets: None,
+ version_type: Some("release".to_string()),
+ };
+
+ let merged = merge_versions(child, parent);
+
+ // Child libraries should come first
+ assert_eq!(merged.libraries.len(), 2);
+ assert_eq!(merged.libraries[0].name, "fabric:loader:1.0");
+ assert_eq!(merged.libraries[1].name, "net.minecraft:client:1.20.4");
+
+ // Child main class should override
+ assert_eq!(
+ merged.main_class,
+ "net.fabricmc.loader.launch.knot.KnotClient"
+ );
+
+ // Parent downloads should be used
+ assert!(merged.downloads.is_some());
+
+ // inheritsFrom should be cleared
+ assert!(merged.inherits_from.is_none());
+ }
+
+ #[test]
+ fn test_needs_inheritance_resolution() {
+ let with_inheritance = GameVersion {
+ id: "test".to_string(),
+ downloads: None,
+ asset_index: None,
+ libraries: vec![],
+ main_class: "Main".to_string(),
+ minecraft_arguments: None,
+ arguments: None,
+ java_version: None,
+ inherits_from: Some("1.20.4".to_string()),
+ assets: None,
+ version_type: None,
+ };
+
+ let without_inheritance = GameVersion {
+ id: "test".to_string(),
+ downloads: None,
+ asset_index: None,
+ libraries: vec![],
+ main_class: "Main".to_string(),
+ minecraft_arguments: None,
+ arguments: None,
+ java_version: None,
+ inherits_from: None,
+ assets: None,
+ version_type: None,
+ };
+
+ assert!(needs_inheritance_resolution(&with_inheritance));
+ assert!(!needs_inheritance_resolution(&without_inheritance));
+ }
+}