diff options
| author | 2026-01-16 18:38:47 +0800 | |
|---|---|---|
| committer | 2026-01-16 18:42:12 +0800 | |
| commit | 1119f6c3cf421da2f2db92873efae8135c76b678 (patch) | |
| tree | 57ee487c5003807e984c084a31a37c34e30820cf | |
| parent | 52cf610264444bde95c1feae58414bb9849855eb (diff) | |
| download | DropOut-1119f6c3cf421da2f2db92873efae8135c76b678.tar.gz DropOut-1119f6c3cf421da2f2db92873efae8135c76b678.zip | |
feat: enhance Java version management for Minecraft versions
Added functionality to determine and validate the required Java version for Minecraft versions, including checks for compatibility with older versions. Implemented event emissions for version installation and deletion, and updated the UI to reflect Java version requirements and installation status. Improved version metadata handling and added support for deleting versions.
| -rw-r--r-- | src-tauri/Cargo.toml | 1 | ||||
| -rw-r--r-- | src-tauri/src/core/forge.rs | 116 | ||||
| -rw-r--r-- | src-tauri/src/core/java.rs | 58 | ||||
| -rw-r--r-- | src-tauri/src/core/manifest.rs | 7 | ||||
| -rw-r--r-- | src-tauri/src/main.rs | 383 | ||||
| -rw-r--r-- | ui/src/components/BottomBar.svelte | 56 | ||||
| -rw-r--r-- | ui/src/components/ModLoaderSelector.svelte | 38 | ||||
| -rw-r--r-- | ui/src/components/VersionsView.svelte | 325 | ||||
| -rw-r--r-- | ui/src/stores/game.svelte.ts | 6 | ||||
| -rw-r--r-- | ui/src/types/index.ts | 2 |
10 files changed, 854 insertions, 138 deletions
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9fe91b7..407da5a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ dirs = "5.0" serde_urlencoded = "0.7.1" tauri-plugin-dialog = "2.6.0" tauri-plugin-fs = "2.4.5" +bytes = "1.11.0" [build-dependencies] tauri-build = { version = "2.0", features = [] } diff --git a/src-tauri/src/core/forge.rs b/src-tauri/src/core/forge.rs index 0528b76..65bf413 100644 --- a/src-tauri/src/core/forge.rs +++ b/src-tauri/src/core/forge.rs @@ -16,6 +16,7 @@ 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/"; +const FORGE_FILES_URL: &str = "https://files.minecraftforge.net/"; /// Represents a Forge version entry. #[derive(Debug, Deserialize, Serialize, Clone)] @@ -180,27 +181,93 @@ pub fn generate_version_id(game_version: &str, forge_version: &str) -> String { format!("{}-forge-{}", game_version, forge_version) } -/// Fetch the Forge installer manifest to get the library list -async fn fetch_forge_installer_manifest( +/// Try to download the Forge installer from multiple possible URL formats. +/// This is necessary because older Forge versions use different URL patterns. +async fn try_download_forge_installer( game_version: &str, forge_version: &str, -) -> Result<ForgeInstallerManifest, Box<dyn Error + Send + Sync>> { +) -> Result<bytes::Bytes, Box<dyn Error + Send + Sync>> { let forge_full = format!("{}-{}", game_version, forge_version); - - // Download the installer JAR to extract version.json - let installer_url = format!( - "{}net/minecraftforge/forge/{}/forge-{}-installer.jar", - FORGE_MAVEN_URL, forge_full, forge_full - ); - - println!("Fetching Forge installer from: {}", installer_url); - - let response = reqwest::get(&installer_url).await?; - if !response.status().is_success() { - return Err(format!("Failed to download Forge installer: {}", response.status()).into()); + // For older versions (like 1.7.10), the URL needs an additional -{game_version} suffix + let forge_full_with_suffix = format!("{}-{}", forge_full, game_version); + + // Try different URL formats for different Forge versions + // Order matters: try most common formats first, then fallback to alternatives + let url_patterns = vec![ + // Standard Maven format (for modern versions): forge/{game_version}-{forge_version}/forge-{game_version}-{forge_version}-installer.jar + format!( + "{}net/minecraftforge/forge/{}/forge-{}-installer.jar", + FORGE_MAVEN_URL, forge_full, forge_full + ), + // Old version format with suffix (for versions like 1.7.10): forge/{game_version}-{forge_version}-{game_version}/forge-{game_version}-{forge_version}-{game_version}-installer.jar + // This is the correct format for 1.7.10 and similar old versions + format!( + "{}net/minecraftforge/forge/{}/forge-{}-installer.jar", + FORGE_MAVEN_URL, forge_full_with_suffix, forge_full_with_suffix + ), + // Files.minecraftforge.net format with suffix (for old versions like 1.7.10) + format!( + "{}maven/net/minecraftforge/forge/{}/forge-{}-installer.jar", + FORGE_FILES_URL, forge_full_with_suffix, forge_full_with_suffix + ), + // Files.minecraftforge.net standard format (for older versions) + format!( + "{}maven/net/minecraftforge/forge/{}/forge-{}-installer.jar", + FORGE_FILES_URL, forge_full, forge_full + ), + // Alternative Maven format + format!( + "{}net/minecraftforge/forge/{}-{}/forge-{}-{}-installer.jar", + FORGE_MAVEN_URL, game_version, forge_version, game_version, forge_version + ), + // Alternative files format + format!( + "{}maven/net/minecraftforge/forge/{}-{}/forge-{}-{}-installer.jar", + FORGE_FILES_URL, game_version, forge_version, game_version, forge_version + ), + ]; + + let mut last_error = None; + for url in url_patterns { + println!("Trying Forge installer URL: {}", url); + match reqwest::get(&url).await { + Ok(response) => { + if response.status().is_success() { + match response.bytes().await { + Ok(bytes) => { + println!("Successfully downloaded Forge installer from: {}", url); + return Ok(bytes); + } + Err(e) => { + last_error = Some(format!("Failed to read response body: {}", e)); + continue; + } + } + } else { + last_error = Some(format!("HTTP {}: {}", response.status(), url)); + continue; + } + } + Err(e) => { + last_error = Some(format!("Request failed: {}", e)); + continue; + } + } } - let bytes = response.bytes().await?; + Err(format!( + "Failed to download Forge installer from any URL. Last error: {}", + last_error.unwrap_or_else(|| "Unknown error".to_string()) + ) + .into()) +} + +/// Fetch the Forge installer manifest to get the library list +async fn fetch_forge_installer_manifest( + game_version: &str, + forge_version: &str, +) -> Result<ForgeInstallerManifest, Box<dyn Error + Send + Sync>> { + let bytes = try_download_forge_installer(game_version, forge_version).await?; // Extract version.json from the JAR (which is a ZIP file) let cursor = std::io::Cursor::new(bytes.as_ref()); @@ -274,23 +341,10 @@ pub async fn run_forge_installer( forge_version: &str, java_path: &PathBuf, ) -> Result<(), Box<dyn Error + Send + Sync>> { - // Download the installer JAR - let installer_url = format!( - "{}net/minecraftforge/forge/{}-{}/forge-{}-{}-installer.jar", - FORGE_MAVEN_URL, game_version, forge_version, game_version, forge_version - ); - let installer_path = game_dir.join("forge-installer.jar"); - // Download installer - let client = reqwest::Client::new(); - let response = client.get(&installer_url).send().await?; - - if !response.status().is_success() { - return Err(format!("Failed to download Forge installer: {}", response.status()).into()); - } - - let bytes = response.bytes().await?; + // Download installer using the same multi-URL approach + let bytes = try_download_forge_installer(game_version, forge_version).await?; tokio::fs::write(&installer_path, &bytes).await?; // Run the installer in headless mode diff --git a/src-tauri/src/core/java.rs b/src-tauri/src/core/java.rs index 1d57d21..0c7769b 100644 --- a/src-tauri/src/core/java.rs +++ b/src-tauri/src/core/java.rs @@ -881,6 +881,64 @@ pub fn get_recommended_java(required_major_version: Option<u64>) -> Option<JavaI } } +/// Get compatible Java for a specific Minecraft version with upper bound +/// For older Minecraft versions (1.13.x and below), we need Java 8 specifically +/// as newer Java versions have compatibility issues with old Forge versions +pub fn get_compatible_java( + app_handle: &AppHandle, + required_major_version: Option<u64>, + max_major_version: Option<u32>, +) -> Option<JavaInstallation> { + let installations = detect_all_java_installations(app_handle); + + if let Some(max_version) = max_major_version { + // Find Java version within the acceptable range + installations.into_iter().find(|java| { + let major = parse_java_version(&java.version); + let meets_min = if let Some(required) = required_major_version { + major >= required as u32 + } else { + true + }; + meets_min && major <= max_version + }) + } else if let Some(required) = required_major_version { + // Find exact match or higher (no upper bound) + installations.into_iter().find(|java| { + let major = parse_java_version(&java.version); + major >= required as u32 + }) + } else { + // Return newest + installations.into_iter().next() + } +} + +/// Check if a Java installation is compatible with the required version range +pub fn is_java_compatible( + java_path: &str, + required_major_version: Option<u64>, + max_major_version: Option<u32>, +) -> bool { + let java_path_buf = PathBuf::from(java_path); + if let Some(java) = check_java_installation(&java_path_buf) { + let major = parse_java_version(&java.version); + let meets_min = if let Some(required) = required_major_version { + major >= required as u32 + } else { + true + }; + let meets_max = if let Some(max_version) = max_major_version { + major <= max_version + } else { + true + }; + meets_min && meets_max + } else { + false + } +} + /// Detect all installed Java versions (including system installations and DropOut downloads) pub fn detect_all_java_installations(app_handle: &AppHandle) -> Vec<JavaInstallation> { let mut installations = detect_java_installations(); diff --git a/src-tauri/src/core/manifest.rs b/src-tauri/src/core/manifest.rs index d92ae58..637b935 100644 --- a/src-tauri/src/core/manifest.rs +++ b/src-tauri/src/core/manifest.rs @@ -25,6 +25,13 @@ pub struct Version { pub time: String, #[serde(rename = "releaseTime")] pub release_time: String, + /// Java version requirement (major version number) + /// This is populated from the version JSON file if the version is installed locally + #[serde(skip_serializing_if = "Option::is_none")] + pub java_version: Option<u64>, + /// Whether this version is installed locally + #[serde(rename = "isInstalled", skip_serializing_if = "Option::is_none")] + pub is_installed: Option<bool>, } pub async fn fetch_version_manifest() -> Result<VersionManifest, Box<dyn Error + Send + Sync>> { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 463bd5d..661309a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -139,6 +139,121 @@ async fn start_game( // (for modded versions, this is the parent vanilla version) let minecraft_version = original_inherits_from.unwrap_or_else(|| version_id.clone()); + // Get required Java version from version file's javaVersion field + // The version file (after merging with parent) should contain the correct javaVersion + let required_java_major = version_details + .java_version + .as_ref() + .map(|jv| jv.major_version); + + // For older Minecraft versions (1.13.x and below), if javaVersion specifies Java 8, + // we should only allow Java 8 (not higher) due to compatibility issues with old Forge + // For newer versions, javaVersion.majorVersion is the minimum required version + let max_java_major = if let Some(required) = required_java_major { + // If version file specifies Java 8, enforce it as maximum (old versions need exactly Java 8) + // For Java 9+, allow that version or higher + if required <= 8 { + Some(8) + } else { + None // No upper bound for Java 9+ + } + } else { + // If version file doesn't specify javaVersion, this shouldn't happen for modern versions + // But if it does, we can't determine compatibility - log a warning + emit_log!( + window, + "Warning: Version file does not specify javaVersion. Using system default Java." + .to_string() + ); + None + }; + + // Check if configured Java is compatible + let mut java_path_to_use = config.java_path.clone(); + if !java_path_to_use.is_empty() && java_path_to_use != "java" { + let is_compatible = + core::java::is_java_compatible(&java_path_to_use, required_java_major, max_java_major); + + if !is_compatible { + emit_log!( + window, + format!( + "Configured Java version may not be compatible. Looking for compatible Java..." + ) + ); + + // Try to find a compatible Java version + if let Some(compatible_java) = + core::java::get_compatible_java(app_handle, required_java_major, max_java_major) + { + emit_log!( + window, + format!( + "Found compatible Java {} at: {}", + compatible_java.version, compatible_java.path + ) + ); + java_path_to_use = compatible_java.path; + } else { + let version_constraint = if let Some(max) = max_java_major { + if let Some(min) = required_java_major { + if min == max as u64 { + format!("Java {}", min) + } else { + format!("Java {} to {}", min, max) + } + } else { + format!("Java {} (or lower)", max) + } + } else if let Some(min) = required_java_major { + format!("Java {} or higher", min) + } else { + "any Java version".to_string() + }; + + return Err(format!( + "No compatible Java installation found. This version requires {}. Please install a compatible Java version in settings.", + version_constraint + )); + } + } + } else { + // No Java configured, try to find a compatible one + if let Some(compatible_java) = + core::java::get_compatible_java(app_handle, required_java_major, max_java_major) + { + emit_log!( + window, + format!( + "Using Java {} at: {}", + compatible_java.version, compatible_java.path + ) + ); + java_path_to_use = compatible_java.path; + } else { + let version_constraint = if let Some(max) = max_java_major { + if let Some(min) = required_java_major { + if min == max as u64 { + format!("Java {}", min) + } else { + format!("Java {} to {}", min, max) + } + } else { + format!("Java {} (or lower)", max) + } + } else if let Some(min) = required_java_major { + format!("Java {} or higher", min) + } else { + "any Java version".to_string() + }; + + return Err(format!( + "No compatible Java installation found. This version requires {}. Please install a compatible Java version in settings.", + version_constraint + )); + } + } + // 2. Prepare download tasks emit_log!(window, "Preparing download tasks...".to_string()); let mut download_tasks = Vec::new(); @@ -521,33 +636,67 @@ async fn start_game( 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!("Java Args: {:?}", &args)); - } - // Get Java path from config or detect - let java_path_str = if !config.java_path.is_empty() && config.java_path != "java" { - config.java_path.clone() - } else { - // Try to find a suitable Java installation - let javas = core::java::detect_all_java_installations(&app_handle); - if let Some(java) = javas.first() { - java.path.clone() - } else { - return Err( - "No Java installation found. Please configure Java in settings.".to_string(), - ); - } - }; - let java_path = utils::path::normalize_java_path(&java_path_str)?; + // Format Java command with sensitive information masked + let masked_args: Vec<String> = args + .iter() + .enumerate() + .map(|(i, arg)| { + // Check if previous argument was a sensitive flag + if i > 0 { + let prev_arg = &args[i - 1]; + if prev_arg == "--accessToken" || prev_arg == "--uuid" { + return "***".to_string(); + } + } + + // Mask sensitive argument values + if arg == "--accessToken" || arg == "--uuid" { + arg.clone() + } else if arg.starts_with("token:") { + // Mask token: prefix tokens (Session ID format) + "token:***".to_string() + } else if arg.len() > 100 + && arg.contains('.') + && !arg.contains('/') + && !arg.contains('\\') + && !arg.contains(':') + { + // Likely a JWT token (very long string with dots, no paths) + "***".to_string() + } else if arg.len() == 36 + && arg.contains('-') + && arg.chars().all(|c| c.is_ascii_hexdigit() || c == '-') + { + // Likely a UUID (36 chars with dashes) + "***".to_string() + } else { + arg.clone() + } + }) + .collect(); + + // Format as actual Java command (properly quote arguments with spaces) + let masked_args_str: Vec<String> = masked_args + .iter() + .map(|arg| { + if arg.contains(' ') { + format!("\"{}\"", arg) + } else { + arg.clone() + } + }) + .collect(); + + let java_command = format!("{} {}", java_path_to_use, masked_args_str.join(" ")); + emit_log!(window, format!("Java Command: {}", java_command)); // Spawn the process emit_log!( window, - format!("Starting Java process: {}", java_path.display()) + format!("Starting Java process: {}", java_path_to_use) ); - let mut command = Command::new(&java_path); + let mut command = Command::new(&java_path_to_use); command.args(&args); command.current_dir(&game_dir); // Run in game directory command.stdout(Stdio::piped()); @@ -567,7 +716,7 @@ async fn start_game( // Spawn and handle output let mut child = command .spawn() - .map_err(|e| format!("Failed to launch Java at '{}': {}\nPlease check your Java installation and path configuration in Settings.", java_path.display(), e))?; + .map_err(|e| format!("Failed to launch Java at '{}': {}\nPlease check your Java installation and path configuration in Settings.", java_path_to_use, e))?; emit_log!(window, "Java process started successfully".to_string()); @@ -699,9 +848,42 @@ fn parse_jvm_arguments( } #[tauri::command] -async fn get_versions() -> Result<Vec<core::manifest::Version>, String> { +async fn get_versions(window: Window) -> Result<Vec<core::manifest::Version>, 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))?; + match core::manifest::fetch_version_manifest().await { - Ok(manifest) => Ok(manifest.versions), + Ok(manifest) => { + let mut versions = manifest.versions; + + // For each version, try to load Java version info and check installation status + for version in &mut versions { + // Check if version is installed + let version_dir = game_dir.join("versions").join(&version.id); + let json_path = version_dir.join(format!("{}.json", version.id)); + let client_jar_path = version_dir.join(format!("{}.jar", version.id)); + + // Version is installed if both JSON and client jar exist + let is_installed = json_path.exists() && client_jar_path.exists(); + version.is_installed = Some(is_installed); + + // If installed, try to load the version JSON to get javaVersion + if is_installed { + if let Ok(game_version) = + core::manifest::load_local_version(&game_dir, &version.id).await + { + if let Some(java_ver) = game_version.java_version { + version.java_version = Some(java_ver.major_version); + } + } + } + } + + Ok(versions) + } Err(e) => Err(e.to_string()), } } @@ -1008,6 +1190,9 @@ async fn install_version( format!("Installation of {} completed successfully!", version_id) ); + // Emit event to notify frontend that version installation is complete + let _ = window.emit("version-installed", &version_id); + Ok(()) } @@ -1371,6 +1556,9 @@ async fn install_fabric( format!("Fabric installed successfully: {}", result.id) ); + // Emit event to notify frontend + let _ = window.emit("fabric-installed", &result.id); + Ok(result) } @@ -1388,6 +1576,147 @@ async fn list_installed_fabric_versions(window: Window) -> Result<Vec<String>, S .map_err(|e| e.to_string()) } +/// Get Java version requirement for a specific version +#[tauri::command] +async fn get_version_java_version( + window: Window, + version_id: String, +) -> Result<Option<u64>, 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))?; + + // Try to load the version JSON to get javaVersion + match core::manifest::load_version(&game_dir, &version_id).await { + Ok(game_version) => Ok(game_version.java_version.map(|jv| jv.major_version)), + Err(_) => Ok(None), // Version not found or can't be loaded + } +} + +/// Version metadata for display in the UI +#[derive(serde::Serialize)] +struct VersionMetadata { + id: String, + #[serde(rename = "javaVersion")] + java_version: Option<u64>, + #[serde(rename = "isInstalled")] + is_installed: bool, +} + +/// Delete a version (remove version directory) +#[tauri::command] +async fn delete_version(window: Window, version_id: String) -> Result<(), 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))?; + + let version_dir = game_dir.join("versions").join(&version_id); + + if !version_dir.exists() { + return Err(format!("Version {} not found", version_id)); + } + + // Remove the entire version directory + tokio::fs::remove_dir_all(&version_dir) + .await + .map_err(|e| format!("Failed to delete version: {}", e))?; + + // Emit event to notify frontend + let _ = window.emit("version-deleted", &version_id); + + Ok(()) +} + +/// Get detailed metadata for a specific version +#[tauri::command] +async fn get_version_metadata( + window: Window, + version_id: String, +) -> Result<VersionMetadata, 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))?; + + // Initialize metadata + let mut metadata = VersionMetadata { + id: version_id.clone(), + java_version: None, + is_installed: false, + }; + + // Check if version is in manifest and get Java version if available + if let Ok(manifest) = core::manifest::fetch_version_manifest().await { + if let Some(version_entry) = manifest.versions.iter().find(|v| v.id == version_id) { + // Note: version_entry.java_version is only set if version is installed locally + // For uninstalled versions, we'll fetch from remote below + if let Some(java_ver) = version_entry.java_version { + metadata.java_version = Some(java_ver); + } + } + } + + // Check if version is installed (both JSON and client jar must exist) + let version_dir = game_dir.join("versions").join(&version_id); + let json_path = version_dir.join(format!("{}.json", version_id)); + + // For modded versions, check the parent vanilla version's client jar + let client_jar_path = if version_id.starts_with("fabric-loader-") { + // Format: fabric-loader-X.X.X-1.20.4 + let minecraft_version = version_id + .split('-') + .next_back() + .unwrap_or(&version_id) + .to_string(); + game_dir + .join("versions") + .join(&minecraft_version) + .join(format!("{}.jar", minecraft_version)) + } else if version_id.contains("-forge-") { + // Format: 1.20.4-forge-49.0.38 + let minecraft_version = version_id + .split("-forge-") + .next() + .unwrap_or(&version_id) + .to_string(); + game_dir + .join("versions") + .join(&minecraft_version) + .join(format!("{}.jar", minecraft_version)) + } else { + version_dir.join(format!("{}.jar", version_id)) + }; + + metadata.is_installed = json_path.exists() && client_jar_path.exists(); + + // Try to get Java version - from local if installed, or from remote if not + if metadata.is_installed { + // If installed, load from local version JSON + if let Ok(game_version) = core::manifest::load_version(&game_dir, &version_id).await { + if let Some(java_ver) = game_version.java_version { + metadata.java_version = Some(java_ver.major_version); + } + } + } else if metadata.java_version.is_none() { + // If not installed and we don't have Java version yet, try to fetch from remote + // This is for vanilla versions that are not installed + if !version_id.starts_with("fabric-loader-") && !version_id.contains("-forge-") { + if let Ok(game_version) = core::manifest::fetch_vanilla_version(&version_id).await { + if let Some(java_ver) = game_version.java_version { + metadata.java_version = Some(java_ver.major_version); + } + } + } + } + + Ok(metadata) +} + /// Installed version info #[derive(serde::Serialize)] struct InstalledVersion { @@ -1580,6 +1909,9 @@ async fn install_forge( format!("Forge installed successfully: {}", result.id) ); + // Emit event to notify frontend + let _ = window.emit("forge-installed", &result.id); + Ok(result) } @@ -1802,6 +2134,9 @@ fn main() { check_version_installed, install_version, list_installed_versions, + get_version_java_version, + get_version_metadata, + delete_version, login_offline, get_active_account, logout, diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte index b7bbf71..8a6b7ff 100644 --- a/ui/src/components/BottomBar.svelte +++ b/ui/src/components/BottomBar.svelte @@ -4,7 +4,7 @@ import { authState } from "../stores/auth.svelte"; import { gameState } from "../stores/game.svelte"; import { uiState } from "../stores/ui.svelte"; - import { Terminal, ChevronDown, Play, User, Check, RefreshCw } from 'lucide-svelte'; + import { Terminal, ChevronDown, Play, User, Check } from 'lucide-svelte'; interface InstalledVersion { id: string; @@ -16,23 +16,31 @@ let installedVersions = $state<InstalledVersion[]>([]); let isLoadingVersions = $state(true); let downloadCompleteUnlisten: UnlistenFn | null = null; + let versionDeletedUnlisten: UnlistenFn | null = null; // Load installed versions on mount $effect(() => { loadInstalledVersions(); - setupDownloadListener(); + setupEventListeners(); return () => { if (downloadCompleteUnlisten) { downloadCompleteUnlisten(); } + if (versionDeletedUnlisten) { + versionDeletedUnlisten(); + } }; }); - async function setupDownloadListener() { + async function setupEventListeners() { // Refresh list when a download completes downloadCompleteUnlisten = await listen("download-complete", () => { loadInstalledVersions(); }); + // Refresh list when a version is deleted + versionDeletedUnlisten = await listen("version-deleted", () => { + loadInstalledVersions(); + }); } async function loadInstalledVersions() { @@ -160,18 +168,7 @@ <div class="flex flex-col items-end mr-2"> <!-- Custom Version Dropdown --> <div class="relative" bind:this={dropdownRef}> - <div class="flex items-center gap-2"> - <button - type="button" - onclick={() => loadInstalledVersions()} - class="p-2.5 dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md - dark:text-zinc-500 text-zinc-400 dark:hover:text-white hover:text-black - dark:hover:border-zinc-600 hover:border-zinc-400 transition-colors" - title="Refresh installed versions" - > - <RefreshCw size={14} class={isLoadingVersions ? 'animate-spin' : ''} /> - </button> - <button + <button type="button" onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen} disabled={installedVersions.length === 0 && !isLoadingVersions} @@ -183,21 +180,20 @@ transition-colors cursor-pointer outline-none disabled:opacity-50 disabled:cursor-not-allowed" > - <span class="truncate"> - {#if isLoadingVersions} - Loading... - {:else if installedVersions.length === 0} - No versions installed - {:else} - {gameState.selectedVersion || "Select version"} - {/if} - </span> - <ChevronDown - size={14} - class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}" - /> - </button> - </div> + <span class="truncate"> + {#if isLoadingVersions} + Loading... + {:else if installedVersions.length === 0} + No versions installed + {:else} + {gameState.selectedVersion || "Select version"} + {/if} + </span> + <ChevronDown + size={14} + class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}" + /> + </button> {#if isVersionDropdownOpen && installedVersions.length > 0} <div diff --git a/ui/src/components/ModLoaderSelector.svelte b/ui/src/components/ModLoaderSelector.svelte index e9d147b..34f6f2e 100644 --- a/ui/src/components/ModLoaderSelector.svelte +++ b/ui/src/components/ModLoaderSelector.svelte @@ -291,7 +291,12 @@ {:else if selectedLoader === "fabric"} <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300"> - <div> + {#if fabricLoaders.length === 0} + <div class="text-center p-4 text-sm text-zinc-500 italic"> + No Fabric versions available for {selectedGameVersion} + </div> + {:else} + <div> <label for="fabric-loader-select" class="block text-[10px] uppercase font-bold text-zinc-500 mb-2" >Loader Version</label > @@ -339,21 +344,22 @@ </div> {/if} </div> - </div> - - <button - 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={isInstalling || !selectedFabricLoader} - > - {#if isInstalling} - <Loader2 class="animate-spin" size={16} /> - Installing... - {:else} - <Download size={16} /> - Install Fabric {selectedFabricLoader} - {/if} - </button> + </div> + + <button + 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={isInstalling || !selectedFabricLoader} + > + {#if isInstalling} + <Loader2 class="animate-spin" size={16} /> + Installing... + {:else} + <Download size={16} /> + Install Fabric {selectedFabricLoader} + {/if} + </button> + {/if} </div> {:else if selectedLoader === "forge"} diff --git a/ui/src/components/VersionsView.svelte b/ui/src/components/VersionsView.svelte index 063c28d..2e8b028 100644 --- a/ui/src/components/VersionsView.svelte +++ b/ui/src/components/VersionsView.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import { invoke } from "@tauri-apps/api/core"; + import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { gameState } from "../stores/game.svelte"; import ModLoaderSelector from "./ModLoaderSelector.svelte"; @@ -9,40 +10,130 @@ ); // Filter by version type - let typeFilter = $state<"all" | "release" | "snapshot" | "modded">("all"); + let typeFilter = $state<"all" | "release" | "snapshot" | "installed">("all"); - // Installed modded versions - let installedFabricVersions = $state<string[]>([]); + // Installed modded versions with Java version info (Fabric + Forge) + let installedFabricVersions = $state<Array<{ id: string; javaVersion?: number }>>([]); let isLoadingModded = $state(false); - // Load installed modded versions + // Load installed modded versions with Java version info (both Fabric and Forge) async function loadInstalledModdedVersions() { isLoadingModded = true; try { - installedFabricVersions = await invoke<string[]>( - "list_installed_fabric_versions" + // Get all installed versions and filter for modded ones (Fabric and Forge) + const allInstalled = await invoke<Array<{ id: string; type: string }>>( + "list_installed_versions" ); + + // Filter for Fabric and Forge versions + const moddedIds = allInstalled + .filter(v => v.type === "fabric" || v.type === "forge") + .map(v => v.id); + + // Load Java version for each installed modded version + const versionsWithJava = await Promise.all( + moddedIds.map(async (id) => { + try { + const javaVersion = await invoke<number | null>( + "get_version_java_version", + { versionId: id } + ); + return { + id, + javaVersion: javaVersion ?? undefined, + }; + } catch (e) { + console.error(`Failed to get Java version for ${id}:`, e); + return { id, javaVersion: undefined }; + } + }) + ); + + installedFabricVersions = versionsWithJava; } catch (e) { - console.error("Failed to load installed fabric versions:", e); + console.error("Failed to load installed modded versions:", e); } finally { isLoadingModded = false; } } - // Load on mount + let versionDeletedUnlisten: UnlistenFn | null = null; + let downloadCompleteUnlisten: UnlistenFn | null = null; + let versionInstalledUnlisten: UnlistenFn | null = null; + let fabricInstalledUnlisten: UnlistenFn | null = null; + let forgeInstalledUnlisten: UnlistenFn | null = null; + + // Load on mount and setup event listeners $effect(() => { loadInstalledModdedVersions(); + setupEventListeners(); + return () => { + if (versionDeletedUnlisten) { + versionDeletedUnlisten(); + } + if (downloadCompleteUnlisten) { + downloadCompleteUnlisten(); + } + if (versionInstalledUnlisten) { + versionInstalledUnlisten(); + } + if (fabricInstalledUnlisten) { + fabricInstalledUnlisten(); + } + if (forgeInstalledUnlisten) { + forgeInstalledUnlisten(); + } + }; }); + async function setupEventListeners() { + // Refresh versions when a version is deleted + versionDeletedUnlisten = await listen("version-deleted", async () => { + await gameState.loadVersions(); + await loadInstalledModdedVersions(); + }); + + // Refresh versions when a download completes (version installed) + downloadCompleteUnlisten = await listen("download-complete", async () => { + await gameState.loadVersions(); + await loadInstalledModdedVersions(); + }); + + // Refresh when a version is installed + versionInstalledUnlisten = await listen("version-installed", async () => { + await gameState.loadVersions(); + await loadInstalledModdedVersions(); + }); + + // Refresh when Fabric is installed + fabricInstalledUnlisten = await listen("fabric-installed", async () => { + await gameState.loadVersions(); + await loadInstalledModdedVersions(); + }); + + // Refresh when Forge is installed + forgeInstalledUnlisten = await listen("forge-installed", async () => { + await gameState.loadVersions(); + await loadInstalledModdedVersions(); + }); + } + // Combined versions list (vanilla + modded) let allVersions = $derived(() => { - const moddedVersions = installedFabricVersions.map((id) => ({ - id, - type: "fabric", - url: "", - time: "", - releaseTime: new Date().toISOString(), - })); + const moddedVersions = installedFabricVersions.map((v) => { + // Determine type based on version ID + const versionType = v.id.startsWith("fabric-loader-") ? "fabric" : + v.id.includes("-forge-") ? "forge" : "fabric"; + return { + id: v.id, + type: versionType, + url: "", + time: "", + releaseTime: new Date().toISOString(), + javaVersion: v.javaVersion, + isInstalled: true, // Modded versions in the list are always installed + }; + }); return [...moddedVersions, ...gameState.versions]; }); @@ -54,10 +145,8 @@ versions = versions.filter((v) => v.type === "release"); } else if (typeFilter === "snapshot") { versions = versions.filter((v) => v.type === "snapshot"); - } else if (typeFilter === "modded") { - versions = versions.filter( - (v) => v.type === "fabric" || v.type === "forge" - ); + } else if (typeFilter === "installed") { + versions = versions.filter((v) => v.isInstalled === true); } // Apply search filter @@ -90,10 +179,90 @@ function handleModLoaderInstall(versionId: string) { // Refresh the installed versions list loadInstalledModdedVersions(); + // Refresh vanilla versions to update isInstalled status + gameState.loadVersions(); // Select the newly installed version gameState.selectedVersion = versionId; } + // Delete confirmation dialog state + let showDeleteDialog = $state(false); + let versionToDelete = $state<string | null>(null); + + // Show delete confirmation dialog + function showDeleteConfirmation(versionId: string, event: MouseEvent) { + event.stopPropagation(); // Prevent version selection + versionToDelete = versionId; + showDeleteDialog = true; + } + + // Cancel delete + function cancelDelete() { + showDeleteDialog = false; + versionToDelete = null; + } + + // Confirm and delete version + async function confirmDelete() { + if (!versionToDelete) return; + + try { + await invoke("delete_version", { versionId: versionToDelete }); + // Clear selection if deleted version was selected + if (gameState.selectedVersion === versionToDelete) { + gameState.selectedVersion = ""; + } + // Close dialog + showDeleteDialog = false; + versionToDelete = null; + // Versions will be refreshed automatically via event listener + } catch (e) { + console.error("Failed to delete version:", e); + alert(`Failed to delete version: ${e}`); + // Keep dialog open on error so user can retry + } + } + + // Version metadata for the selected version + interface VersionMetadata { + id: string; + javaVersion?: number; + isInstalled: boolean; + } + + let selectedVersionMetadata = $state<VersionMetadata | null>(null); + let isLoadingMetadata = $state(false); + + // Load metadata when version is selected + async function loadVersionMetadata(versionId: string) { + if (!versionId) { + selectedVersionMetadata = null; + return; + } + + isLoadingMetadata = true; + try { + const metadata = await invoke<VersionMetadata>("get_version_metadata", { + versionId, + }); + selectedVersionMetadata = metadata; + } catch (e) { + console.error("Failed to load version metadata:", e); + selectedVersionMetadata = null; + } finally { + isLoadingMetadata = false; + } + } + + // Watch for selected version changes + $effect(() => { + if (gameState.selectedVersion) { + loadVersionMetadata(gameState.selectedVersion); + } else { + selectedVersionMetadata = null; + } + }); + // Get the base Minecraft version from selected version (for mod loader selector) let selectedBaseVersion = $derived(() => { const selected = gameState.selectedVersion; @@ -140,7 +309,7 @@ <!-- Type Filter Tabs (Glass Caps) --> <div class="flex p-1 bg-white/60 dark:bg-black/20 rounded-xl border border-black/5 dark:border-white/5"> - {#each ['all', 'release', 'snapshot', 'modded'] as filter} + {#each ['all', 'release', 'snapshot', 'installed'] as filter} <button class="flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 capitalize {typeFilter === filter @@ -180,29 +349,52 @@ <div class="absolute inset-0 bg-gradient-to-r from-indigo-500/10 to-transparent pointer-events-none"></div> {/if} - <div class="relative z-10 flex items-center gap-4"> + <div class="relative z-10 flex items-center gap-4 flex-1"> <span class="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border {badge.class}" > {badge.text} </span> - <div> + <div class="flex-1"> <div class="font-bold font-mono text-lg tracking-tight {isSelected ? 'text-black dark:text-white' : 'text-gray-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white'}"> {version.id} </div> - {#if version.releaseTime && version.type !== "fabric" && version.type !== "forge"} - <div class="text-xs dark:text-white/30 text-black/30"> - {new Date(version.releaseTime).toLocaleDateString()} - </div> - {/if} + <div class="flex items-center gap-2 mt-0.5"> + {#if version.releaseTime && version.type !== "fabric" && version.type !== "forge"} + <div class="text-xs dark:text-white/30 text-black/30"> + {new Date(version.releaseTime).toLocaleDateString()} + </div> + {/if} + {#if version.javaVersion} + <div class="flex items-center gap-1 text-xs dark:text-white/40 text-black/40"> + <span class="opacity-60">☕</span> + <span class="font-medium">Java {version.javaVersion}</span> + </div> + {/if} + </div> </div> </div> - {#if isSelected} - <div class="relative z-10 text-indigo-500 dark:text-indigo-400"> - <span class="text-lg">Selected</span> - </div> - {/if} + <div class="relative z-10 flex items-center gap-2"> + {#if version.isInstalled === true} + <button + onclick={(e) => showDeleteConfirmation(version.id, e)} + class="p-2 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-500/10 dark:hover:bg-red-500/20 transition-colors opacity-0 group-hover:opacity-100" + title="Delete version" + > + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M3 6h18"></path> + <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path> + <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path> + </svg> + </button> + {/if} + {#if isSelected} + <div class="text-indigo-500 dark:text-indigo-400"> + <span class="text-lg">Selected</span> + </div> + {/if} + </div> </button> {/each} {/if} @@ -217,9 +409,50 @@ <h3 class="text-xs font-bold uppercase tracking-widest dark:text-white/40 text-black/40 mb-2 relative z-10">Current Selection</h3> {#if gameState.selectedVersion} - <p class="font-mono text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/70 relative z-10 truncate"> + <p class="font-mono text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/70 relative z-10 truncate mb-4"> {gameState.selectedVersion} </p> + + <!-- Version Metadata --> + {#if isLoadingMetadata} + <div class="space-y-3 relative z-10"> + <div class="animate-pulse space-y-2"> + <div class="h-4 bg-black/10 dark:bg-white/10 rounded w-3/4"></div> + <div class="h-4 bg-black/10 dark:bg-white/10 rounded w-1/2"></div> + </div> + </div> + {:else if selectedVersionMetadata} + <div class="space-y-3 relative z-10"> + <!-- Java Version --> + {#if selectedVersionMetadata.javaVersion} + <div> + <div class="text-[10px] font-bold uppercase tracking-wider dark:text-white/40 text-black/40 mb-1">Java Version</div> + <div class="flex items-center gap-2"> + <span class="text-lg opacity-60">☕</span> + <span class="text-sm dark:text-white text-black font-medium"> + Java {selectedVersionMetadata.javaVersion} + </span> + </div> + </div> + {/if} + + <!-- Installation Status --> + <div> + <div class="text-[10px] font-bold uppercase tracking-wider dark:text-white/40 text-black/40 mb-1">Status</div> + <div class="flex items-center gap-2"> + {#if selectedVersionMetadata.isInstalled === true} + <span class="px-2 py-0.5 bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-[10px] font-bold rounded border border-emerald-500/30"> + Installed + </span> + {:else if selectedVersionMetadata.isInstalled === false} + <span class="px-2 py-0.5 bg-zinc-500/20 text-zinc-600 dark:text-zinc-400 text-[10px] font-bold rounded border border-zinc-500/30"> + Not Installed + </span> + {/if} + </div> + </div> + </div> + {/if} {:else} <p class="dark:text-white/20 text-black/20 italic relative z-10">None selected</p> {/if} @@ -235,4 +468,30 @@ </div> </div> + + <!-- Delete Version Confirmation Dialog --> + {#if showDeleteDialog && versionToDelete} + <div class="fixed inset-0 z-[200] bg-black/70 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"> + <div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in fade-in zoom-in-95 duration-200"> + <h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Delete Version</h3> + <p class="text-zinc-600 dark:text-zinc-400 text-sm mb-6"> + Are you sure you want to delete version <span class="text-gray-900 dark:text-white font-mono font-medium">{versionToDelete}</span>? This action cannot be undone. + </p> + <div class="flex gap-3 justify-end"> + <button + onclick={cancelDelete} + class="px-4 py-2 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-lg transition-colors" + > + Cancel + </button> + <button + onclick={confirmDelete} + class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-500 rounded-lg transition-colors" + > + Delete + </button> + </div> + </div> + </div> + {/if} </div> diff --git a/ui/src/stores/game.svelte.ts b/ui/src/stores/game.svelte.ts index 28b2db5..ca5dc2b 100644 --- a/ui/src/stores/game.svelte.ts +++ b/ui/src/stores/game.svelte.ts @@ -14,10 +14,8 @@ export class GameState { async loadVersions() { try { this.versions = await invoke<Version[]>("get_versions"); - if (this.versions.length > 0) { - const latest = this.versions.find((v) => v.type === "release"); - this.selectedVersion = latest ? latest.id : this.versions[0].id; - } + // Don't auto-select version here - let BottomBar handle version selection + // based on installed versions only } catch (e) { console.error("Failed to fetch versions:", e); uiState.setStatus("Error fetching versions: " + e); diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 6471869..9a4da2b 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -6,6 +6,8 @@ export interface Version { url: string; time: string; releaseTime: string; + javaVersion?: number; // Java major version requirement (e.g., 8, 17, 21) + isInstalled?: boolean; // Whether this version is installed locally } export interface Account { |