summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorHsiangNianian <i@jyunko.cn>2026-01-15 18:17:45 +0800
committerHsiangNianian <i@jyunko.cn>2026-01-15 18:17:45 +0800
commit20cd97d8b3af67050fbe7b5f8d6d5fb1c1f3237b (patch)
treef90db0c71e713d5a176ef256dd145d6a17b04867
parentaaa81ccb05362333e512b2609e9d86f11f5457eb (diff)
downloadDropOut-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.rs27
-rw-r--r--src-tauri/src/main.rs290
-rw-r--r--ui/src/components/ModLoaderSelector.svelte149
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>