aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src-tauri
diff options
context:
space:
mode:
authorBegonia, HE <163421589+BegoniaHe@users.noreply.github.com>2026-01-14 22:05:25 +0100
committerBegonia, HE <163421589+BegoniaHe@users.noreply.github.com>2026-01-14 22:05:25 +0100
commitb473aa744e1382e946a92a116707b93151558888 (patch)
treea8957a732caac948412c78ac7a443771f7ee12d0 /src-tauri
parent2cb21f2bbc601ae134095cf0e68b5bcc6966d227 (diff)
parent18111ef323a81e399e3b907c9046170afcb8e0eb (diff)
downloadDropOut-b473aa744e1382e946a92a116707b93151558888.tar.gz
DropOut-b473aa744e1382e946a92a116707b93151558888.zip
Merge main into feat/download-java-rt
- Integrate latest main branch changes (Fabric, Forge support, new UI) - Keep Adoptium Java download feature with SHA256 support - Merge improved download progress tracking with checksum verification - Update dependencies and build configuration
Diffstat (limited to 'src-tauri')
-rw-r--r--src-tauri/Cargo.toml3
-rw-r--r--src-tauri/capabilities/default.json3
-rwxr-xr-xsrc-tauri/scripts/fix-appimage.sh32
-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
-rw-r--r--src-tauri/src/main.rs460
-rw-r--r--src-tauri/tauri.conf.json13
18 files changed, 1901 insertions, 205 deletions
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 0387526..bc831fb 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "dropout"
-version = "0.1.13"
+version = "0.1.19"
edition = "2021"
authors = ["HsiangNianian"]
description = "The DropOut Minecraft Game Launcher"
@@ -27,6 +27,7 @@ flate2 = "1.0"
tar = "0.4"
dirs = "5.0"
serde_urlencoded = "0.7.1"
+tauri-plugin-dialog = "2.5.0"
[build-dependencies]
tauri-build = { version = "2.0", features = [] }
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index 894b905..4d8b907 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -12,6 +12,7 @@
"core:app:allow-version",
"core:path:default",
"core:window:default",
- "shell:allow-open"
+ "shell:allow-open",
+ "dialog:default"
]
}
diff --git a/src-tauri/scripts/fix-appimage.sh b/src-tauri/scripts/fix-appimage.sh
new file mode 100755
index 0000000..6bb375b
--- /dev/null
+++ b/src-tauri/scripts/fix-appimage.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+# fix AppImage for Wayland compatibility
+# This script modifies the AppImage bundle created by Tauri to ensure compatibility with Wayland
+# It specifically targets the GTK backend settings to avoid forcing X11
+
+set -e
+
+echo "Fixing AppImage for Wayland compatibility..."
+
+# Tauri sets the APPIMAGE_BUNDLE_PATH environment variable during the build process
+APPDIR_PATH="${APPIMAGE_BUNDLE_PATH:-}"
+
+if [ -z "$APPDIR_PATH" ]; then
+ echo "No AppImage bundle path found, skipping fix"
+ exit 0
+fi
+
+# Check for the presence of the GTK hook file
+if [ -d "$APPDIR_PATH/apprun-hooks" ]; then
+ HOOK_FILE="$APPDIR_PATH/apprun-hooks/linuxdeploy-plugin-gtk.sh"
+
+ if [ -f "$HOOK_FILE" ]; then
+ echo "Found GTK hook file, patching..."
+
+ # Comment out the line that forces GDK_BACKEND to x11
+ sed -i 's/^export GDK_BACKEND=x11.*$/# export GDK_BACKEND=x11 # Disabled for Wayland compatibility/' "$HOOK_FILE"
+
+ echo "Successfully patched $HOOK_FILE"
+ fi
+fi
+
+echo "AppImage Wayland fix completed!"
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));
+ }
+}
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index 4c3f689..88d614c 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -26,6 +26,12 @@ pub struct MsRefreshTokenState {
pub token: Mutex<Option<String>>,
}
+impl Default for MsRefreshTokenState {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
impl MsRefreshTokenState {
pub fn new() -> Self {
Self {
@@ -34,6 +40,27 @@ impl MsRefreshTokenState {
}
}
+/// Check if a string contains unresolved placeholders in the form ${...}
+///
+/// After the replacement phase, if a string still contains ${...}, it means
+/// that placeholder variable was not found in the replacements map and is
+/// therefore unresolved. We should skip adding such arguments to avoid
+/// passing malformed arguments to the game launcher.
+fn has_unresolved_placeholder(s: &str) -> bool {
+ // Look for the opening sequence
+ if let Some(start_pos) = s.find("${") {
+ // Check if there's a closing brace after the opening sequence
+ if s[start_pos + 2..].find('}').is_some() {
+ // Found a complete ${...} pattern - this is an unresolved placeholder
+ return true;
+ }
+ // Found ${ but no closing } - also treat as unresolved/malformed
+ return true;
+ }
+ // No ${ found - the string is fully resolved
+ false
+}
+
#[tauri::command]
async fn start_game(
window: Window,
@@ -41,7 +68,10 @@ async fn start_game(
config_state: State<'_, core::config::ConfigState>,
version_id: String,
) -> Result<String, String> {
- emit_log!(window, format!("Starting game launch for version: {}", version_id));
+ emit_log!(
+ window,
+ format!("Starting game launch for version: {}", version_id)
+ );
// Check for active account
emit_log!(window, "Checking for active account...".to_string());
@@ -51,16 +81,22 @@ async fn start_game(
.unwrap()
.clone()
.ok_or("No active account found. Please login first.")?;
-
+
let account_type = match &account {
core::auth::Account::Offline(_) => "Offline",
core::auth::Account::Microsoft(_) => "Microsoft",
};
- emit_log!(window, format!("Account found: {} ({})", account.username(), account_type));
+ emit_log!(
+ window,
+ format!("Account found: {} ({})", account.username(), account_type)
+ );
let config = config_state.config.lock().unwrap().clone();
emit_log!(window, format!("Java path: {}", config.java_path));
- emit_log!(window, format!("Memory: {}MB - {}MB", config.min_memory, config.max_memory));
+ emit_log!(
+ window,
+ format!("Memory: {}MB - {}MB", config.min_memory, config.max_memory)
+ );
// Get App Data Directory (e.g., ~/.local/share/com.dropout.launcher or similar)
// The identifier is set in tauri.conf.json.
@@ -78,45 +114,50 @@ async fn start_game(
emit_log!(window, format!("Game directory: {:?}", game_dir));
- // 1. Fetch manifest to find the version URL
- emit_log!(window, "Fetching version manifest...".to_string());
- let manifest = core::manifest::fetch_version_manifest()
- .await
- .map_err(|e| e.to_string())?;
- emit_log!(window, format!("Found {} versions in manifest", manifest.versions.len()));
-
- // Find the version info
- let version_info = manifest
- .versions
- .iter()
- .find(|v| v.id == version_id)
- .ok_or_else(|| format!("Version {} not found in manifest", version_id))?;
-
- // 2. Fetch specific version JSON (client.jar info)
- emit_log!(window, format!("Fetching version details for {}...", version_id));
- let version_url = &version_info.url;
- let version_details: core::game_version::GameVersion = reqwest::get(version_url)
- .await
- .map_err(|e| e.to_string())?
- .json()
+ // 1. Load version (supports both vanilla and modded versions with inheritance)
+ emit_log!(
+ window,
+ format!("Loading version details for {}...", version_id)
+ );
+
+ let version_details = core::manifest::load_version(&game_dir, &version_id)
.await
.map_err(|e| e.to_string())?;
- emit_log!(window, format!("Version details loaded: main class = {}", version_details.main_class));
- // 3. Prepare download tasks
+ emit_log!(
+ window,
+ format!(
+ "Version details loaded: main class = {}",
+ version_details.main_class
+ )
+ );
+
+ // Determine the actual minecraft version for client.jar
+ // (for modded versions, this is the parent vanilla version)
+ let minecraft_version = version_details
+ .inherits_from
+ .clone()
+ .unwrap_or_else(|| version_id.clone());
+
+ // 2. Prepare download tasks
emit_log!(window, "Preparing download tasks...".to_string());
let mut download_tasks = Vec::new();
// --- Client Jar ---
- let client_jar = version_details.downloads.client;
+ // Get downloads from version_details (may be inherited)
+ let downloads = version_details
+ .downloads
+ .as_ref()
+ .ok_or("Version has no downloads information")?;
+ let client_jar = &downloads.client;
let mut client_path = game_dir.join("versions");
- client_path.push(&version_id);
- client_path.push(format!("{}.jar", version_id));
+ client_path.push(&minecraft_version);
+ client_path.push(format!("{}.jar", minecraft_version));
download_tasks.push(core::downloader::DownloadTask {
- url: client_jar.url,
+ url: client_jar.url.clone(),
path: client_path.clone(),
- sha1: Some(client_jar.sha1),
+ sha1: client_jar.sha1.clone(),
sha256: None,
});
@@ -127,7 +168,7 @@ async fn start_game(
for lib in &version_details.libraries {
if core::rules::is_library_allowed(&lib.rules) {
- // 1. Standard Library
+ // 1. Standard Library - check for explicit downloads first
if let Some(downloads) = &lib.downloads {
if let Some(artifact) = &downloads.artifact {
let path_str = artifact
@@ -141,7 +182,7 @@ async fn start_game(
download_tasks.push(core::downloader::DownloadTask {
url: artifact.url.clone(),
path: lib_path,
- sha1: Some(artifact.sha1.clone()),
+ sha1: artifact.sha1.clone(),
sha256: None,
});
}
@@ -175,7 +216,7 @@ async fn start_game(
download_tasks.push(core::downloader::DownloadTask {
url: native_artifact.url,
path: native_path.clone(),
- sha1: Some(native_artifact.sha1),
+ sha1: native_artifact.sha1,
sha256: None,
});
@@ -183,6 +224,21 @@ async fn start_game(
}
}
}
+ } else {
+ // 3. Library without explicit downloads (mod loader libraries)
+ // Use Maven coordinate resolution
+ if let Some(url) =
+ core::maven::resolve_library_url(&lib.name, None, lib.url.as_deref())
+ {
+ if let Some(lib_path) = core::maven::get_library_path(&lib.name, &libraries_dir)
+ {
+ download_tasks.push(core::downloader::DownloadTask {
+ url,
+ path: lib_path,
+ sha1: None, // Maven libraries often don't have SHA1 in the JSON
+ });
+ }
+ }
}
}
}
@@ -193,8 +249,14 @@ async fn start_game(
let objects_dir = assets_dir.join("objects");
let indexes_dir = assets_dir.join("indexes");
+ // Get asset index (may be inherited from parent)
+ let asset_index = version_details
+ .asset_index
+ .as_ref()
+ .ok_or("Version has no asset index information")?;
+
// Download Asset Index JSON
- let asset_index_path = indexes_dir.join(format!("{}.json", version_details.asset_index.id));
+ let asset_index_path = indexes_dir.join(format!("{}.json", asset_index.id));
// Check if index exists or download it
// Note: We need the content of this file to parse it.
@@ -206,11 +268,8 @@ async fn start_game(
.await
.map_err(|e| e.to_string())?
} else {
- println!(
- "Downloading asset index from {}",
- version_details.asset_index.url
- );
- let content = reqwest::get(&version_details.asset_index.url)
+ println!("Downloading asset index from {}", asset_index.url);
+ let content = reqwest::get(&asset_index.url)
.await
.map_err(|e| e.to_string())?
.text()
@@ -260,16 +319,29 @@ async fn start_game(
});
}
- emit_log!(window, format!(
- "Total download tasks: {} (Client + Libraries + Assets)",
- download_tasks.len()
- ));
+ emit_log!(
+ window,
+ format!(
+ "Total download tasks: {} (Client + Libraries + Assets)",
+ download_tasks.len()
+ )
+ );
// 4. Start Download
- emit_log!(window, "Starting downloads...".to_string());
- core::downloader::download_files(window.clone(), download_tasks)
- .await
- .map_err(|e| e.to_string())?;
+ emit_log!(
+ window,
+ format!(
+ "Starting downloads with {} concurrent threads...",
+ config.download_threads
+ )
+ );
+ core::downloader::download_files(
+ window.clone(),
+ download_tasks,
+ config.download_threads as usize,
+ )
+ .await
+ .map_err(|e| e.to_string())?;
emit_log!(window, "All downloads completed successfully".to_string());
// 5. Extract Natives
@@ -332,16 +404,16 @@ async fn start_game(
parse_jvm_arguments(jvm_args, &mut args, &natives_path, &classpath);
}
}
-
+
// Add memory settings (these override any defaults)
args.push(format!("-Xmx{}M", config.max_memory));
args.push(format!("-Xms{}M", config.min_memory));
-
+
// Ensure natives path is set if not already in jvm args
if !args.iter().any(|a| a.contains("-Djava.library.path")) {
args.push(format!("-Djava.library.path={}", natives_path));
}
-
+
// Ensure classpath is set if not already
if !args.iter().any(|a| a == "-cp" || a == "-classpath") {
args.push("-cp".to_string());
@@ -358,10 +430,7 @@ async fn start_game(
replacements.insert("${version_name}", version_id.clone());
replacements.insert("${game_directory}", game_dir.to_string_lossy().to_string());
replacements.insert("${assets_root}", assets_dir.to_string_lossy().to_string());
- replacements.insert(
- "${assets_index_name}",
- version_details.asset_index.id.clone(),
- );
+ replacements.insert("${assets_index_name}", asset_index.id.clone());
replacements.insert("${auth_uuid}", account.uuid());
replacements.insert("${auth_access_token}", account.access_token());
replacements.insert("${user_type}", "mojang".to_string());
@@ -413,7 +482,10 @@ async fn start_game(
for (key, replacement) in &replacements {
arg = arg.replace(key, replacement);
}
- args.push(arg);
+ // Skip arguments with unresolved placeholders
+ if !has_unresolved_placeholder(&arg) {
+ args.push(arg);
+ }
} else if let Some(arr) = val.as_array() {
for sub in arr {
if let Some(s) = sub.as_str() {
@@ -421,7 +493,10 @@ async fn start_game(
for (key, replacement) in &replacements {
arg = arg.replace(key, replacement);
}
- args.push(arg);
+ // Skip arguments with unresolved placeholders
+ if !has_unresolved_placeholder(&arg) {
+ args.push(arg);
+ }
}
}
}
@@ -433,14 +508,20 @@ async fn start_game(
}
}
- emit_log!(window, format!("Preparing to launch game with {} arguments...", args.len()));
+ emit_log!(
+ window,
+ format!("Preparing to launch game with {} arguments...", args.len())
+ );
// Debug: Log arguments (only first few to avoid spam)
if args.len() > 10 {
emit_log!(window, format!("First 10 args: {:?}", &args[..10]));
}
// Spawn the process
- emit_log!(window, format!("Starting Java process: {}", config.java_path));
+ emit_log!(
+ window,
+ format!("Starting Java process: {}", config.java_path)
+ );
let mut command = Command::new(&config.java_path);
command.args(&args);
command.current_dir(&game_dir); // Run in game directory
@@ -452,7 +533,10 @@ async fn start_game(
{
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(CREATE_NO_WINDOW);
- emit_log!(window, "Applied CREATE_NO_WINDOW flag for Windows".to_string());
+ emit_log!(
+ window,
+ "Applied CREATE_NO_WINDOW flag for Windows".to_string()
+ );
}
// Spawn and handle output
@@ -472,7 +556,10 @@ async fn start_game(
.expect("child did not have a handle to stderr");
// Emit launcher log that game is running
- emit_log!(window, "Game is now running, capturing output...".to_string());
+ emit_log!(
+ window,
+ "Game is now running, capturing output...".to_string()
+ );
let window_rx = window.clone();
tokio::spawn(async move {
@@ -541,9 +628,9 @@ fn parse_jvm_arguments(
} else if let Some(obj) = item.as_object() {
// Conditional argument with rules
let allow = if let Some(rules_val) = obj.get("rules") {
- if let Ok(rules) = serde_json::from_value::<Vec<core::game_version::Rule>>(
- rules_val.clone(),
- ) {
+ if let Ok(rules) =
+ serde_json::from_value::<Vec<core::game_version::Rule>>(rules_val.clone())
+ {
core::rules::is_library_allowed(&Some(rules))
} else {
false
@@ -600,13 +687,16 @@ async fn login_offline(
let account = core::auth::Account::Offline(core::auth::OfflineAccount { username, uuid });
*state.active_account.lock().unwrap() = Some(account.clone());
-
+
// Save to storage
let app_handle = window.app_handle();
- let app_dir = app_handle.path().app_data_dir().map_err(|e| e.to_string())?;
+ let app_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| e.to_string())?;
let storage = core::account_storage::AccountStorage::new(app_dir);
storage.add_or_update_account(&account, None)?;
-
+
Ok(account)
}
@@ -618,23 +708,28 @@ async fn get_active_account(
}
#[tauri::command]
-async fn logout(
- window: Window,
- state: State<'_, core::auth::AccountState>,
-) -> Result<(), String> {
+async fn logout(window: Window, state: State<'_, core::auth::AccountState>) -> Result<(), String> {
// Get current account UUID before clearing
- let uuid = state.active_account.lock().unwrap().as_ref().map(|a| a.uuid());
-
+ let uuid = state
+ .active_account
+ .lock()
+ .unwrap()
+ .as_ref()
+ .map(|a| a.uuid());
+
*state.active_account.lock().unwrap() = None;
-
+
// Remove from storage
if let Some(uuid) = uuid {
let app_handle = window.app_handle();
- let app_dir = app_handle.path().app_data_dir().map_err(|e| e.to_string())?;
+ let app_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| e.to_string())?;
let storage = core::account_storage::AccountStorage::new(app_dir);
storage.remove_account(&uuid)?;
}
-
+
Ok(())
}
@@ -669,23 +764,23 @@ async fn complete_microsoft_login(
) -> Result<core::auth::Account, String> {
// 1. Poll (once) for token
let token_resp = core::auth::exchange_code_for_token(&device_code).await?;
-
+
// Store MS refresh token
let ms_refresh_token = token_resp.refresh_token.clone();
*ms_refresh_state.token.lock().unwrap() = ms_refresh_token.clone();
-
+
// 2. Xbox Live Auth
let (xbl_token, uhs) = core::auth::method_xbox_live(&token_resp.access_token).await?;
-
+
// 3. XSTS Auth
let xsts_token = core::auth::method_xsts(&xbl_token).await?;
-
+
// 4. Minecraft Auth
let mc_token = core::auth::login_minecraft(&xsts_token, &uhs).await?;
-
+
// 5. Get Profile
let profile = core::auth::fetch_profile(&mc_token).await?;
-
+
// 6. Create Account
let account = core::auth::Account::Microsoft(core::auth::MicrosoftAccount {
username: profile.name,
@@ -695,18 +790,22 @@ async fn complete_microsoft_login(
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,
});
-
+
// 7. Save to state
*state.active_account.lock().unwrap() = Some(account.clone());
-
+
// 8. Save to storage
let app_handle = window.app_handle();
- let app_dir = app_handle.path().app_data_dir().map_err(|e| e.to_string())?;
+ let app_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| e.to_string())?;
let storage = core::account_storage::AccountStorage::new(app_dir);
storage.add_or_update_account(&account, ms_refresh_token)?;
-
+
Ok(account)
}
@@ -719,26 +818,29 @@ async fn refresh_account(
) -> Result<core::auth::Account, String> {
// Get stored MS refresh token
let app_handle = window.app_handle();
- let app_dir = app_handle.path().app_data_dir().map_err(|e| e.to_string())?;
+ let app_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| e.to_string())?;
let storage = core::account_storage::AccountStorage::new(app_dir.clone());
-
+
let (stored_account, ms_refresh) = storage
.get_active_account()
.ok_or("No active account found")?;
-
+
let ms_refresh_token = ms_refresh.ok_or("No refresh token available")?;
-
+
// Perform full refresh
let (new_account, new_ms_refresh) = core::auth::refresh_full_auth(&ms_refresh_token).await?;
let account = core::auth::Account::Microsoft(new_account);
-
+
// Update state
*state.active_account.lock().unwrap() = Some(account.clone());
*ms_refresh_state.token.lock().unwrap() = Some(new_ms_refresh.clone());
-
+
// Update storage
storage.add_or_update_account(&account, Some(new_ms_refresh))?;
-
+
Ok(account)
}
@@ -790,33 +892,178 @@ async fn fetch_available_java_versions() -> Result<Vec<u32>, String> {
core::java::fetch_available_versions().await
}
+/// Get Minecraft versions supported by Fabric
+#[tauri::command]
+async fn get_fabric_game_versions() -> Result<Vec<core::fabric::FabricGameVersion>, String> {
+ core::fabric::fetch_supported_game_versions()
+ .await
+ .map_err(|e| e.to_string())
+}
+
+/// Get available Fabric loader versions
+#[tauri::command]
+async fn get_fabric_loader_versions() -> Result<Vec<core::fabric::FabricLoaderVersion>, String> {
+ core::fabric::fetch_loader_versions()
+ .await
+ .map_err(|e| e.to_string())
+}
+
+/// Get Fabric loaders available for a specific Minecraft version
+#[tauri::command]
+async fn get_fabric_loaders_for_version(
+ game_version: String,
+) -> Result<Vec<core::fabric::FabricLoaderEntry>, String> {
+ core::fabric::fetch_loaders_for_game_version(&game_version)
+ .await
+ .map_err(|e| e.to_string())
+}
+
+/// Install Fabric loader for a specific Minecraft version
+#[tauri::command]
+async fn install_fabric(
+ window: Window,
+ game_version: String,
+ loader_version: String,
+) -> Result<core::fabric::InstalledFabricVersion, String> {
+ emit_log!(
+ window,
+ format!(
+ "Installing Fabric {} for Minecraft {}...",
+ loader_version, game_version
+ )
+ );
+
+ let app_handle = window.app_handle();
+ let game_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("Failed to get app data dir: {}", e))?;
+
+ let result = core::fabric::install_fabric(&game_dir, &game_version, &loader_version)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ emit_log!(
+ window,
+ format!("Fabric installed successfully: {}", result.id)
+ );
+
+ Ok(result)
+}
+
+/// List installed Fabric versions
+#[tauri::command]
+async fn list_installed_fabric_versions(window: Window) -> Result<Vec<String>, String> {
+ let app_handle = window.app_handle();
+ let game_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("Failed to get app data dir: {}", e))?;
+
+ core::fabric::list_installed_fabric_versions(&game_dir)
+ .await
+ .map_err(|e| e.to_string())
+}
+
+/// Check if Fabric is installed for a specific version
+#[tauri::command]
+async fn is_fabric_installed(
+ window: Window,
+ game_version: String,
+ loader_version: String,
+) -> Result<bool, String> {
+ let app_handle = window.app_handle();
+ let game_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("Failed to get app data dir: {}", e))?;
+
+ Ok(core::fabric::is_fabric_installed(
+ &game_dir,
+ &game_version,
+ &loader_version,
+ ))
+}
+
+/// Get Minecraft versions supported by Forge
+#[tauri::command]
+async fn get_forge_game_versions() -> Result<Vec<String>, String> {
+ core::forge::fetch_supported_game_versions()
+ .await
+ .map_err(|e| e.to_string())
+}
+
+/// Get available Forge versions for a specific Minecraft version
+#[tauri::command]
+async fn get_forge_versions_for_game(
+ game_version: String,
+) -> Result<Vec<core::forge::ForgeVersion>, String> {
+ core::forge::fetch_forge_versions(&game_version)
+ .await
+ .map_err(|e| e.to_string())
+}
+
+/// Install Forge for a specific Minecraft version
+#[tauri::command]
+async fn install_forge(
+ window: Window,
+ game_version: String,
+ forge_version: String,
+) -> Result<core::forge::InstalledForgeVersion, String> {
+ emit_log!(
+ window,
+ format!(
+ "Installing Forge {} for Minecraft {}...",
+ forge_version, game_version
+ )
+ );
+
+ let app_handle = window.app_handle();
+ let game_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("Failed to get app data dir: {}", e))?;
+
+ let result = core::forge::install_forge(&game_dir, &game_version, &forge_version)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ emit_log!(
+ window,
+ format!("Forge installed successfully: {}", result.id)
+ );
+
+ Ok(result)
+}
+
fn main() {
tauri::Builder::default()
+ .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.manage(core::auth::AccountState::new())
.manage(MsRefreshTokenState::new())
.setup(|app| {
let config_state = core::config::ConfigState::new(app.handle());
app.manage(config_state);
-
+
// Load saved account on startup
let app_dir = app.path().app_data_dir().unwrap();
let storage = core::account_storage::AccountStorage::new(app_dir);
-
+
if let Some((stored_account, ms_refresh)) = storage.get_active_account() {
let account = stored_account.to_account();
let auth_state: State<core::auth::AccountState> = app.state();
*auth_state.active_account.lock().unwrap() = Some(account);
-
+
// Store MS refresh token
if let Some(token) = ms_refresh {
let ms_state: State<MsRefreshTokenState> = app.state();
*ms_state.token.lock().unwrap() = Some(token);
}
-
+
println!("[Startup] Loaded saved account");
}
-
+
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -834,7 +1081,18 @@ fn main() {
get_recommended_java,
fetch_adoptium_java,
download_adoptium_java,
- fetch_available_java_versions
+ fetch_available_java_versions,
+ // Fabric commands
+ get_fabric_game_versions,
+ get_fabric_loader_versions,
+ get_fabric_loaders_for_version,
+ install_fabric,
+ list_installed_fabric_versions,
+ is_fabric_installed,
+ // Forge commands
+ get_forge_game_versions,
+ get_forge_versions_for_game,
+ install_forge
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 7d2b0a3..9a395fa 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,6 +1,6 @@
{
"productName": "dropout",
- "version": "0.1.13",
+ "version": "0.1.19",
"identifier": "com.dropout.launcher",
"build": {
"beforeDevCommand": "cd ../ui && pnpm dev",
@@ -14,8 +14,8 @@
"title": "Minecraft DropOut Launcher",
"width": 1024,
"height": 768,
- "minWidth": 800,
- "minHeight": 600,
+ "minWidth": 905,
+ "minHeight": 575,
"resizable": true
}
],
@@ -33,6 +33,11 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
- ]
+ ],
+ "linux": {
+ "appimage": {
+ "bundleMediaFramework": false
+ }
+ }
}
} \ No newline at end of file