diff options
| -rw-r--r-- | src-tauri/Cargo.toml | 2 | ||||
| -rw-r--r-- | src-tauri/src/core/fabric.rs | 27 | ||||
| -rw-r--r-- | src-tauri/src/core/forge.rs | 302 | ||||
| -rw-r--r-- | src-tauri/src/main.rs | 449 | ||||
| -rw-r--r-- | src-tauri/tauri.conf.json | 2 | ||||
| -rw-r--r-- | ui/src/App.svelte | 26 | ||||
| -rw-r--r-- | ui/src/components/BottomBar.svelte | 172 | ||||
| -rw-r--r-- | ui/src/components/HomeView.svelte | 70 | ||||
| -rw-r--r-- | ui/src/components/ModLoaderSelector.svelte | 149 | ||||
| -rw-r--r-- | ui/src/components/ParticleBackground.svelte | 15 | ||||
| -rw-r--r-- | ui/src/components/VersionsView.svelte | 2 | ||||
| -rw-r--r-- | ui/src/lib/effects/SaturnEffect.ts | 147 | ||||
| -rw-r--r-- | ui/src/stores/auth.svelte.ts | 54 | ||||
| -rw-r--r-- | ui/src/types/index.ts | 3 |
14 files changed, 1282 insertions, 138 deletions
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0eb143c..97529a1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dropout" -version = "0.1.21" +version = "0.1.23" edition = "2021" authors = ["HsiangNianian"] description = "The DropOut Minecraft Game Launcher" 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/core/forge.rs b/src-tauri/src/core/forge.rs index 0f17bcc..e69b296 100644 --- a/src-tauri/src/core/forge.rs +++ b/src-tauri/src/core/forge.rs @@ -5,8 +5,7 @@ //! - 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. +//! This implementation fetches the installer manifest to get the correct library list. use serde::{Deserialize, Serialize}; use std::error::Error; @@ -42,6 +41,46 @@ pub struct InstalledForgeVersion { pub path: PathBuf, } +/// Forge installer manifest structure (from version.json inside installer JAR) +#[derive(Debug, Deserialize)] +struct ForgeInstallerManifest { + id: Option<String>, + #[serde(rename = "inheritsFrom")] + inherits_from: Option<String>, + #[serde(rename = "mainClass")] + main_class: Option<String>, + #[serde(default)] + libraries: Vec<ForgeLibrary>, + arguments: Option<ForgeArguments>, +} + +#[derive(Debug, Deserialize)] +struct ForgeArguments { + game: Option<Vec<serde_json::Value>>, + jvm: Option<Vec<serde_json::Value>>, +} + +#[derive(Debug, Deserialize, Clone)] +struct ForgeLibrary { + name: String, + #[serde(default)] + downloads: Option<ForgeLibraryDownloads>, + #[serde(default)] + url: Option<String>, +} + +#[derive(Debug, Deserialize, Clone)] +struct ForgeLibraryDownloads { + artifact: Option<ForgeArtifact>, +} + +#[derive(Debug, Deserialize, Clone)] +struct ForgeArtifact { + path: Option<String>, + url: Option<String>, + sha1: Option<String>, +} + /// Fetch all Minecraft versions supported by Forge. /// /// # Returns @@ -138,18 +177,49 @@ 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( + game_version: &str, + forge_version: &str, +) -> Result<ForgeInstallerManifest, 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()); + } + + let bytes = response.bytes().await?; + + // Extract version.json from the JAR (which is a ZIP file) + let cursor = std::io::Cursor::new(bytes.as_ref()); + let mut archive = zip::ZipArchive::new(cursor)?; + + // Look for version.json in the archive + let version_json = archive.by_name("version.json")?; + let manifest: ForgeInstallerManifest = serde_json::from_reader(version_json)?; + + Ok(manifest) +} + /// 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). +/// This function downloads the Forge installer JAR and runs it in headless mode +/// to properly install Forge with all necessary patches. /// /// # 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") +/// * `java_path` - Path to the Java executable /// /// # Returns /// Information about the installed version. @@ -160,10 +230,11 @@ pub async fn install_forge( ) -> 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)?; + // Fetch the installer manifest to get the complete version.json + let manifest = fetch_forge_installer_manifest(game_version, forge_version).await?; + + // Create version JSON from the manifest + let version_json = create_forge_version_json_from_manifest(game_version, forge_version, &manifest)?; // Create the version directory let version_dir = game_dir.join("versions").join(&version_id); @@ -182,55 +253,185 @@ pub async fn install_forge( }) } -/// Create a basic Forge version JSON. +/// Install Forge using the official installer JAR. +/// This runs the Forge installer in headless mode to properly patch the client. +/// +/// # Arguments +/// * `game_dir` - The .minecraft directory path +/// * `game_version` - The Minecraft version +/// * `forge_version` - The Forge version +/// * `java_path` - Path to the Java executable /// -/// 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. +/// # Returns +/// Result indicating success or failure +pub async fn run_forge_installer( + game_dir: &PathBuf, + game_version: &str, + 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?; + tokio::fs::write(&installer_path, &bytes).await?; + + // Run the installer in headless mode + // The installer accepts --installClient <path> to install to a specific directory + let output = tokio::process::Command::new(java_path) + .arg("-jar") + .arg(&installer_path) + .arg("--installClient") + .arg(game_dir) + .output() + .await?; + + // Clean up installer + let _ = tokio::fs::remove_file(&installer_path).await; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(format!( + "Forge installer failed:\nstdout: {}\nstderr: {}", + stdout, stderr + ).into()); + } + + Ok(()) +} + +/// Create a Forge version JSON from the installer manifest. +fn create_forge_version_json_from_manifest( + game_version: &str, + forge_version: &str, + manifest: &ForgeInstallerManifest, +) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> { + let version_id = generate_version_id(game_version, forge_version); + + // Use main class from manifest or default + let main_class = manifest.main_class.clone().unwrap_or_else(|| { + if is_modern_forge(game_version) { + "cpw.mods.bootstraplauncher.BootstrapLauncher".to_string() + } else { + "net.minecraft.launchwrapper.Launch".to_string() + } + }); + + // Convert libraries to JSON format, preserving download info + let lib_entries: Vec<serde_json::Value> = manifest.libraries + .iter() + .map(|lib| { + let mut entry = serde_json::json!({ + "name": lib.name + }); + + // Add URL if present + if let Some(url) = &lib.url { + entry["url"] = serde_json::Value::String(url.clone()); + } else { + // Default to Forge Maven for Forge libraries + entry["url"] = serde_json::Value::String(FORGE_MAVEN_URL.to_string()); + } + + // Add downloads if present + if let Some(downloads) = &lib.downloads { + if let Some(artifact) = &downloads.artifact { + let mut artifact_json = serde_json::Map::new(); + if let Some(path) = &artifact.path { + artifact_json.insert("path".to_string(), serde_json::Value::String(path.clone())); + } + if let Some(url) = &artifact.url { + artifact_json.insert("url".to_string(), serde_json::Value::String(url.clone())); + } + if let Some(sha1) = &artifact.sha1 { + artifact_json.insert("sha1".to_string(), serde_json::Value::String(sha1.clone())); + } + if !artifact_json.is_empty() { + entry["downloads"] = serde_json::json!({ + "artifact": artifact_json + }); + } + } + } + + entry + }) + .collect(); + + // Build arguments + let mut arguments = serde_json::json!({ + "game": [], + "jvm": [] + }); + + if let Some(args) = &manifest.arguments { + if let Some(game_args) = &args.game { + arguments["game"] = serde_json::Value::Array(game_args.clone()); + } + if let Some(jvm_args) = &args.jvm { + arguments["jvm"] = serde_json::Value::Array(jvm_args.clone()); + } + } + + let json = serde_json::json!({ + "id": version_id, + "inheritsFrom": manifest.inherits_from.clone().unwrap_or_else(|| game_version.to_string()), + "type": "release", + "mainClass": main_class, + "libraries": lib_entries, + "arguments": arguments + }); + + Ok(json) +} + +/// Create a Forge version JSON with the proper library list (fallback). +#[allow(dead_code)] fn create_forge_version_json( game_version: &str, forge_version: &str, + libraries: &[ForgeLibrary], ) -> 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), - ), - ], - ) + let main_class = if is_modern_forge(game_version) { + "cpw.mods.bootstraplauncher.BootstrapLauncher" } 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), - ], - ) + "net.minecraft.launchwrapper.Launch" }; + // Convert libraries to JSON format + let lib_entries: Vec<serde_json::Value> = libraries + .iter() + .map(|lib| { + serde_json::json!({ + "name": lib.name, + "url": FORGE_MAVEN_URL + }) + }) + .collect(); + let json = serde_json::json!({ "id": version_id, "inheritsFrom": game_version, "type": "release", "mainClass": main_class, - "libraries": libraries, + "libraries": lib_entries, "arguments": { "game": [], "jvm": [] @@ -240,19 +441,6 @@ fn create_forge_version_json( 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(); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b69912e..3671166 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -121,6 +121,13 @@ async fn start_game( format!("Loading version details for {}...", version_id) ); + // First, load the local version to get the original inheritsFrom value + // (before merge clears it) + let original_inherits_from = match core::manifest::load_local_version(&game_dir, &version_id).await { + Ok(local_version) => local_version.inherits_from.clone(), + Err(_) => None, + }; + let version_details = core::manifest::load_version(&game_dir, &version_id) .await .map_err(|e| e.to_string())?; @@ -135,9 +142,7 @@ async fn start_game( // 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() + let minecraft_version = original_inherits_from .unwrap_or_else(|| version_id.clone()); // 2. Prepare download tasks @@ -380,6 +385,7 @@ async fn start_game( for lib in &version_details.libraries { if core::rules::is_library_allowed(&lib.rules) { if let Some(downloads) = &lib.downloads { + // Standard library with explicit downloads if let Some(artifact) = &downloads.artifact { let path_str = artifact .path @@ -388,6 +394,12 @@ async fn start_game( let lib_path = libraries_dir.join(path_str); classpath_entries.push(lib_path.to_string_lossy().to_string()); } + } else { + // Library without explicit downloads (mod loader libraries) + // Use Maven coordinate resolution + if let Some(lib_path) = core::maven::get_library_path(&lib.name, &libraries_dir) { + classpath_entries.push(lib_path.to_string_lossy().to_string()); + } } } } @@ -680,6 +692,300 @@ 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) + ); + + // First, try to fetch the vanilla version from Mojang and save it locally + let version_details = match core::manifest::load_local_version(&game_dir, &version_id).await { + Ok(v) => v, + Err(_) => { + // Not found locally, fetch from Mojang + emit_log!(window, format!("Fetching version {} from Mojang...", version_id)); + let fetched = core::manifest::fetch_vanilla_version(&version_id) + .await + .map_err(|e| e.to_string())?; + + // Save the version JSON locally + emit_log!(window, format!("Saving version JSON...")); + core::manifest::save_local_version(&game_dir, &fetched) + .await + .map_err(|e| e.to_string())?; + + fetched + } + }; + + // Now load the full version with inheritance resolved + 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 +1071,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 { @@ -1008,6 +1329,99 @@ async fn list_installed_fabric_versions(window: Window) -> Result<Vec<String>, S .map_err(|e| e.to_string()) } +/// Installed version info +#[derive(serde::Serialize)] +struct InstalledVersion { + id: String, + #[serde(rename = "type")] + version_type: String, // "release", "snapshot", "fabric", "forge", "modpack" +} + +/// List all installed versions from the data directory +/// Simply lists all folders in the versions directory without validation +#[tauri::command] +async fn list_installed_versions(window: Window) -> Result<Vec<InstalledVersion>, 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 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 + .map_err(|e| e.to_string())?; + + while let Some(entry) = entries.next_entry().await.map_err(|e| e.to_string())? { + // Only include directories + if !entry.file_type().await.map_err(|e| e.to_string())?.is_dir() { + continue; + } + + let name = entry.file_name().to_string_lossy().to_string(); + let version_dir = entry.path(); + + // Determine version type based on folder name or JSON content + let version_type = if name.starts_with("fabric-loader-") { + "fabric".to_string() + } else if name.contains("-forge") || name.contains("forge-") { + "forge".to_string() + } else { + // Try to read JSON to get type, otherwise guess from name + let json_path = version_dir.join(format!("{}.json", name)); + if json_path.exists() { + if let Ok(content) = tokio::fs::read_to_string(&json_path).await { + if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) { + json.get("type") + .and_then(|t| t.as_str()) + .unwrap_or("modpack") + .to_string() + } else { + "modpack".to_string() + } + } else { + "modpack".to_string() + } + } else { + // No JSON file - treat as modpack/custom + "modpack".to_string() + } + }; + + installed.push(InstalledVersion { + id: name, + version_type, + }); + } + + // Sort: modded/modpack first, then by version id descending + installed.sort_by(|a, b| { + let a_priority = match a.version_type.as_str() { + "fabric" | "forge" => 0, + "modpack" => 1, + _ => 2, + }; + let b_priority = match b.version_type.as_str() { + "fabric" | "forge" => 0, + "modpack" => 1, + _ => 2, + }; + + match a_priority.cmp(&b_priority) { + std::cmp::Ordering::Equal => b.id.cmp(&a.id), // Descending order + other => other, + } + }); + + Ok(installed) +} + /// Check if Fabric is installed for a specific version #[tauri::command] async fn is_fabric_installed( @@ -1050,6 +1464,7 @@ async fn get_forge_versions_for_game( #[tauri::command] async fn install_forge( window: Window, + config_state: State<'_, core::config::ConfigState>, game_version: String, forge_version: String, ) -> Result<core::forge::InstalledForgeVersion, String> { @@ -1067,6 +1482,31 @@ async fn install_forge( .app_data_dir() .map_err(|e| format!("Failed to get app data dir: {}", e))?; + // Get Java path from config or detect + let config = config_state.config.lock().unwrap().clone(); + 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 = std::path::PathBuf::from(&java_path_str); + + emit_log!(window, "Running Forge installer...".to_string()); + + // Run the Forge installer to properly patch the client + core::forge::run_forge_installer(&game_dir, &game_version, &forge_version, &java_path) + .await + .map_err(|e| format!("Forge installer failed: {}", e))?; + + emit_log!(window, "Forge installer completed, creating version profile...".to_string()); + + // Now create the version JSON let result = core::forge::install_forge(&game_dir, &game_version, &forge_version) .await .map_err(|e| e.to_string())?; @@ -1241,6 +1681,9 @@ fn main() { .invoke_handler(tauri::generate_handler![ start_game, get_versions, + check_version_installed, + install_version, + list_installed_versions, login_offline, get_active_account, logout, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ce54ca8..060a871 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "dropout", - "version": "0.1.21", + "version": "0.1.23", "identifier": "com.dropout.launcher", "build": { "beforeDevCommand": "pnpm -C ../ui dev", diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 0bb31ae..760a15f 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -141,6 +141,32 @@ <LoginModal /> <StatusToast /> + <!-- Logout Confirmation Dialog --> + {#if authState.isLogoutConfirmOpen} + <div class="fixed inset-0 z-[200] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4"> + <div class="bg-zinc-900 border 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-white mb-2">Logout</h3> + <p class="text-zinc-400 text-sm mb-6"> + Are you sure you want to logout <span class="text-white font-medium">{authState.currentAccount?.username}</span>? + </p> + <div class="flex gap-3 justify-end"> + <button + onclick={() => authState.cancelLogout()} + class="px-4 py-2 text-sm font-medium text-zinc-300 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors" + > + Cancel + </button> + <button + onclick={() => authState.confirmLogout()} + class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-500 rounded-lg transition-colors" + > + Logout + </button> + </div> + </div> + </div> + {/if} + {#if uiState.showConsole} <div class="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-8"> <div class="w-full h-full max-w-6xl max-h-[85vh] bg-[#1e1e1e] rounded-lg overflow-hidden border border-zinc-700 shadow-2xl relative flex flex-col"> diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte index abb0b23..b7bbf71 100644 --- a/ui/src/components/BottomBar.svelte +++ b/ui/src/components/BottomBar.svelte @@ -1,23 +1,68 @@ <script lang="ts"> + import { invoke } from "@tauri-apps/api/core"; + import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { authState } from "../stores/auth.svelte"; import { gameState } from "../stores/game.svelte"; import { uiState } from "../stores/ui.svelte"; - import { Terminal, ChevronDown, Play, User, Check } from 'lucide-svelte'; + import { Terminal, ChevronDown, Play, User, Check, RefreshCw } from 'lucide-svelte'; + + interface InstalledVersion { + id: string; + type: string; + } let isVersionDropdownOpen = $state(false); let dropdownRef: HTMLDivElement; + let installedVersions = $state<InstalledVersion[]>([]); + let isLoadingVersions = $state(true); + let downloadCompleteUnlisten: UnlistenFn | null = null; + + // Load installed versions on mount + $effect(() => { + loadInstalledVersions(); + setupDownloadListener(); + return () => { + if (downloadCompleteUnlisten) { + downloadCompleteUnlisten(); + } + }; + }); + + async function setupDownloadListener() { + // Refresh list when a download completes + downloadCompleteUnlisten = await listen("download-complete", () => { + loadInstalledVersions(); + }); + } + + async function loadInstalledVersions() { + isLoadingVersions = true; + try { + installedVersions = await invoke<InstalledVersion[]>("list_installed_versions"); + // If no version is selected but we have installed versions, select the first one + if (!gameState.selectedVersion && installedVersions.length > 0) { + gameState.selectedVersion = installedVersions[0].id; + } + } catch (e) { + console.error("Failed to load installed versions:", e); + } finally { + isLoadingVersions = false; + } + } let versionOptions = $derived( - gameState.versions.length === 0 + isLoadingVersions ? [{ id: "loading", type: "loading", label: "Loading..." }] - : gameState.versions.map(v => ({ - ...v, - label: `${v.id}${v.type !== 'release' ? ` (${v.type})` : ''}` - })) + : installedVersions.length === 0 + ? [{ id: "empty", type: "empty", label: "No versions installed" }] + : installedVersions.map(v => ({ + ...v, + label: `${v.id}${v.type !== 'release' ? ` (${v.type})` : ''}` + })) ); function selectVersion(id: string) { - if (id !== "loading") { + if (id !== "loading" && id !== "empty") { gameState.selectedVersion = id; isVersionDropdownOpen = false; } @@ -35,6 +80,16 @@ return () => document.removeEventListener('click', handleClickOutside); } }); + + function getVersionTypeColor(type: string) { + switch (type) { + case 'fabric': return 'text-indigo-400'; + case 'forge': return 'text-orange-400'; + case 'snapshot': return 'text-amber-400'; + case 'modpack': return 'text-purple-400'; + default: return 'text-emerald-400'; + } + } </script> <div @@ -67,12 +122,23 @@ {authState.currentAccount ? authState.currentAccount.username : "Login Account"} </div> <div class="text-[10px] uppercase tracking-wider dark:text-zinc-500 text-gray-500 flex items-center gap-2"> - <span - class="w-1.5 h-1.5 rounded-full {authState.currentAccount - ? 'bg-emerald-500' - : 'bg-zinc-400'}" - ></span> - {authState.currentAccount ? "Online" : "Guest"} + {#if authState.currentAccount} + {#if authState.currentAccount.type === "Microsoft"} + {#if authState.currentAccount.expires_at && authState.currentAccount.expires_at * 1000 < Date.now()} + <span class="w-1.5 h-1.5 rounded-full bg-red-500"></span> + <span class="text-red-400">Expired</span> + {:else} + <span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span> + Online + {/if} + {:else} + <span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span> + Offline + {/if} + {:else} + <span class="w-1.5 h-1.5 rounded-full bg-zinc-400"></span> + Guest + {/if} </div> </div> </div> @@ -94,47 +160,70 @@ <div class="flex flex-col items-end mr-2"> <!-- Custom Version Dropdown --> <div class="relative" bind:this={dropdownRef}> - <button - type="button" - onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen} - class="flex items-center justify-between gap-2 w-56 px-4 py-2.5 text-left - dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md - text-sm font-mono dark:text-white text-gray-900 - dark:hover:border-zinc-600 hover:border-zinc-400 - focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 - transition-colors cursor-pointer outline-none" - > - <span class="truncate"> - {#if gameState.versions.length === 0} - Loading... - {: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 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 + type="button" + onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen} + disabled={installedVersions.length === 0 && !isLoadingVersions} + class="flex items-center justify-between gap-2 w-56 px-4 py-2.5 text-left + dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md + text-sm font-mono dark:text-white text-gray-900 + dark:hover:border-zinc-600 hover:border-zinc-400 + focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 + 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> - {#if isVersionDropdownOpen} + {#if isVersionDropdownOpen && installedVersions.length > 0} <div class="absolute z-50 w-full mt-1 py-1 dark:bg-zinc-900 bg-white border dark:border-zinc-700 border-zinc-300 rounded-md shadow-xl - max-h-72 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 bottom-full mb-1" + max-h-72 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 bottom-full mb-1 right-0" > {#each versionOptions as version} <button type="button" onclick={() => selectVersion(version.id)} - disabled={version.id === "loading"} + disabled={version.id === "loading" || version.id === "empty"} class="w-full flex items-center justify-between px-3 py-2 text-sm font-mono text-left transition-colors outline-none {version.id === gameState.selectedVersion ? 'bg-indigo-600 text-white' : 'dark:text-zinc-300 text-gray-700 dark:hover:bg-zinc-800 hover:bg-zinc-100'} - {version.id === 'loading' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}" + {version.id === 'loading' || version.id === 'empty' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}" > - <span class="truncate">{version.label}</span> + <span class="truncate flex items-center gap-2"> + {version.id} + {#if version.type && version.type !== 'release' && version.type !== 'loading' && version.type !== 'empty'} + <span class="text-[10px] uppercase {version.id === gameState.selectedVersion ? 'text-white/70' : getVersionTypeColor(version.type)}"> + {version.type} + </span> + {/if} + </span> {#if version.id === gameState.selectedVersion} <Check size={14} class="shrink-0 ml-2" /> {/if} @@ -147,7 +236,8 @@ <button onclick={() => gameState.startGame()} - class="bg-emerald-600 hover:bg-emerald-500 text-white h-14 px-10 rounded-sm transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-emerald-500/20 flex items-center gap-3 font-bold text-lg tracking-widest uppercase" + disabled={installedVersions.length === 0 || !gameState.selectedVersion} + class="bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white h-14 px-10 rounded-sm transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-emerald-500/20 flex items-center gap-3 font-bold text-lg tracking-widest uppercase" > <Play size={24} fill="currentColor" /> <span>Launch</span> diff --git a/ui/src/components/HomeView.svelte b/ui/src/components/HomeView.svelte index 7bb7e44..2fa8390 100644 --- a/ui/src/components/HomeView.svelte +++ b/ui/src/components/HomeView.svelte @@ -3,6 +3,7 @@ import { gameState } from '../stores/game.svelte'; import { releasesState } from '../stores/releases.svelte'; import { Calendar, ExternalLink } from 'lucide-svelte'; + import { getSaturnEffect } from './ParticleBackground.svelte'; type Props = { mouseX: number; @@ -10,6 +11,60 @@ }; let { mouseX = 0, mouseY = 0 }: Props = $props(); + // Saturn effect mouse interaction handlers + function handleSaturnMouseDown(e: MouseEvent) { + const effect = getSaturnEffect(); + if (effect) { + effect.handleMouseDown(e.clientX); + } + } + + function handleSaturnMouseMove(e: MouseEvent) { + const effect = getSaturnEffect(); + if (effect) { + effect.handleMouseMove(e.clientX); + } + } + + function handleSaturnMouseUp() { + const effect = getSaturnEffect(); + if (effect) { + effect.handleMouseUp(); + } + } + + function handleSaturnMouseLeave() { + const effect = getSaturnEffect(); + if (effect) { + effect.handleMouseUp(); + } + } + + function handleSaturnTouchStart(e: TouchEvent) { + if (e.touches.length === 1) { + const effect = getSaturnEffect(); + if (effect) { + effect.handleTouchStart(e.touches[0].clientX); + } + } + } + + function handleSaturnTouchMove(e: TouchEvent) { + if (e.touches.length === 1) { + const effect = getSaturnEffect(); + if (effect) { + effect.handleTouchMove(e.touches[0].clientX); + } + } + } + + function handleSaturnTouchEnd() { + const effect = getSaturnEffect(); + if (effect) { + effect.handleTouchEnd(); + } + } + onMount(() => { releasesState.loadReleases(); }); @@ -65,6 +120,7 @@ // Formatting helper const formatLine = (text: string) => text .replace(/\*\*(.*?)\*\*/g, '<strong class="text-zinc-200">$1</strong>') + .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em class="text-zinc-400 italic">$1</em>') .replace(/`([^`]+)`/g, '<code class="bg-zinc-800 px-1 py-0.5 rounded text-xs text-zinc-300 font-mono border border-white/5 break-all whitespace-normal">$1</code>') .replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" class="text-indigo-400 hover:text-indigo-300 hover:underline decoration-indigo-400/30 break-all">$1</a>'); @@ -103,8 +159,18 @@ <!-- Scrollable Container --> <div class="relative z-10 h-full {releasesState.isLoading ? 'overflow-hidden' : 'overflow-y-auto custom-scrollbar scroll-smooth'}"> - <!-- Hero Section (Full Height) --> - <div class="min-h-full flex flex-col justify-end p-12 pb-32"> + <!-- Hero Section (Full Height) - Interactive area for Saturn rotation --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="min-h-full flex flex-col justify-end p-12 pb-32 cursor-grab active:cursor-grabbing select-none" + onmousedown={handleSaturnMouseDown} + onmousemove={handleSaturnMouseMove} + onmouseup={handleSaturnMouseUp} + onmouseleave={handleSaturnMouseLeave} + ontouchstart={handleSaturnTouchStart} + ontouchmove={handleSaturnTouchMove} + ontouchend={handleSaturnTouchEnd} + > <!-- 3D Floating Hero Text --> <div class="transition-transform duration-200 ease-out origin-bottom-left" 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> diff --git a/ui/src/components/ParticleBackground.svelte b/ui/src/components/ParticleBackground.svelte index 080f1f2..7644b1a 100644 --- a/ui/src/components/ParticleBackground.svelte +++ b/ui/src/components/ParticleBackground.svelte @@ -1,7 +1,17 @@ +<script lang="ts" module> + import { SaturnEffect } from "../lib/effects/SaturnEffect"; + + // Global reference to the active Saturn effect for external control + let globalSaturnEffect: SaturnEffect | null = null; + + export function getSaturnEffect(): SaturnEffect | null { + return globalSaturnEffect; + } +</script> + <script lang="ts"> import { onMount, onDestroy } from "svelte"; import { ConstellationEffect } from "../lib/effects/ConstellationEffect"; - import { SaturnEffect } from "../lib/effects/SaturnEffect"; import { settingsState } from "../stores/settings.svelte"; let canvas: HTMLCanvasElement; @@ -16,8 +26,10 @@ if (settingsState.settings.active_effect === "saturn") { activeEffect = new SaturnEffect(canvas); + globalSaturnEffect = activeEffect; } else { activeEffect = new ConstellationEffect(canvas); + globalSaturnEffect = null; } // Ensure correct size immediately @@ -48,6 +60,7 @@ onDestroy(() => { if (activeEffect) activeEffect.destroy(); + globalSaturnEffect = null; }); </script> diff --git a/ui/src/components/VersionsView.svelte b/ui/src/components/VersionsView.svelte index 99cc296..ce354b9 100644 --- a/ui/src/components/VersionsView.svelte +++ b/ui/src/components/VersionsView.svelte @@ -80,6 +80,8 @@ return { text: "Fabric", class: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-300 dark:border-indigo-500/30" }; case "forge": return { text: "Forge", class: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-500/20 dark:text-orange-300 dark:border-orange-500/30" }; + case "modpack": + return { text: "Modpack", class: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-500/20 dark:text-purple-300 dark:border-purple-500/30" }; default: return { text: type, class: "bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-zinc-500/20 dark:text-zinc-300 dark:border-zinc-500/30" }; } diff --git a/ui/src/lib/effects/SaturnEffect.ts b/ui/src/lib/effects/SaturnEffect.ts index a370936..42aee66 100644 --- a/ui/src/lib/effects/SaturnEffect.ts +++ b/ui/src/lib/effects/SaturnEffect.ts @@ -18,6 +18,21 @@ export class SaturnEffect { private angle: number = 0; private scaleFactor: number = 1; + // Mouse interaction properties + private isDragging: boolean = false; + private lastMouseX: number = 0; + private lastMouseTime: number = 0; + private mouseVelocities: number[] = []; // Store recent velocities for averaging + + // Rotation speed control + private readonly baseSpeed: number = 0.005; // Original rotation speed + private currentSpeed: number = 0.005; // Current rotation speed (can be modified by mouse) + private rotationDirection: number = 1; // 1 for clockwise, -1 for counter-clockwise + private readonly speedDecayRate: number = 0.992; // How fast speed returns to normal (closer to 1 = slower decay) + private readonly minSpeedMultiplier: number = 1; // Minimum speed is baseSpeed + private readonly maxSpeedMultiplier: number = 50; // Maximum speed is 50x baseSpeed + private isStopped: boolean = false; // Whether the user has stopped the rotation + constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; this.ctx = canvas.getContext('2d', { @@ -33,6 +48,121 @@ export class SaturnEffect { this.animate(); } + // Public methods for external mouse event handling + // These can be called from any element that wants to control the Saturn rotation + + handleMouseDown(clientX: number) { + this.isDragging = true; + this.lastMouseX = clientX; + this.lastMouseTime = performance.now(); + this.mouseVelocities = []; + } + + handleMouseMove(clientX: number) { + if (!this.isDragging) return; + + const currentTime = performance.now(); + const deltaTime = currentTime - this.lastMouseTime; + + if (deltaTime > 0) { + const deltaX = clientX - this.lastMouseX; + const velocity = deltaX / deltaTime; // pixels per millisecond + + // Store recent velocities (keep last 5 for smoothing) + this.mouseVelocities.push(velocity); + if (this.mouseVelocities.length > 5) { + this.mouseVelocities.shift(); + } + + // Apply direct rotation while dragging + this.angle += deltaX * 0.002; + } + + this.lastMouseX = clientX; + this.lastMouseTime = currentTime; + } + + handleMouseUp() { + if (this.isDragging && this.mouseVelocities.length > 0) { + this.applyFlingVelocity(); + } + this.isDragging = false; + } + + handleTouchStart(clientX: number) { + this.isDragging = true; + this.lastMouseX = clientX; + this.lastMouseTime = performance.now(); + this.mouseVelocities = []; + } + + handleTouchMove(clientX: number) { + if (!this.isDragging) return; + + const currentTime = performance.now(); + const deltaTime = currentTime - this.lastMouseTime; + + if (deltaTime > 0) { + const deltaX = clientX - this.lastMouseX; + const velocity = deltaX / deltaTime; + + this.mouseVelocities.push(velocity); + if (this.mouseVelocities.length > 5) { + this.mouseVelocities.shift(); + } + + this.angle += deltaX * 0.002; + } + + this.lastMouseX = clientX; + this.lastMouseTime = currentTime; + } + + handleTouchEnd() { + if (this.isDragging && this.mouseVelocities.length > 0) { + this.applyFlingVelocity(); + } + this.isDragging = false; + } + + private applyFlingVelocity() { + // Calculate average velocity from recent samples + const avgVelocity = this.mouseVelocities.reduce((a, b) => a + b, 0) / this.mouseVelocities.length; + + // Threshold for considering it a "fling" (pixels per millisecond) + const flingThreshold = 0.3; + // Threshold for considering the rotation as "stopped" by user + const stopThreshold = 0.1; + + if (Math.abs(avgVelocity) > flingThreshold) { + // User flung it - start rotating again + this.isStopped = false; + + // Determine new direction based on fling direction + const newDirection = avgVelocity > 0 ? 1 : -1; + + // If direction changed, update it permanently + if (newDirection !== this.rotationDirection) { + this.rotationDirection = newDirection; + } + + // Calculate speed boost based on fling strength + // Map velocity to speed multiplier (stronger fling = faster rotation) + const speedMultiplier = Math.min( + this.maxSpeedMultiplier, + this.minSpeedMultiplier + Math.abs(avgVelocity) * 10 + ); + + this.currentSpeed = this.baseSpeed * speedMultiplier; + } else if (Math.abs(avgVelocity) < stopThreshold) { + // User gently released - keep it stopped + this.isStopped = true; + this.currentSpeed = 0; + } + // If velocity is between stopThreshold and flingThreshold, + // keep current state (don't change isStopped) + } + resize(width: number, height: number) { const dpr = window.devicePixelRatio || 1; this.width = width; @@ -104,8 +234,21 @@ export class SaturnEffect { // Normal blending this.ctx.globalCompositeOperation = 'source-over'; - // Slower rotation (from 0.0015 to 0.0005) - this.angle += 0.0005; + // Update rotation speed - decay towards base speed while maintaining direction + if (!this.isDragging && !this.isStopped) { + if (this.currentSpeed > this.baseSpeed) { + // Gradually decay speed back to base speed + this.currentSpeed = this.baseSpeed + (this.currentSpeed - this.baseSpeed) * this.speedDecayRate; + + // Snap to base speed when close enough + if (this.currentSpeed - this.baseSpeed < 0.00001) { + this.currentSpeed = this.baseSpeed; + } + } + + // Apply rotation with current speed and direction + this.angle += this.currentSpeed * this.rotationDirection; + } const cx = this.width * 0.6; const cy = this.height * 0.5; diff --git a/ui/src/stores/auth.svelte.ts b/ui/src/stores/auth.svelte.ts index 3d58245..eb9dccd 100644 --- a/ui/src/stores/auth.svelte.ts +++ b/ui/src/stores/auth.svelte.ts @@ -1,11 +1,14 @@ import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-shell"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import type { Account, DeviceCodeResponse } from "../types"; import { uiState } from "./ui.svelte"; +import { logsState } from "./logs.svelte"; export class AuthState { currentAccount = $state<Account | null>(null); isLoginModalOpen = $state(false); + isLogoutConfirmOpen = $state(false); loginMode = $state<"select" | "offline" | "microsoft">("select"); offlineUsername = $state(""); deviceCodeData = $state<DeviceCodeResponse | null>(null); @@ -14,6 +17,7 @@ export class AuthState { private pollInterval: ReturnType<typeof setInterval> | null = null; private isPollingRequestActive = false; + private authProgressUnlisten: UnlistenFn | null = null; async checkAccount() { try { @@ -26,15 +30,29 @@ export class AuthState { openLoginModal() { if (this.currentAccount) { - if (confirm("Logout " + this.currentAccount.username + "?")) { - invoke("logout").then(() => (this.currentAccount = null)); - } + // Show custom logout confirmation dialog + this.isLogoutConfirmOpen = true; return; } this.resetLoginState(); this.isLoginModalOpen = true; } + cancelLogout() { + this.isLogoutConfirmOpen = false; + } + + async confirmLogout() { + this.isLogoutConfirmOpen = false; + try { + await invoke("logout"); + this.currentAccount = null; + uiState.setStatus("Logged out successfully"); + } catch (e) { + console.error("Logout failed:", e); + } + } + closeLoginModal() { this.stopPolling(); this.isLoginModalOpen = false; @@ -65,6 +83,9 @@ export class AuthState { this.msLoginStatus = "Waiting for authorization..."; this.stopPolling(); + // Setup auth progress listener + this.setupAuthProgressListener(); + try { this.deviceCodeData = (await invoke( "start_microsoft_login" @@ -78,6 +99,7 @@ export class AuthState { } open(this.deviceCodeData.verification_uri); + logsState.addLog("info", "Auth", "Microsoft login started, waiting for browser authorization..."); console.log("Starting polling for token..."); const intervalMs = (this.deviceCodeData.interval || 5) * 1000; @@ -87,6 +109,7 @@ export class AuthState { ); } } catch (e) { + logsState.addLog("error", "Auth", `Failed to start Microsoft login: ${e}`); alert("Failed to start Microsoft login: " + e); this.loginMode = "select"; } finally { @@ -94,6 +117,27 @@ export class AuthState { } } + private async setupAuthProgressListener() { + // Clean up previous listener if exists + if (this.authProgressUnlisten) { + this.authProgressUnlisten(); + this.authProgressUnlisten = null; + } + + this.authProgressUnlisten = await listen<string>("auth-progress", (event) => { + const message = event.payload; + this.msLoginStatus = message; + logsState.addLog("info", "Auth", message); + }); + } + + private cleanupAuthListener() { + if (this.authProgressUnlisten) { + this.authProgressUnlisten(); + this.authProgressUnlisten = null; + } + } + stopPolling() { if (this.pollInterval) { clearInterval(this.pollInterval); @@ -113,7 +157,9 @@ export class AuthState { console.log("Login Successful!", this.currentAccount); this.stopPolling(); + this.cleanupAuthListener(); this.isLoginModalOpen = false; + logsState.addLog("info", "Auth", `Login successful! Welcome, ${this.currentAccount.username}`); uiState.setStatus("Welcome back, " + this.currentAccount.username); } catch (e: any) { const errStr = e.toString(); @@ -122,12 +168,14 @@ export class AuthState { } else { console.error("Polling Error:", errStr); this.msLoginStatus = "Error: " + errStr; + logsState.addLog("error", "Auth", `Login error: ${errStr}`); if ( errStr.includes("expired_token") || errStr.includes("access_denied") ) { this.stopPolling(); + this.cleanupAuthListener(); alert("Login failed: " + errStr); this.loginMode = "select"; } diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 09a7d5e..0f02d64 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -12,6 +12,9 @@ export interface Account { type: "Offline" | "Microsoft"; username: string; uuid: string; + access_token?: string; + refresh_token?: string; + expires_at?: number; // Unix timestamp for Microsoft accounts } export interface DeviceCodeResponse { |