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 | |
| 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')
| -rw-r--r-- | src-tauri/Cargo.toml | 1 | ||||
| -rw-r--r-- | src-tauri/build.rs | 7 | ||||
| -rw-r--r-- | src-tauri/capabilities/default.json | 8 | ||||
| -rw-r--r-- | src-tauri/src/core/instance.rs | 516 | ||||
| -rw-r--r-- | src-tauri/src/core/modpack.rs | 7 | ||||
| -rw-r--r-- | src-tauri/src/main.rs | 1124 | ||||
| -rw-r--r-- | src-tauri/tauri.conf.json | 3 |
7 files changed, 1184 insertions, 482 deletions
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b375c6e..ccec463 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -52,6 +52,7 @@ ctor = "0.6.3" inventory = "0.3.21" [build-dependencies] +dotenvy = { version = "0.15", default-features = false } tauri-build = { version = "2.0", features = [] } [target.'cfg(all(windows, target_env = "gnu"))'.build-dependencies] diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 63f98e2..00f5755 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,4 +1,11 @@ fn main() { + // Load .env file if present so optional build-time vars (e.g. CURSEFORGE_API_KEY) + // are available to option_env!() without requiring CI to have a real .env file. + if let Ok(path) = dotenvy::dotenv() { + println!("cargo:rerun-if-changed={}", path.display()); + } + println!("cargo:rerun-if-env-changed=CURSEFORGE_API_KEY"); + // For MinGW targets, use embed-resource to generate proper COFF format #[cfg(all(windows, target_env = "gnu"))] { diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 81975f2..1b67261 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -3,6 +3,14 @@ "identifier": "default", "description": "Default capabilities for the DropOut launcher", "windows": ["main"], + "remote": { + "urls": [ + "http://127.0.0.1:5173", + "http://127.0.0.1:5173/*", + "http://localhost:5173", + "http://localhost:5173/*" + ] + }, "permissions": [ "core:default", "core:event:default", diff --git a/src-tauri/src/core/instance.rs b/src-tauri/src/core/instance.rs index 0237270..bc303c4 100644 --- a/src-tauri/src/core/instance.rs +++ b/src-tauri/src/core/instance.rs @@ -5,13 +5,16 @@ //! - Each instance has its own versions, libraries, assets, mods, and saves //! - Support for instance switching and isolation +use crate::core::config::LauncherConfig; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use tauri::{AppHandle, Manager}; use ts_rs::TS; +use zip::write::SimpleFileOptions; /// Represents a game instance/profile #[derive(Debug, Clone, Serialize, Deserialize, TS)] @@ -52,10 +55,69 @@ pub struct InstanceConfig { pub active_instance_id: Option<String>, // 当前活动的实例ID } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "instance.ts")] +pub struct InstanceRepairResult { + pub restored_instances: usize, + pub removed_stale_entries: usize, + pub created_default_active: bool, + pub active_instance_id: Option<String>, +} + +#[derive(Debug, Clone)] +pub struct InstancePaths { + pub root: PathBuf, + pub metadata_versions: PathBuf, + pub version_cache: PathBuf, + pub libraries: PathBuf, + pub assets: PathBuf, + pub mods: PathBuf, + pub config: PathBuf, + pub saves: PathBuf, + pub resourcepacks: PathBuf, + pub shaderpacks: PathBuf, + pub screenshots: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstanceOperation { + Launch, + Install, + Delete, + ImportExport, +} + +impl InstanceOperation { + fn label(self) -> &'static str { + match self { + Self::Launch => "launching", + Self::Install => "installing", + Self::Delete => "deleting", + Self::ImportExport => "importing or exporting", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ExportedInstance { + name: String, + version_id: Option<String>, + icon_path: Option<String>, + notes: Option<String>, + mod_loader: Option<String>, + mod_loader_version: Option<String>, + jvm_args_override: Option<String>, + memory_override: Option<MemoryOverride>, + java_path_override: Option<String>, +} + /// State management for instances pub struct InstanceState { pub instances: Mutex<InstanceConfig>, pub file_path: PathBuf, + operation_locks: Mutex<HashMap<String, InstanceOperation>>, } impl InstanceState { @@ -74,7 +136,158 @@ impl InstanceState { Self { instances: Mutex::new(config), file_path, + operation_locks: Mutex::new(HashMap::new()), + } + } + + fn app_dir(app_handle: &AppHandle) -> Result<PathBuf, String> { + app_handle.path().app_data_dir().map_err(|e| e.to_string()) + } + + fn instances_dir(app_handle: &AppHandle) -> Result<PathBuf, String> { + Ok(Self::app_dir(app_handle)?.join("instances")) + } + + fn validate_instance_name( + config: &InstanceConfig, + name: &str, + exclude_id: Option<&str>, + ) -> Result<(), String> { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("Instance name cannot be empty".to_string()); + } + + let duplicated = config.instances.iter().any(|instance| { + if let Some(exclude_id) = exclude_id { + if instance.id == exclude_id { + return false; + } + } + + instance.name.trim().eq_ignore_ascii_case(trimmed) + }); + + if duplicated { + return Err(format!("Instance \"{}\" already exists", trimmed)); + } + + Ok(()) + } + + fn create_instance_directory_structure(instance_dir: &Path) -> Result<(), String> { + fs::create_dir_all(instance_dir).map_err(|e| e.to_string())?; + + for folder in [ + "versions", + "libraries", + "assets", + "mods", + "config", + "saves", + "resourcepacks", + "shaderpacks", + "screenshots", + "logs", + ] { + fs::create_dir_all(instance_dir.join(folder)).map_err(|e| e.to_string())?; + } + + Ok(()) + } + + fn insert_instance( + &self, + instance: Instance, + set_active_when_empty: bool, + ) -> Result<(), String> { + let mut config = self.instances.lock().unwrap(); + Self::validate_instance_name(&config, &instance.name, Some(&instance.id))?; + config.instances.push(instance.clone()); + + if set_active_when_empty && config.active_instance_id.is_none() { + config.active_instance_id = Some(instance.id); + } + + drop(config); + self.save() + } + + pub fn begin_operation(&self, id: &str, operation: InstanceOperation) -> Result<(), String> { + let mut locks = self.operation_locks.lock().unwrap(); + if let Some(active) = locks.get(id) { + return Err(format!("Instance {} is busy: {}", id, active.label())); } + + locks.insert(id.to_string(), operation); + Ok(()) + } + + pub fn end_operation(&self, id: &str) { + self.operation_locks.lock().unwrap().remove(id); + } + + pub fn resolve_paths( + &self, + id: &str, + config: &LauncherConfig, + app_handle: &AppHandle, + ) -> Result<InstancePaths, String> { + let instance = self + .get_instance(id) + .ok_or_else(|| format!("Instance {} not found", id))?; + let shared_root = Self::app_dir(app_handle)?; + + Ok(InstancePaths { + root: instance.game_dir.clone(), + metadata_versions: instance.game_dir.join("versions"), + version_cache: if config.use_shared_caches { + shared_root.join("versions") + } else { + instance.game_dir.join("versions") + }, + libraries: if config.use_shared_caches { + shared_root.join("libraries") + } else { + instance.game_dir.join("libraries") + }, + assets: if config.use_shared_caches { + shared_root.join("assets") + } else { + instance.game_dir.join("assets") + }, + mods: instance.game_dir.join("mods"), + config: instance.game_dir.join("config"), + saves: instance.game_dir.join("saves"), + resourcepacks: instance.game_dir.join("resourcepacks"), + shaderpacks: instance.game_dir.join("shaderpacks"), + screenshots: instance.game_dir.join("screenshots"), + }) + } + + pub fn resolve_directory( + &self, + id: &str, + folder: &str, + config: &LauncherConfig, + app_handle: &AppHandle, + ) -> Result<PathBuf, String> { + let paths = self.resolve_paths(id, config, app_handle)?; + let resolved = match folder { + "versions" => paths.metadata_versions, + "version-cache" => paths.version_cache, + "libraries" => paths.libraries, + "assets" => paths.assets, + "mods" => paths.mods, + "config" => paths.config, + "saves" => paths.saves, + "resourcepacks" => paths.resourcepacks, + "shaderpacks" => paths.shaderpacks, + "screenshots" => paths.screenshots, + other => paths.root.join(other), + }; + + Ok(resolved) } /// Save the instance configuration to disk @@ -92,23 +305,22 @@ impl InstanceState { name: String, app_handle: &AppHandle, ) -> Result<Instance, String> { - let app_dir = app_handle.path().app_data_dir().unwrap(); + let trimmed_name = name.trim().to_string(); + { + let config = self.instances.lock().unwrap(); + Self::validate_instance_name(&config, &trimmed_name, None)?; + } + + let app_dir = Self::app_dir(app_handle)?; let instance_id = uuid::Uuid::new_v4().to_string(); let instance_dir = app_dir.join("instances").join(&instance_id); let game_dir = instance_dir.clone(); - // Create instance directory structure - fs::create_dir_all(&instance_dir).map_err(|e| e.to_string())?; - fs::create_dir_all(instance_dir.join("versions")).map_err(|e| e.to_string())?; - fs::create_dir_all(instance_dir.join("libraries")).map_err(|e| e.to_string())?; - fs::create_dir_all(instance_dir.join("assets")).map_err(|e| e.to_string())?; - fs::create_dir_all(instance_dir.join("mods")).map_err(|e| e.to_string())?; - fs::create_dir_all(instance_dir.join("config")).map_err(|e| e.to_string())?; - fs::create_dir_all(instance_dir.join("saves")).map_err(|e| e.to_string())?; + Self::create_instance_directory_structure(&instance_dir)?; let instance = Instance { id: instance_id.clone(), - name, + name: trimmed_name, game_dir, version_id: None, created_at: chrono::Utc::now().timestamp(), @@ -122,22 +334,14 @@ impl InstanceState { java_path_override: None, }; - let mut config = self.instances.lock().unwrap(); - config.instances.push(instance.clone()); - - // If this is the first instance, set it as active - if config.active_instance_id.is_none() { - config.active_instance_id = Some(instance_id); - } - - drop(config); - self.save()?; + self.insert_instance(instance.clone(), true)?; Ok(instance) } /// Delete an instance pub fn delete_instance(&self, id: &str) -> Result<(), String> { + self.begin_operation(id, InstanceOperation::Delete)?; let mut config = self.instances.lock().unwrap(); // Find the instance @@ -166,6 +370,8 @@ impl InstanceState { .map_err(|e| format!("Failed to delete instance directory: {}", e))?; } + self.end_operation(id); + Ok(()) } @@ -179,7 +385,13 @@ impl InstanceState { .position(|i| i.id == instance.id) .ok_or_else(|| format!("Instance {} not found", instance.id))?; - config.instances[index] = instance; + Self::validate_instance_name(&config, &instance.name, Some(&instance.id))?; + + let existing = config.instances[index].clone(); + let mut updated = instance; + updated.game_dir = existing.game_dir; + updated.created_at = existing.created_at; + config.instances[index] = updated; drop(config); self.save()?; @@ -236,17 +448,34 @@ impl InstanceState { new_name: String, app_handle: &AppHandle, ) -> Result<Instance, String> { + // Local RAII guard to ensure end_operation is always called + struct OperationGuard<'a> { + manager: &'a InstanceState, + id: &'a str, + } + + impl<'a> Drop for OperationGuard<'a> { + fn drop(&mut self) { + // This will run on all exit paths from duplicate_instance + self.manager.end_operation(self.id); + } + } + + self.begin_operation(id, InstanceOperation::ImportExport)?; + let _operation_guard = OperationGuard { manager: self, id }; + let source_instance = self .get_instance(id) .ok_or_else(|| format!("Instance {} not found", id))?; + { + let config = self.instances.lock().unwrap(); + Self::validate_instance_name(&config, &new_name, None)?; + } + // Prepare new instance metadata (but don't save yet) let new_id = uuid::Uuid::new_v4().to_string(); - let instances_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| e.to_string())? - .join("instances"); + let instances_dir = Self::instances_dir(app_handle)?; let new_game_dir = instances_dir.join(&new_id); // Copy directory FIRST - if this fails, don't create metadata @@ -255,14 +484,13 @@ impl InstanceState { .map_err(|e| format!("Failed to copy instance directory: {}", e))?; } else { // If source dir doesn't exist, create new empty game dir - std::fs::create_dir_all(&new_game_dir) - .map_err(|e| format!("Failed to create instance directory: {}", e))?; + Self::create_instance_directory_structure(&new_game_dir)?; } // NOW create metadata and save let new_instance = Instance { id: new_id, - name: new_name, + name: new_name.trim().to_string(), game_dir: new_game_dir, version_id: source_instance.version_id.clone(), mod_loader: source_instance.mod_loader.clone(), @@ -279,10 +507,238 @@ impl InstanceState { java_path_override: source_instance.java_path_override.clone(), }; - self.update_instance(new_instance.clone())?; + self.insert_instance(new_instance.clone(), false)?; Ok(new_instance) } + + pub fn export_instance(&self, id: &str, archive_path: &Path) -> Result<PathBuf, String> { + self.begin_operation(id, InstanceOperation::ImportExport)?; + let instance = self + .get_instance(id) + .ok_or_else(|| format!("Instance {} not found", id))?; + + if let Some(parent) = archive_path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + + let file = fs::File::create(archive_path).map_err(|e| e.to_string())?; + let mut writer = zip::ZipWriter::new(file); + let options = SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .unix_permissions(0o644); + + let exported = ExportedInstance { + name: instance.name.clone(), + version_id: instance.version_id.clone(), + icon_path: instance.icon_path.clone(), + notes: instance.notes.clone(), + mod_loader: instance.mod_loader.clone(), + mod_loader_version: instance.mod_loader_version.clone(), + jvm_args_override: instance.jvm_args_override.clone(), + memory_override: instance.memory_override.clone(), + java_path_override: instance.java_path_override.clone(), + }; + + writer + .start_file("dropout-instance.json", options) + .map_err(|e| e.to_string())?; + writer + .write_all( + serde_json::to_string_pretty(&exported) + .map_err(|e| e.to_string())? + .as_bytes(), + ) + .map_err(|e| e.to_string())?; + + append_directory_to_zip(&mut writer, &instance.game_dir, &instance.game_dir, options)?; + writer.finish().map_err(|e| e.to_string())?; + self.end_operation(id); + + Ok(archive_path.to_path_buf()) + } + + pub fn import_instance( + &self, + archive_path: &Path, + app_handle: &AppHandle, + new_name: Option<String>, + ) -> Result<Instance, String> { + let file = fs::File::open(archive_path).map_err(|e| e.to_string())?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| e.to_string())?; + + let exported: ExportedInstance = { + let mut metadata = archive.by_name("dropout-instance.json").map_err(|_| { + "Invalid instance archive: missing dropout-instance.json".to_string() + })?; + let mut content = String::new(); + metadata + .read_to_string(&mut content) + .map_err(|e| e.to_string())?; + serde_json::from_str(&content).map_err(|e| e.to_string())? + }; + + let final_name = new_name.unwrap_or(exported.name.clone()); + { + let config = self.instances.lock().unwrap(); + Self::validate_instance_name(&config, &final_name, None)?; + } + + let imported = self.create_instance(final_name, app_handle)?; + self.begin_operation(&imported.id, InstanceOperation::ImportExport)?; + + for index in 0..archive.len() { + let mut entry = archive.by_index(index).map_err(|e| e.to_string())?; + let Some(enclosed_name) = entry.enclosed_name().map(|p| p.to_path_buf()) else { + continue; + }; + + if enclosed_name == PathBuf::from("dropout-instance.json") { + continue; + } + + let out_path = imported.game_dir.join(&enclosed_name); + if entry.name().ends_with('/') { + fs::create_dir_all(&out_path).map_err(|e| e.to_string())?; + continue; + } + + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + + let mut output = fs::File::create(&out_path).map_err(|e| e.to_string())?; + std::io::copy(&mut entry, &mut output).map_err(|e| e.to_string())?; + } + + let mut hydrated = imported.clone(); + hydrated.version_id = exported.version_id; + hydrated.icon_path = exported.icon_path; + hydrated.notes = exported.notes; + hydrated.mod_loader = exported.mod_loader; + hydrated.mod_loader_version = exported.mod_loader_version; + hydrated.jvm_args_override = exported.jvm_args_override; + hydrated.memory_override = exported.memory_override; + hydrated.java_path_override = exported.java_path_override; + self.update_instance(hydrated.clone())?; + self.end_operation(&imported.id); + + Ok(hydrated) + } + + pub fn repair_instances(&self, app_handle: &AppHandle) -> Result<InstanceRepairResult, String> { + let instances_dir = Self::instances_dir(app_handle)?; + fs::create_dir_all(&instances_dir).map_err(|e| e.to_string())?; + + let mut config = self.instances.lock().unwrap().clone(); + let mut restored_instances = 0usize; + let mut removed_stale_entries = 0usize; + + config.instances.retain(|instance| { + let keep = instance.game_dir.exists(); + if !keep { + removed_stale_entries += 1; + } + keep + }); + + let known_ids: std::collections::HashSet<String> = config + .instances + .iter() + .map(|instance| instance.id.clone()) + .collect(); + + for entry in fs::read_dir(&instances_dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + if !entry.file_type().map_err(|e| e.to_string())?.is_dir() { + continue; + } + + let id = entry.file_name().to_string_lossy().to_string(); + if known_ids.contains(&id) { + continue; + } + + let recovered = Instance { + id: id.clone(), + name: format!("Recovered {}", &id[..id.len().min(8)]), + game_dir: entry.path(), + version_id: None, + created_at: chrono::Utc::now().timestamp(), + last_played: None, + icon_path: None, + notes: Some("Recovered from instances directory".to_string()), + mod_loader: Some("vanilla".to_string()), + mod_loader_version: None, + jvm_args_override: None, + memory_override: None, + java_path_override: None, + }; + + config.instances.push(recovered); + restored_instances += 1; + } + + config + .instances + .sort_by(|left, right| left.created_at.cmp(&right.created_at)); + + let mut created_default_active = false; + if config.active_instance_id.is_none() + || !config + .instances + .iter() + .any(|instance| Some(&instance.id) == config.active_instance_id.as_ref()) + { + config.active_instance_id = + config.instances.first().map(|instance| instance.id.clone()); + created_default_active = config.active_instance_id.is_some(); + } + + *self.instances.lock().unwrap() = config.clone(); + drop(config); + self.save()?; + + Ok(InstanceRepairResult { + restored_instances, + removed_stale_entries, + created_default_active, + active_instance_id: self.get_active_instance().map(|instance| instance.id), + }) + } +} + +fn append_directory_to_zip( + writer: &mut zip::ZipWriter<fs::File>, + current_dir: &Path, + base_dir: &Path, + options: SimpleFileOptions, +) -> Result<(), String> { + if !current_dir.exists() { + return Ok(()); + } + + for entry in fs::read_dir(current_dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + let relative = path.strip_prefix(base_dir).map_err(|e| e.to_string())?; + let zip_name = relative.to_string_lossy().replace('\\', "/"); + + if path.is_dir() { + writer + .add_directory(format!("{}/", zip_name), options) + .map_err(|e| e.to_string())?; + append_directory_to_zip(writer, &path, base_dir, options)?; + } else { + writer + .start_file(zip_name, options) + .map_err(|e| e.to_string())?; + let data = fs::read(&path).map_err(|e| e.to_string())?; + writer.write_all(&data).map_err(|e| e.to_string())?; + } + } + + Ok(()) } /// Copy a directory recursively diff --git a/src-tauri/src/core/modpack.rs b/src-tauri/src/core/modpack.rs index 5ac9493..a580000 100644 --- a/src-tauri/src/core/modpack.rs +++ b/src-tauri/src/core/modpack.rs @@ -294,7 +294,7 @@ fn parse_multimc(archive: &mut Archive) -> Result<ParsedModpack, String> { // ── CurseForge API resolution ───────────────────────────────────────────── -const CURSEFORGE_API_KEY: &str = env!("CURSEFORGE_API_KEY"); +const CURSEFORGE_API_KEY: Option<&str> = option_env!("CURSEFORGE_API_KEY"); async fn resolve_curseforge_files(files: &[ModpackFile]) -> Result<Vec<ModpackFile>, String> { let file_ids: Vec<u64> = files @@ -368,9 +368,12 @@ async fn cf_post( endpoint: &str, body: &serde_json::Value, ) -> Result<serde_json::Value, String> { + let api_key = CURSEFORGE_API_KEY + .ok_or("CurseForge modpack support requires CURSEFORGE_API_KEY set at build time")?; + let resp = client .post(format!("https://api.curseforge.com{endpoint}")) - .header("x-api-key", CURSEFORGE_API_KEY) + .header("x-api-key", api_key) .json(body) .send() .await 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, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1c31a09..9ab9e6a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -5,7 +5,7 @@ "build": { "beforeDevCommand": "pnpm --filter @dropout/ui dev", "beforeBuildCommand": "pnpm --filter @dropout/ui build", - "devUrl": "http://localhost:5173", + "devUrl": "http://127.0.0.1:5173", "frontendDist": "../packages/ui/dist" }, "app": { @@ -20,6 +20,7 @@ } ], "security": { + "devCsp": null, "csp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:;", "capabilities": ["default"] } |