diff options
| author | 2026-01-15 18:17:45 +0800 | |
|---|---|---|
| committer | 2026-01-15 18:17:45 +0800 | |
| commit | 20cd97d8b3af67050fbe7b5f8d6d5fb1c1f3237b (patch) | |
| tree | f90db0c71e713d5a176ef256dd145d6a17b04867 | |
| parent | aaa81ccb05362333e512b2609e9d86f11f5457eb (diff) | |
| download | DropOut-20cd97d8b3af67050fbe7b5f8d6d5fb1c1f3237b.tar.gz DropOut-20cd97d8b3af67050fbe7b5f8d6d5fb1c1f3237b.zip | |
feat: Add version installation and check functionality to enhance mod loader support in the application
| -rw-r--r-- | src-tauri/src/core/fabric.rs | 27 | ||||
| -rw-r--r-- | src-tauri/src/main.rs | 290 | ||||
| -rw-r--r-- | ui/src/components/ModLoaderSelector.svelte | 149 |
3 files changed, 439 insertions, 27 deletions
diff --git a/src-tauri/src/core/fabric.rs b/src-tauri/src/core/fabric.rs index fd38f41..3e4d50d 100644 --- a/src-tauri/src/core/fabric.rs +++ b/src-tauri/src/core/fabric.rs @@ -63,10 +63,31 @@ pub struct FabricLibrary { } /// Main class configuration for Fabric. +/// Can be either a struct with client/server fields or a simple string. #[derive(Debug, Deserialize, Serialize, Clone)] -pub struct FabricMainClass { - pub client: String, - pub server: String, +#[serde(untagged)] +pub enum FabricMainClass { + Structured { + client: String, + server: String, + }, + Simple(String), +} + +impl FabricMainClass { + pub fn client(&self) -> &str { + match self { + FabricMainClass::Structured { client, .. } => client, + FabricMainClass::Simple(s) => s, + } + } + + pub fn server(&self) -> &str { + match self { + FabricMainClass::Structured { server, .. } => server, + FabricMainClass::Simple(s) => s, + } + } } /// Represents a Minecraft version supported by Fabric. diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b69912e..24f3ce3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -680,6 +680,279 @@ async fn get_versions() -> Result<Vec<core::manifest::Version>, String> { } } +/// Check if a version is installed (has client.jar) +#[tauri::command] +async fn check_version_installed(window: Window, version_id: 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))?; + + // For modded versions, check the parent vanilla version + let minecraft_version = if version_id.starts_with("fabric-loader-") { + // Format: fabric-loader-X.X.X-1.20.4 + version_id.split('-').last().unwrap_or(&version_id).to_string() + } else if version_id.contains("-forge-") { + // Format: 1.20.4-forge-49.0.38 + version_id.split("-forge-").next().unwrap_or(&version_id).to_string() + } else { + version_id.clone() + }; + + let client_jar = game_dir + .join("versions") + .join(&minecraft_version) + .join(format!("{}.jar", minecraft_version)); + + Ok(client_jar.exists()) +} + +/// Install a version (download client, libraries, assets) without launching +#[tauri::command] +async fn install_version( + window: Window, + config_state: State<'_, core::config::ConfigState>, + version_id: String, +) -> Result<(), String> { + emit_log!( + window, + format!("Starting installation for version: {}", version_id) + ); + + let config = config_state.config.lock().unwrap().clone(); + 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))?; + + // Ensure game directory exists + tokio::fs::create_dir_all(&game_dir) + .await + .map_err(|e| e.to_string())?; + + emit_log!(window, format!("Game directory: {:?}", game_dir)); + + // 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 + ) + ); + + // Determine the actual minecraft version for client.jar + let minecraft_version = version_details + .inherits_from + .clone() + .unwrap_or_else(|| version_id.clone()); + + // Prepare download tasks + emit_log!(window, "Preparing download tasks...".to_string()); + let mut download_tasks = Vec::new(); + + // --- Client Jar --- + 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(&minecraft_version); + client_path.push(format!("{}.jar", minecraft_version)); + + download_tasks.push(core::downloader::DownloadTask { + url: client_jar.url.clone(), + path: client_path.clone(), + sha1: client_jar.sha1.clone(), + sha256: None, + }); + + // --- Libraries --- + let libraries_dir = game_dir.join("libraries"); + + for lib in &version_details.libraries { + if core::rules::is_library_allowed(&lib.rules) { + if let Some(downloads) = &lib.downloads { + if let Some(artifact) = &downloads.artifact { + let path_str = artifact + .path + .clone() + .unwrap_or_else(|| format!("{}.jar", lib.name)); + + let mut lib_path = libraries_dir.clone(); + lib_path.push(path_str); + + download_tasks.push(core::downloader::DownloadTask { + url: artifact.url.clone(), + path: lib_path, + sha1: artifact.sha1.clone(), + sha256: None, + }); + } + + // Native Library (classifiers) + if let Some(classifiers) = &downloads.classifiers { + let os_key = if cfg!(target_os = "linux") { + "natives-linux" + } else if cfg!(target_os = "windows") { + "natives-windows" + } else if cfg!(target_os = "macos") { + "natives-osx" + } else { + "" + }; + + if let Some(native_artifact_value) = classifiers.get(os_key) { + if let Ok(native_artifact) = + serde_json::from_value::<core::game_version::DownloadArtifact>( + native_artifact_value.clone(), + ) + { + let path_str = native_artifact.path.clone().unwrap(); + let mut native_path = libraries_dir.clone(); + native_path.push(&path_str); + + download_tasks.push(core::downloader::DownloadTask { + url: native_artifact.url, + path: native_path.clone(), + sha1: native_artifact.sha1, + sha256: None, + }); + } + } + } + } else { + // Library without explicit downloads (mod loader libraries) + 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, + sha256: None, + }); + } + } + } + } + } + + // --- Assets --- + let assets_dir = game_dir.join("assets"); + let objects_dir = assets_dir.join("objects"); + let indexes_dir = assets_dir.join("indexes"); + + let asset_index = version_details + .asset_index + .as_ref() + .ok_or("Version has no asset index information")?; + + let asset_index_path = indexes_dir.join(format!("{}.json", asset_index.id)); + + let asset_index_content: String = if asset_index_path.exists() { + tokio::fs::read_to_string(&asset_index_path) + .await + .map_err(|e| e.to_string())? + } else { + emit_log!(window, format!("Downloading asset index...")); + let content = reqwest::get(&asset_index.url) + .await + .map_err(|e| e.to_string())? + .text() + .await + .map_err(|e| e.to_string())?; + + tokio::fs::create_dir_all(&indexes_dir) + .await + .map_err(|e| e.to_string())?; + tokio::fs::write(&asset_index_path, &content) + .await + .map_err(|e| e.to_string())?; + content + }; + + #[derive(serde::Deserialize)] + struct AssetObject { + hash: String, + } + + #[derive(serde::Deserialize)] + struct AssetIndexJson { + objects: std::collections::HashMap<String, AssetObject>, + } + + let asset_index_parsed: AssetIndexJson = + serde_json::from_str(&asset_index_content).map_err(|e| e.to_string())?; + + emit_log!( + window, + format!("Processing {} assets...", asset_index_parsed.objects.len()) + ); + + for (_name, object) in asset_index_parsed.objects { + let hash = object.hash; + let prefix = &hash[0..2]; + let path = objects_dir.join(prefix).join(&hash); + let url = format!( + "https://resources.download.minecraft.net/{}/{}", + prefix, hash + ); + + download_tasks.push(core::downloader::DownloadTask { + url, + path, + sha1: Some(hash), + sha256: None, + }); + } + + emit_log!( + window, + format!( + "Total download tasks: {} (Client + Libraries + Assets)", + download_tasks.len() + ) + ); + + // Start Download + 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, + format!("Installation of {} completed successfully!", version_id) + ); + + Ok(()) +} + #[tauri::command] async fn login_offline( window: Window, @@ -765,24 +1038,39 @@ async fn complete_microsoft_login( ms_refresh_state: State<'_, MsRefreshTokenState>, device_code: String, ) -> Result<core::auth::Account, String> { + // Helper to emit auth progress + let emit_progress = |step: &str| { + let _ = window.emit("auth-progress", step); + }; + // 1. Poll (once) for token + emit_progress("Receiving token from Microsoft..."); let token_resp = core::auth::exchange_code_for_token(&device_code).await?; + emit_progress("Token received successfully!"); // 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 + emit_progress("Authenticating with Xbox Live..."); let (xbl_token, uhs) = core::auth::method_xbox_live(&token_resp.access_token).await?; + emit_progress("Xbox Live authentication successful!"); // 3. XSTS Auth + emit_progress("Authenticating with XSTS..."); let xsts_token = core::auth::method_xsts(&xbl_token).await?; + emit_progress("XSTS authentication successful!"); // 4. Minecraft Auth + emit_progress("Authenticating with Minecraft..."); let mc_token = core::auth::login_minecraft(&xsts_token, &uhs).await?; + emit_progress("Minecraft authentication successful!"); // 5. Get Profile + emit_progress("Fetching Minecraft profile..."); let profile = core::auth::fetch_profile(&mc_token).await?; + emit_progress(&format!("Welcome, {}!", profile.name)); // 6. Create Account let account = core::auth::Account::Microsoft(core::auth::MicrosoftAccount { @@ -1241,6 +1529,8 @@ fn main() { .invoke_handler(tauri::generate_handler![ start_game, get_versions, + check_version_installed, + install_version, login_offline, get_active_account, logout, diff --git a/ui/src/components/ModLoaderSelector.svelte b/ui/src/components/ModLoaderSelector.svelte index cb949c5..e9d147b 100644 --- a/ui/src/components/ModLoaderSelector.svelte +++ b/ui/src/components/ModLoaderSelector.svelte @@ -1,12 +1,14 @@ <script lang="ts"> import { invoke } from "@tauri-apps/api/core"; + import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import type { FabricGameVersion, FabricLoaderVersion, ForgeVersion, ModLoaderType, } from "../types"; - import { Loader2, Download, AlertCircle, Check, ChevronDown } from 'lucide-svelte'; + import { Loader2, Download, AlertCircle, Check, ChevronDown, CheckCircle } from 'lucide-svelte'; + import { logsState } from "../stores/logs.svelte"; interface Props { selectedGameVersion: string; @@ -18,7 +20,9 @@ // State let selectedLoader = $state<ModLoaderType>("vanilla"); let isLoading = $state(false); + let isInstalling = $state(false); let error = $state<string | null>(null); + let isVersionInstalled = $state(false); // Fabric state let fabricLoaders = $state<FabricLoaderVersion[]>([]); @@ -33,13 +37,35 @@ let fabricDropdownRef = $state<HTMLDivElement | null>(null); let forgeDropdownRef = $state<HTMLDivElement | null>(null); - // Load mod loader versions when game version changes + // Check if version is installed when game version changes + $effect(() => { + if (selectedGameVersion) { + checkInstallStatus(); + } + }); + + // Load mod loader versions when game version or loader type changes $effect(() => { if (selectedGameVersion && selectedLoader !== "vanilla") { loadModLoaderVersions(); } }); + async function checkInstallStatus() { + if (!selectedGameVersion) { + isVersionInstalled = false; + return; + } + try { + isVersionInstalled = await invoke<boolean>("check_version_installed", { + versionId: selectedGameVersion, + }); + } catch (e) { + console.error("Failed to check install status:", e); + isVersionInstalled = false; + } + } + async function loadModLoaderVersions() { isLoading = true; error = null; @@ -51,7 +77,6 @@ }); fabricLoaders = loaders.map((l) => l.loader); if (fabricLoaders.length > 0) { - // Select first stable version or first available const stable = fabricLoaders.find((l) => l.stable); selectedFabricLoader = stable?.version || fabricLoaders[0].version; } @@ -63,7 +88,6 @@ } ); if (forgeVersions.length > 0) { - // Select recommended version first, then latest const recommended = forgeVersions.find((v) => v.recommended); const latest = forgeVersions.find((v) => v.latest); selectedForgeVersion = @@ -78,34 +102,75 @@ } } + async function installVanilla() { + if (!selectedGameVersion) { + error = "Please select a Minecraft version first"; + return; + } + + isInstalling = true; + error = null; + logsState.addLog("info", "Installer", `Starting installation of ${selectedGameVersion}...`); + + try { + await invoke("install_version", { + versionId: selectedGameVersion, + }); + logsState.addLog("info", "Installer", `Successfully installed ${selectedGameVersion}`); + isVersionInstalled = true; + onInstall(selectedGameVersion); + } catch (e) { + error = `Failed to install: ${e}`; + logsState.addLog("error", "Installer", `Installation failed: ${e}`); + console.error(e); + } finally { + isInstalling = false; + } + } + async function installModLoader() { if (!selectedGameVersion) { error = "Please select a Minecraft version first"; return; } - isLoading = true; + isInstalling = true; error = null; try { + // First install the base game if not installed + if (!isVersionInstalled) { + logsState.addLog("info", "Installer", `Installing base game ${selectedGameVersion} first...`); + await invoke("install_version", { + versionId: selectedGameVersion, + }); + isVersionInstalled = true; + } + + // Then install the mod loader if (selectedLoader === "fabric" && selectedFabricLoader) { + logsState.addLog("info", "Installer", `Installing Fabric ${selectedFabricLoader} for ${selectedGameVersion}...`); const result = await invoke<any>("install_fabric", { gameVersion: selectedGameVersion, loaderVersion: selectedFabricLoader, }); + logsState.addLog("info", "Installer", `Fabric installed successfully: ${result.id}`); onInstall(result.id); } else if (selectedLoader === "forge" && selectedForgeVersion) { + logsState.addLog("info", "Installer", `Installing Forge ${selectedForgeVersion} for ${selectedGameVersion}...`); const result = await invoke<any>("install_forge", { gameVersion: selectedGameVersion, forgeVersion: selectedForgeVersion, }); + logsState.addLog("info", "Installer", `Forge installed successfully: ${result.id}`); onInstall(result.id); } } catch (e) { error = `Failed to install ${selectedLoader}: ${e}`; + logsState.addLog("error", "Installer", `Installation failed: ${e}`); console.error(e); } finally { - isLoading = false; + isInstalling = false; } } @@ -170,6 +235,7 @@ ? 'bg-white dark:bg-white/10 text-black dark:text-white shadow-sm' : 'text-zinc-500 dark:text-zinc-500 hover:text-black dark:hover:text-white'}" onclick={() => onLoaderChange(loader as ModLoaderType)} + disabled={isInstalling} > {loader} </button> @@ -178,15 +244,38 @@ <!-- Content Area --> <div class="min-h-[100px] flex flex-col justify-center"> - {#if selectedLoader === "vanilla"} - <div class="text-center p-6 border border-dashed border-zinc-200 dark:border-white/10 rounded-sm text-zinc-500 text-sm"> - Standard Minecraft experience. No modifications. - </div> - - {:else if !selectedGameVersion} + {#if !selectedGameVersion} <div class="flex items-center gap-3 p-4 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 text-amber-700 dark:text-amber-200 rounded-sm text-sm"> <AlertCircle size={16} /> - <span>Please select a base Minecraft version first.</span> + <span>Please select a Minecraft version first.</span> + </div> + + {:else if selectedLoader === "vanilla"} + <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300"> + <div class="text-center p-4 border border-dashed border-zinc-200 dark:border-white/10 rounded-sm text-zinc-500 text-sm"> + Standard Minecraft experience. No modifications. + </div> + + {#if isVersionInstalled} + <div class="flex items-center justify-center gap-2 p-3 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 text-emerald-700 dark:text-emerald-300 rounded-sm text-sm"> + <CheckCircle size={16} /> + <span>Version {selectedGameVersion} is installed</span> + </div> + {:else} + <button + class="w-full bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2" + onclick={installVanilla} + disabled={isInstalling} + > + {#if isInstalling} + <Loader2 class="animate-spin" size={16} /> + Installing... + {:else} + <Download size={16} /> + Install {selectedGameVersion} + {/if} + </button> + {/if} </div> {:else if isLoading} @@ -211,12 +300,13 @@ <button type="button" onclick={() => isFabricDropdownOpen = !isFabricDropdownOpen} + disabled={isInstalling} class="w-full flex items-center justify-between gap-2 px-4 py-2.5 text-left bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm text-gray-900 dark:text-white hover:border-zinc-400 dark:hover:border-zinc-600 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 - transition-colors cursor-pointer outline-none" + transition-colors cursor-pointer outline-none disabled:opacity-50" > <span class="truncate">{selectedFabricLabel}</span> <ChevronDown @@ -252,12 +342,17 @@ </div> <button - class="w-full bg-black dark:bg-white text-white dark:text-black py-2.5 px-4 rounded-sm font-bold text-sm transition-all hover:opacity-90 flex items-center justify-center gap-2" + class="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2" onclick={installModLoader} - disabled={isLoading || !selectedFabricLoader} + disabled={isInstalling || !selectedFabricLoader} > - <Download size={16} /> - Install Fabric + {#if isInstalling} + <Loader2 class="animate-spin" size={16} /> + Installing... + {:else} + <Download size={16} /> + Install Fabric {selectedFabricLoader} + {/if} </button> </div> @@ -277,12 +372,13 @@ <button type="button" onclick={() => isForgeDropdownOpen = !isForgeDropdownOpen} + disabled={isInstalling} class="w-full flex items-center justify-between gap-2 px-4 py-2.5 text-left bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm text-gray-900 dark:text-white hover:border-zinc-400 dark:hover:border-zinc-600 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 - transition-colors cursor-pointer outline-none" + transition-colors cursor-pointer outline-none disabled:opacity-50" > <span class="truncate">{selectedForgeLabel}</span> <ChevronDown @@ -318,12 +414,17 @@ </div> <button - class="w-full bg-black dark:bg-white text-white dark:text-black py-2.5 px-4 rounded-sm font-bold text-sm transition-all hover:opacity-90 flex items-center justify-center gap-2" - onclick={installModLoader} - disabled={isLoading || !selectedForgeVersion} + class="w-full bg-orange-600 hover:bg-orange-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2" + onclick={installModLoader} + disabled={isInstalling || !selectedForgeVersion} > - <Download size={16} /> - Install Forge + {#if isInstalling} + <Loader2 class="animate-spin" size={16} /> + Installing... + {:else} + <Download size={16} /> + Install Forge {selectedForgeVersion} + {/if} </button> {/if} </div> |