diff options
| author | 2026-03-26 09:06:56 +0800 | |
|---|---|---|
| committer | 2026-03-26 09:06:56 +0800 | |
| commit | 2412f7a3a626fc3b9e7b59ce1fc900468b792972 (patch) | |
| tree | 68b2ad2b56daa1ad040a4a0df0f7db509e16d53c /src-tauri/src/main.rs | |
| parent | 788715b1ca5ab5b67fcc2e69650b74e14c953a57 (diff) | |
| parent | 94b0d8e208363c802c12b56d8bdbef574dd1fb91 (diff) | |
| download | DropOut-2412f7a3a626fc3b9e7b59ce1fc900468b792972.tar.gz DropOut-2412f7a3a626fc3b9e7b59ce1fc900468b792972.zip | |
Merge branch 'main' into refactor/fe
Diffstat (limited to 'src-tauri/src/main.rs')
| -rw-r--r-- | src-tauri/src/main.rs | 1124 |
1 files changed, 675 insertions, 449 deletions
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 33c94fe..63287cd 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6,7 +6,9 @@ use std::process::Stdio; use std::sync::Mutex; use tauri::{Emitter, Manager, State, Window}; // Added Emitter use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::process::Command; +use tokio::process::{Child, Command}; +use tokio::sync::Mutex as AsyncMutex; +use tokio::time::{Duration, sleep}; use ts_rs::TS; // Added Serialize #[cfg(target_os = "windows")] @@ -42,6 +44,40 @@ impl MsRefreshTokenState { } } +struct RunningGameProcess { + child: Child, + instance_id: String, + version_id: String, +} + +pub struct GameProcessState { + running_game: AsyncMutex<Option<RunningGameProcess>>, +} + +impl Default for GameProcessState { + fn default() -> Self { + Self::new() + } +} + +impl GameProcessState { + pub fn new() -> Self { + Self { + running_game: AsyncMutex::new(None), + } + } +} + +#[derive(Debug, Clone, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "core.ts")] +struct GameExitedEvent { + instance_id: String, + version_id: String, + exit_code: Option<i32>, + was_stopped: bool, +} + /// Check if a string contains unresolved placeholders in the form ${...} /// /// After the replacement phase, if a string still contains ${...}, it means @@ -63,6 +99,29 @@ fn has_unresolved_placeholder(s: &str) -> bool { false } +fn resolve_minecraft_version(version_id: &str) -> String { + if let Some(rest) = version_id.strip_prefix("fabric-loader-") { + // Fabric version IDs are of the form: fabric-loader-<loader>-<mc> + // After stripping the prefix, we split once to separate loader vs mc + let mut parts = rest.splitn(2, '-'); + let _loader_version = parts.next(); + if let Some(mc_version) = parts.next() { + mc_version.to_string() + } else { + // Malformed Fabric ID, fall back to original + version_id.to_string() + } + } else if version_id.contains("-forge-") { + version_id + .split("-forge-") + .next() + .unwrap_or(version_id) + .to_string() + } else { + version_id.to_string() + } +} + #[tauri::command] #[dropout_macros::api] async fn start_game( @@ -70,6 +129,7 @@ async fn start_game( auth_state: State<'_, core::auth::AccountState>, config_state: State<'_, core::config::ConfigState>, assistant_state: State<'_, core::assistant::AssistantState>, + game_process_state: State<'_, GameProcessState>, instance_state: State<'_, core::instance::InstanceState>, instance_id: String, version_id: String, @@ -82,6 +142,52 @@ async fn start_game( ) ); + let stale_instance_to_unlock = { + let mut running_game = game_process_state.running_game.lock().await; + + if let Some(existing_game) = running_game.as_mut() { + match existing_game.child.try_wait() { + Ok(Some(status)) => { + emit_log!( + window, + format!( + "Clearing stale game process for instance {} (exit code: {:?})", + existing_game.instance_id, + status.code() + ) + ); + let stale_instance_id = existing_game.instance_id.clone(); + *running_game = None; + Some(stale_instance_id) + } + Ok(None) => { + return Err(format!( + "A game is already running for instance {}", + existing_game.instance_id + )); + } + Err(error) => { + emit_log!( + window, + format!( + "Clearing broken game process state for instance {}: {}", + existing_game.instance_id, error + ) + ); + let stale_instance_id = existing_game.instance_id.clone(); + *running_game = None; + Some(stale_instance_id) + } + } + } else { + None + } + }; + + if let Some(stale_instance_id) = stale_instance_to_unlock { + instance_state.end_operation(&stale_instance_id); + } + // Check for active account emit_log!(window, "Checking for active account...".to_string()); let mut account = auth_state @@ -123,16 +229,18 @@ async fn start_game( emit_log!(window, "Account found".to_string()); let config = config_state.config.lock().unwrap().clone(); + let app_handle = window.app_handle(); + instance_state.begin_operation(&instance_id, core::instance::InstanceOperation::Launch)?; + + let launch_result: Result<String, String> = async { emit_log!(window, format!("Java path: {}", config.java_path)); emit_log!( window, format!("Memory: {}MB - {}MB", config.min_memory, config.max_memory) ); - // Get game directory from instance - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; + let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?; + let game_dir = resolved_paths.root.clone(); // Ensure game directory exists tokio::fs::create_dir_all(&game_dir) @@ -203,7 +311,6 @@ async fn start_game( // Resolve Java using priority-based resolution // Priority: instance override > global config > user preference > auto-detect // TODO: refactor into a separate function - let app_handle = window.app_handle(); let instance = instance_state .get_instance(&instance_id) .ok_or_else(|| format!("Instance {} not found", instance_id))?; @@ -260,12 +367,7 @@ async fn start_game( .as_ref() .ok_or("Version has no downloads information")?; let client_jar = &downloads.client; - // Use shared caches for versions if enabled - let mut client_path = if config.use_shared_caches { - app_handle.path().app_data_dir().unwrap().join("versions") - } else { - game_dir.join("versions") - }; + let mut client_path = resolved_paths.version_cache.clone(); client_path.push(&minecraft_version); client_path.push(format!("{}.jar", minecraft_version)); @@ -278,12 +380,7 @@ async fn start_game( // --- Libraries --- println!("Processing libraries..."); - // Use shared caches for libraries if enabled - let libraries_dir = if config.use_shared_caches { - app_handle.path().app_data_dir().unwrap().join("libraries") - } else { - game_dir.join("libraries") - }; + let libraries_dir = resolved_paths.libraries.clone(); let mut native_libs_paths = Vec::new(); // Store paths to native jars for extraction for lib in &version_details.libraries { @@ -380,12 +477,7 @@ async fn start_game( // --- Assets --- println!("Fetching asset index..."); - // Use shared caches for assets if enabled - let assets_dir = if config.use_shared_caches { - app_handle.path().app_data_dir().unwrap().join("assets") - } else { - game_dir.join("assets") - }; + let assets_dir = resolved_paths.assets.clone(); let objects_dir = assets_dir.join("objects"); let indexes_dir = assets_dir.join("indexes"); @@ -772,6 +864,15 @@ async fn start_game( .take() .expect("child did not have a handle to stderr"); + { + let mut running_game = game_process_state.running_game.lock().await; + *running_game = Some(RunningGameProcess { + child, + instance_id: instance_id.clone(), + version_id: version_id.clone(), + }); + } + // Emit launcher log that game is running emit_log!( window, @@ -793,6 +894,7 @@ async fn start_game( let window_rx_err = window.clone(); let assistant_arc_err = assistant_state.assistant.clone(); let window_exit = window.clone(); + let app_handle_exit = app_handle.clone(); tokio::spawn(async move { let mut reader = BufReader::new(stderr).lines(); while let Ok(Some(line)) = reader.next_line().await { @@ -804,17 +906,64 @@ async fn start_game( }); // Monitor game process exit + let launch_instance_id = instance_id.clone(); + let launch_version_id = version_id.clone(); tokio::spawn(async move { - match child.wait().await { - Ok(status) => { - let msg = format!("Game process exited with status: {}", status); - let _ = window_exit.emit("launcher-log", &msg); - let _ = window_exit.emit("game-exited", status.code().unwrap_or(-1)); - } - Err(e) => { - let msg = format!("Error waiting for game process: {}", e); + loop { + let exit_event = { + let state: State<'_, GameProcessState> = app_handle_exit.state(); + let mut running_game = state.running_game.lock().await; + + let Some(active_game) = running_game.as_mut() else { + break; + }; + + if active_game.instance_id != launch_instance_id { + break; + } + + match active_game.child.try_wait() { + Ok(Some(status)) => { + let exit_code = status.code(); + *running_game = None; + Some(GameExitedEvent { + instance_id: launch_instance_id.clone(), + version_id: launch_version_id.clone(), + exit_code, + was_stopped: false, + }) + } + Ok(None) => None, + Err(error) => { + let _ = window_exit.emit( + "launcher-log", + format!("Error waiting for game process: {}", error), + ); + *running_game = None; + Some(GameExitedEvent { + instance_id: launch_instance_id.clone(), + version_id: launch_version_id.clone(), + exit_code: None, + was_stopped: false, + }) + } + } + }; + + if let Some(event) = exit_event { + let msg = format!( + "Game process exited for instance {} with status {:?}", + event.instance_id, event.exit_code + ); let _ = window_exit.emit("launcher-log", &msg); + let _ = window_exit.emit("game-exited", &event); + + let state: State<core::instance::InstanceState> = window_exit.app_handle().state(); + state.end_operation(&event.instance_id); + break; } + + sleep(Duration::from_millis(500)).await; } }); @@ -825,6 +974,70 @@ async fn start_game( } Ok(format!("Launched Minecraft {} successfully!", version_id)) + } + .await; + + if launch_result.is_err() { + instance_state.end_operation(&instance_id); + } + + launch_result +} + +#[tauri::command] +#[dropout_macros::api] +async fn stop_game( + window: Window, + game_process_state: State<'_, GameProcessState>, + instance_state: State<'_, core::instance::InstanceState>, +) -> Result<String, String> { + let mut running_game = { + let mut state = game_process_state.running_game.lock().await; + state.take().ok_or("No running game process found")? + }; + + emit_log!( + window, + format!( + "Stopping game process for instance {}...", + running_game.instance_id + ) + ); + + let exit_code = match running_game.child.try_wait() { + Ok(Some(status)) => status.code(), + Ok(None) => { + running_game + .child + .start_kill() + .map_err(|e| format!("Failed to stop game process: {}", e))?; + + running_game + .child + .wait() + .await + .map_err(|e| format!("Failed while waiting for the game to stop: {}", e))? + .code() + } + Err(error) => { + return Err(format!("Failed to inspect running game process: {}", error)); + } + }; + + let event = GameExitedEvent { + instance_id: running_game.instance_id.clone(), + version_id: running_game.version_id.clone(), + exit_code, + was_stopped: true, + }; + + let _ = window.emit("game-exited", &event); + instance_state.end_operation(&running_game.instance_id); + + Ok(format!( + "Stopped Minecraft {} for instance {}", + running_game.version_id, running_game.instance_id + )) } /// Parse JVM arguments from version.json @@ -911,12 +1124,14 @@ async fn get_versions() -> Result<Vec<core::manifest::Version>, String> { #[dropout_macros::api] async fn get_versions_of_instance( _window: Window, + config_state: State<'_, core::config::ConfigState>, instance_state: State<'_, core::instance::InstanceState>, instance_id: String, ) -> Result<Vec<core::manifest::Version>, String> { - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; + let config = config_state.config.lock().unwrap().clone(); + let app_handle = _window.app_handle(); + let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?; + let game_dir = resolved_paths.root.clone(); match core::manifest::fetch_version_manifest().await { Ok(manifest) => { @@ -925,9 +1140,12 @@ async fn get_versions_of_instance( // 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 version_dir = resolved_paths.metadata_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)); + let client_jar_path = resolved_paths + .version_cache + .join(&version.id) + .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(); @@ -956,35 +1174,18 @@ async fn get_versions_of_instance( #[dropout_macros::api] async fn check_version_installed( _window: Window, + config_state: State<'_, core::config::ConfigState>, instance_state: State<'_, core::instance::InstanceState>, instance_id: String, version_id: String, ) -> Result<bool, String> { - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; - - // 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('-') - .next_back() - .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 config = config_state.config.lock().unwrap().clone(); + let app_handle = _window.app_handle(); + let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?; + let minecraft_version = resolve_minecraft_version(&version_id); - let client_jar = game_dir - .join("versions") + let client_jar = resolved_paths + .version_cache .join(&minecraft_version) .join(format!("{}.jar", minecraft_version)); @@ -1010,310 +1211,295 @@ async fn install_version( ); let config = config_state.config.lock().unwrap().clone(); + let app_handle = window.app_handle(); + instance_state.begin_operation(&instance_id, core::instance::InstanceOperation::Install)?; - // Get game directory from instance - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; - - // 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())?; + let install_result: Result<(), String> = async { + let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?; + let game_dir = resolved_paths.root.clone(); - // 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 - } - }; + // Ensure game directory exists + tokio::fs::create_dir_all(&game_dir) + .await + .map_err(|e| e.to_string())?; - // 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!("Game directory: {:?}", game_dir)); - emit_log!( - window, - format!( - "Version details loaded: main class = {}", - version_details.main_class - ) - ); + // Load version (supports both vanilla and modded versions with inheritance) + emit_log!( + window, + format!("Loading version details for {}...", version_id) + ); - // Determine the actual minecraft version for client.jar - let minecraft_version = version_details - .inherits_from - .clone() - .unwrap_or_else(|| version_id.clone()); + // 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 + } + }; - // Prepare download tasks - emit_log!(window, "Preparing download tasks...".to_string()); - let mut download_tasks = Vec::new(); + // 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())?; - // --- Client Jar --- - let downloads = version_details - .downloads - .as_ref() - .ok_or("Version has no downloads information")?; - let client_jar = &downloads.client; - // Use shared caches for versions if enabled - let mut client_path = if config.use_shared_caches { - window - .app_handle() - .path() - .app_data_dir() - .unwrap() - .join("versions") - } else { - game_dir.join("versions") - }; - client_path.push(&minecraft_version); - client_path.push(format!("{}.jar", minecraft_version)); + emit_log!( + window, + format!( + "Version details loaded: main class = {}", + version_details.main_class + ) + ); - download_tasks.push(core::downloader::DownloadTask { - url: client_jar.url.clone(), - path: client_path.clone(), - sha1: client_jar.sha1.clone(), - sha256: None, - }); + // 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 = resolved_paths.version_cache.clone(); + client_path.push(&minecraft_version); + client_path.push(format!("{}.jar", minecraft_version)); - // --- Libraries --- - // Use shared caches for libraries if enabled - let libraries_dir = if config.use_shared_caches { - window - .app_handle() - .path() - .app_data_dir() - .unwrap() - .join("libraries") - } else { - game_dir.join("libraries") - }; + download_tasks.push(core::downloader::DownloadTask { + url: client_jar.url.clone(), + path: client_path.clone(), + sha1: client_jar.sha1.clone(), + sha256: None, + }); - for lib in &version_details.libraries { - if core::rules::is_library_allowed(&lib.rules, Some(&config.feature_flags)) { - 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)); + // --- Libraries --- + let libraries_dir = resolved_paths.libraries.clone(); - let mut lib_path = libraries_dir.clone(); - lib_path.push(path_str); + for lib in &version_details.libraries { + if core::rules::is_library_allowed(&lib.rules, Some(&config.feature_flags)) { + 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)); - download_tasks.push(core::downloader::DownloadTask { - url: artifact.url.clone(), - path: lib_path, - sha1: artifact.sha1.clone(), - sha256: None, - }); - } + let mut lib_path = libraries_dir.clone(); + lib_path.push(path_str); - // Native Library (classifiers) - if let Some(classifiers) = &downloads.classifiers { - // Determine candidate keys based on OS and architecture - let arch = std::env::consts::ARCH; - let mut candidates: Vec<String> = Vec::new(); - if cfg!(target_os = "linux") { - candidates.push("natives-linux".to_string()); - candidates.push(format!("natives-linux-{}", arch)); - if arch == "aarch64" { - candidates.push("natives-linux-arm64".to_string()); - } - } else if cfg!(target_os = "windows") { - candidates.push("natives-windows".to_string()); - candidates.push(format!("natives-windows-{}", arch)); - } else if cfg!(target_os = "macos") { - candidates.push("natives-osx".to_string()); - candidates.push("natives-macos".to_string()); - candidates.push(format!("natives-macos-{}", arch)); + download_tasks.push(core::downloader::DownloadTask { + url: artifact.url.clone(), + path: lib_path, + sha1: artifact.sha1.clone(), + sha256: None, + }); } - // Pick the first available classifier key - let mut chosen: Option<core::game_version::DownloadArtifact> = None; - for key in candidates { - if let Some(native_artifact_value) = classifiers.get(&key) { - if let Ok(artifact) = - serde_json::from_value::<core::game_version::DownloadArtifact>( - native_artifact_value.clone(), - ) - { - chosen = Some(artifact); - break; + // Native Library (classifiers) + if let Some(classifiers) = &downloads.classifiers { + // Determine candidate keys based on OS and architecture + let arch = std::env::consts::ARCH; + let mut candidates: Vec<String> = Vec::new(); + if cfg!(target_os = "linux") { + candidates.push("natives-linux".to_string()); + candidates.push(format!("natives-linux-{}", arch)); + if arch == "aarch64" { + candidates.push("natives-linux-arm64".to_string()); } + } else if cfg!(target_os = "windows") { + candidates.push("natives-windows".to_string()); + candidates.push(format!("natives-windows-{}", arch)); + } else if cfg!(target_os = "macos") { + candidates.push("natives-osx".to_string()); + candidates.push("natives-macos".to_string()); + candidates.push(format!("natives-macos-{}", arch)); } - } - if let Some(native_artifact) = chosen { - let path_str = native_artifact.path.clone().unwrap(); - let mut native_path = libraries_dir.clone(); - native_path.push(&path_str); + // Pick the first available classifier key + let mut chosen: Option<core::game_version::DownloadArtifact> = None; + for key in candidates { + if let Some(native_artifact_value) = classifiers.get(&key) { + if let Ok(artifact) = + serde_json::from_value::<core::game_version::DownloadArtifact>( + native_artifact_value.clone(), + ) + { + chosen = Some(artifact); + break; + } + } + } - download_tasks.push(core::downloader::DownloadTask { - url: native_artifact.url, - path: native_path.clone(), - sha1: native_artifact.sha1, - sha256: None, - }); + if let Some(native_artifact) = chosen { + 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) + } else { + // Library without explicit downloads (mod loader libraries) + if let Some(url) = + core::maven::resolve_library_url(&lib.name, None, lib.url.as_deref()) { - download_tasks.push(core::downloader::DownloadTask { - url, - path: lib_path, - sha1: None, - sha256: None, - }); + 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 --- - // Use shared caches for assets if enabled - let assets_dir = if config.use_shared_caches { - window - .app_handle() - .path() - .app_data_dir() - .unwrap() - .join("assets") - } else { - game_dir.join("assets") - }; - let objects_dir = assets_dir.join("objects"); - let indexes_dir = assets_dir.join("indexes"); + // --- Assets --- + let assets_dir = resolved_paths.assets.clone(); + 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 = version_details + .asset_index + .as_ref() + .ok_or("Version has no asset index information")?; - 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())?; + let asset_index_path = indexes_dir.join(format!("{}.json", asset_index.id)); - 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 - }; + 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())?; - #[derive(serde::Deserialize)] - struct AssetObject { - hash: 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 AssetIndexJson { - objects: std::collections::HashMap<String, AssetObject>, - } + #[derive(serde::Deserialize)] + struct AssetObject { + hash: String, + } - let asset_index_parsed: AssetIndexJson = - serde_json::from_str(&asset_index_content).map_err(|e| e.to_string())?; + #[derive(serde::Deserialize)] + struct AssetIndexJson { + objects: std::collections::HashMap<String, AssetObject>, + } - emit_log!( - window, - format!("Processing {} assets...", asset_index_parsed.objects.len()) - ); + let asset_index_parsed: AssetIndexJson = + serde_json::from_str(&asset_index_content).map_err(|e| e.to_string())?; - 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 + emit_log!( + window, + format!("Processing {} assets...", asset_index_parsed.objects.len()) ); - download_tasks.push(core::downloader::DownloadTask { - url, - path, - sha1: Some(hash), - sha256: None, - }); - } + 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 + ); - emit_log!( - window, - format!( - "Total download tasks: {} (Client + Libraries + Assets)", - download_tasks.len() - ) - ); + download_tasks.push(core::downloader::DownloadTask { + url, + path, + sha1: Some(hash), + sha256: None, + }); + } - // Start Download - emit_log!( - window, - format!( - "Starting downloads with {} concurrent threads...", - config.download_threads + 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, ) - ); - core::downloader::download_files( - window.clone(), - download_tasks, - config.download_threads as usize, - ) - .await - .map_err(|e| e.to_string())?; + .await + .map_err(|e| e.to_string())?; - emit_log!( - window, - format!("Installation of {} completed successfully!", version_id) - ); + emit_log!( + window, + format!("Installation of {} completed successfully!", version_id) + ); - // Emit event to notify frontend that version installation is complete - let _ = window.emit("version-installed", &version_id); + if let Some(mut instance) = instance_state.get_instance(&instance_id) { + instance.version_id = Some(version_id.clone()); + instance.mod_loader = Some("vanilla".to_string()); + instance.mod_loader_version = None; + instance_state.update_instance(instance)?; + } - Ok(()) + // Emit event to notify frontend that version installation is complete + let _ = window.emit("version-installed", &version_id); + + Ok(()) + } + .await; + + instance_state.end_operation(&instance_id); + install_result } #[tauri::command] @@ -1707,31 +1893,39 @@ async fn install_fabric( ) ); - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; + instance_state.begin_operation(&instance_id, core::instance::InstanceOperation::Install)?; - let result = core::fabric::install_fabric(&game_dir, &game_version, &loader_version) - .await - .map_err(|e| e.to_string())?; + let install_result: Result<core::fabric::InstalledFabricVersion, String> = async { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; - emit_log!( - window, - format!("Fabric installed successfully: {}", result.id) - ); + let result = core::fabric::install_fabric(&game_dir, &game_version, &loader_version) + .await + .map_err(|e| e.to_string())?; - // Update Instance's mod_loader metadata and version_id - if let Some(mut instance) = instance_state.get_instance(&instance_id) { - instance.mod_loader = Some("fabric".to_string()); - instance.mod_loader_version = Some(loader_version.clone()); - instance.version_id = Some(result.id.clone()); - instance_state.update_instance(instance)?; - } + emit_log!( + window, + format!("Fabric installed successfully: {}", result.id) + ); - // Emit event to notify frontend - let _ = window.emit("fabric-installed", &result.id); + // Update Instance's mod_loader metadata and version_id + if let Some(mut instance) = instance_state.get_instance(&instance_id) { + instance.mod_loader = Some("fabric".to_string()); + instance.mod_loader_version = Some(loader_version.clone()); + instance.version_id = Some(result.id.clone()); + instance_state.update_instance(instance)?; + } - Ok(result) + // Emit event to notify frontend + let _ = window.emit("fabric-installed", &result.id); + + Ok(result) + } + .await; + + instance_state.end_operation(&instance_id); + install_result } /// List installed Fabric versions @@ -1786,15 +1980,15 @@ struct VersionMetadata { #[dropout_macros::api] async fn delete_version( window: Window, + config_state: State<'_, core::config::ConfigState>, instance_state: State<'_, core::instance::InstanceState>, instance_id: String, version_id: String, ) -> Result<(), String> { - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; - - let version_dir = game_dir.join("versions").join(&version_id); + let config = config_state.config.lock().unwrap().clone(); + let app_handle = window.app_handle(); + let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?; + let version_dir = resolved_paths.metadata_versions.join(&version_id); if !version_dir.exists() { return Err(format!("Version {} not found", version_id)); @@ -1841,13 +2035,15 @@ async fn delete_version( #[dropout_macros::api] async fn get_version_metadata( _window: Window, + config_state: State<'_, core::config::ConfigState>, instance_state: State<'_, core::instance::InstanceState>, instance_id: String, version_id: String, ) -> Result<VersionMetadata, String> { - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; + let config = config_state.config.lock().unwrap().clone(); + let app_handle = _window.app_handle(); + let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?; + let game_dir = resolved_paths.root.clone(); // Initialize metadata let mut metadata = VersionMetadata { @@ -1868,35 +2064,15 @@ async fn get_version_metadata( } // Check if version is installed (both JSON and client jar must exist) - let version_dir = game_dir.join("versions").join(&version_id); + let version_dir = resolved_paths.metadata_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)) - }; + let minecraft_version = resolve_minecraft_version(&version_id); + let client_jar_path = resolved_paths + .version_cache + .join(&minecraft_version) + .join(format!("{}.jar", minecraft_version)); metadata.is_installed = json_path.exists() && client_jar_path.exists(); @@ -2081,83 +2257,91 @@ async fn install_forge( ) ); - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; + instance_state.begin_operation(&instance_id, core::instance::InstanceOperation::Install)?; - // Get Java path from config or detect - let config = config_state.config.lock().unwrap().clone(); - let app_handle = window.app_handle(); - 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).await; - if let Some(java) = javas.first() { - java.path.clone() + let install_result: Result<core::forge::InstalledForgeVersion, String> = async { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; + + // Get Java path from config or detect + let config = config_state.config.lock().unwrap().clone(); + let app_handle = window.app_handle(); + let java_path_str = if !config.java_path.is_empty() && config.java_path != "java" { + config.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)?; + // Try to find a suitable Java installation + let javas = core::java::detect_all_java_installations(app_handle).await; + 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)?; - emit_log!(window, "Running Forge installer...".to_string()); + 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))?; + // 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() - ); + emit_log!( + window, + "Forge installer completed, creating version profile...".to_string() + ); - // Check if the version JSON already exists - let version_id = core::forge::generate_version_id(&game_version, &forge_version); - let json_path = game_dir - .join("versions") - .join(&version_id) - .join(format!("{}.json", version_id)); + // Check if the version JSON already exists + let version_id = core::forge::generate_version_id(&game_version, &forge_version); + let json_path = game_dir + .join("versions") + .join(&version_id) + .join(format!("{}.json", version_id)); + + let result = if json_path.exists() { + // Version JSON was created by the installer, load it + emit_log!( + window, + "Using version profile created by Forge installer".to_string() + ); + core::forge::InstalledForgeVersion { + id: version_id, + minecraft_version: game_version.clone(), + forge_version: forge_version.clone(), + path: json_path, + } + } else { + // Installer didn't create JSON, create it manually + core::forge::install_forge(&game_dir, &game_version, &forge_version) + .await + .map_err(|e| e.to_string())? + }; - let result = if json_path.exists() { - // Version JSON was created by the installer, load it emit_log!( window, - "Using version profile created by Forge installer".to_string() + format!("Forge installed successfully: {}", result.id) ); - core::forge::InstalledForgeVersion { - id: version_id, - minecraft_version: game_version.clone(), - forge_version: forge_version.clone(), - path: json_path, + + // Update Instance's mod_loader metadata and version_id + if let Some(mut instance) = instance_state.get_instance(&instance_id) { + instance.mod_loader = Some("forge".to_string()); + instance.mod_loader_version = Some(forge_version.clone()); + instance.version_id = Some(result.id.clone()); + instance_state.update_instance(instance)?; } - } else { - // Installer didn't create JSON, create it manually - core::forge::install_forge(&game_dir, &game_version, &forge_version) - .await - .map_err(|e| e.to_string())? - }; - emit_log!( - window, - format!("Forge installed successfully: {}", result.id) - ); + // Emit event to notify frontend + let _ = window.emit("forge-installed", &result.id); - // Update Instance's mod_loader metadata and version_id - if let Some(mut instance) = instance_state.get_instance(&instance_id) { - instance.mod_loader = Some("forge".to_string()); - instance.mod_loader_version = Some(forge_version.clone()); - instance.version_id = Some(result.id.clone()); - instance_state.update_instance(instance)?; + Ok(result) } + .await; - // Emit event to notify frontend - let _ = window.emit("forge-installed", &result.id); - - Ok(result) + instance_state.end_operation(&instance_id); + install_result } #[derive(serde::Serialize, TS)] @@ -2416,6 +2600,43 @@ async fn duplicate_instance( state.duplicate_instance(&instance_id, new_name, app_handle) } +/// Export an instance to a zip archive +#[tauri::command] +#[dropout_macros::api] +async fn export_instance( + state: State<'_, core::instance::InstanceState>, + instance_id: String, + archive_path: String, +) -> Result<String, String> { + state + .export_instance(&instance_id, std::path::Path::new(&archive_path)) + .map(|path| path.to_string_lossy().to_string()) +} + +/// Import an instance from a zip archive +#[tauri::command] +#[dropout_macros::api] +async fn import_instance( + window: Window, + state: State<'_, core::instance::InstanceState>, + archive_path: String, + new_name: Option<String>, +) -> Result<core::instance::Instance, String> { + let app_handle = window.app_handle(); + state.import_instance(std::path::Path::new(&archive_path), app_handle, new_name) +} + +/// Repair instance index from on-disk directories +#[tauri::command] +#[dropout_macros::api] +async fn repair_instances( + window: Window, + state: State<'_, core::instance::InstanceState>, +) -> Result<core::instance::InstanceRepairResult, String> { + let app_handle = window.app_handle(); + state.repair_instances(app_handle) +} + #[tauri::command] #[dropout_macros::api] async fn assistant_chat_stream( @@ -2467,11 +2688,11 @@ async fn migrate_shared_caches( ); // Automatically enable shared caches config - let mut config = config_state.config.lock().unwrap().clone(); - config.use_shared_caches = true; - drop(config); - *config_state.config.lock().unwrap() = config_state.config.lock().unwrap().clone(); - config_state.config.lock().unwrap().use_shared_caches = true; + { + let mut config = config_state.config.lock().unwrap(); + config.use_shared_caches = true; + config.keep_legacy_per_instance_storage = false; + } config_state.save()?; Ok(MigrationResult { @@ -2499,15 +2720,15 @@ struct FileInfo { #[tauri::command] #[dropout_macros::api] async fn list_instance_directory( + app: Window, + config_state: State<'_, core::config::ConfigState>, instance_state: State<'_, core::instance::InstanceState>, instance_id: String, folder: String, // "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots" ) -> Result<Vec<FileInfo>, String> { - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; - - let target_dir = game_dir.join(&folder); + let config = config_state.config.lock().unwrap().clone(); + let target_dir = + instance_state.resolve_directory(&instance_id, &folder, &config, app.app_handle())?; if !target_dir.exists() { tokio::fs::create_dir_all(&target_dir) .await @@ -2599,6 +2820,7 @@ fn main() { .plugin(tauri_plugin_shell::init()) .manage(core::auth::AccountState::new()) .manage(MsRefreshTokenState::new()) + .manage(GameProcessState::new()) .manage(core::assistant::AssistantState::new()) .setup(|app| { let config_state = core::config::ConfigState::new(app.handle()); @@ -2643,6 +2865,7 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ start_game, + stop_game, get_versions, get_versions_of_instance, check_version_installed, @@ -2700,6 +2923,9 @@ fn main() { set_active_instance, get_active_instance, duplicate_instance, + export_instance, + import_instance, + repair_instances, migrate_shared_caches, list_instance_directory, delete_instance_file, |