diff options
| author | 2026-01-14 22:05:25 +0100 | |
|---|---|---|
| committer | 2026-01-14 22:05:25 +0100 | |
| commit | b473aa744e1382e946a92a116707b93151558888 (patch) | |
| tree | a8957a732caac948412c78ac7a443771f7ee12d0 /src-tauri | |
| parent | 2cb21f2bbc601ae134095cf0e68b5bcc6966d227 (diff) | |
| parent | 18111ef323a81e399e3b907c9046170afcb8e0eb (diff) | |
| download | DropOut-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.toml | 3 | ||||
| -rw-r--r-- | src-tauri/capabilities/default.json | 3 | ||||
| -rwxr-xr-x | src-tauri/scripts/fix-appimage.sh | 32 | ||||
| -rw-r--r-- | src-tauri/src/core/account_storage.rs | 29 | ||||
| -rw-r--r-- | src-tauri/src/core/auth.rs | 36 | ||||
| -rw-r--r-- | src-tauri/src/core/config.rs | 13 | ||||
| -rw-r--r-- | src-tauri/src/core/downloader.rs | 164 | ||||
| -rw-r--r-- | src-tauri/src/core/fabric.rs | 274 | ||||
| -rw-r--r-- | src-tauri/src/core/forge.rs | 336 | ||||
| -rw-r--r-- | src-tauri/src/core/game_version.rs | 47 | ||||
| -rw-r--r-- | src-tauri/src/core/java.rs | 24 | ||||
| -rw-r--r-- | src-tauri/src/core/manifest.rs | 154 | ||||
| -rw-r--r-- | src-tauri/src/core/maven.rs | 263 | ||||
| -rw-r--r-- | src-tauri/src/core/mod.rs | 4 | ||||
| -rw-r--r-- | src-tauri/src/core/rules.rs | 7 | ||||
| -rw-r--r-- | src-tauri/src/core/version_merge.rs | 244 | ||||
| -rw-r--r-- | src-tauri/src/main.rs | 460 | ||||
| -rw-r--r-- | src-tauri/tauri.conf.json | 13 |
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(¶ms).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(¶ms).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(¶ms).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 |