aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src-tauri/src
diff options
context:
space:
mode:
authorHsiangNianian <i@jyunko.cn>2026-03-12 15:40:18 +0800
committerHsiangNianian <i@jyunko.cn>2026-03-12 15:40:18 +0800
commit4a504c7e3d0c50cb90907d7903bc325d7daaf369 (patch)
tree3c8033f253c724a2480a769c293bd033f0ff2da3 /src-tauri/src
parentb63cf2e9cdba4dd4960aba61756bc2dca5666fa9 (diff)
downloadDropOut-4a504c7e3d0c50cb90907d7903bc325d7daaf369.tar.gz
DropOut-4a504c7e3d0c50cb90907d7903bc325d7daaf369.zip
feat(instance): finish multi instances system
Diffstat (limited to 'src-tauri/src')
-rw-r--r--src-tauri/src/core/instance.rs502
-rw-r--r--src-tauri/src/main.rs1118
2 files changed, 1141 insertions, 479 deletions
diff --git a/src-tauri/src/core/instance.rs b/src-tauri/src/core/instance.rs
index 0237270..2646d2b 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,9 +136,160 @@ 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
pub fn save(&self) -> Result<(), String> {
let config = self.instances.lock().unwrap();
@@ -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,19 @@ impl InstanceState {
new_name: String,
app_handle: &AppHandle,
) -> Result<Instance, String> {
+ self.begin_operation(id, InstanceOperation::ImportExport)?;
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 +469,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 +492,239 @@ impl InstanceState {
java_path_override: source_instance.java_path_override.clone(),
};
- self.update_instance(new_instance.clone())?;
+ self.insert_instance(new_instance.clone(), false)?;
+ self.end_operation(id);
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/main.rs b/src-tauri/src/main.rs
index 33c94fe..e7deee7 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,39 @@ 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)]
+#[serde(rename_all = "camelCase")]
+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 +98,24 @@ fn has_unresolved_placeholder(s: &str) -> bool {
false
}
+fn resolve_minecraft_version(version_id: &str) -> String {
+ if version_id.starts_with("fabric-loader-") {
+ version_id
+ .split('-')
+ .next_back()
+ .unwrap_or(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 +123,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 +136,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 +223,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 +305,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 +361,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 +374,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 +471,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 +858,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 +888,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 +900,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 +968,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 +1118,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 +1134,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 +1168,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 +1205,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())?;
+ // Ensure game directory exists
+ tokio::fs::create_dir_all(&game_dir)
+ .await
+ .map_err(|e| e.to_string())?;
- fetched
- }
- };
+ emit_log!(window, format!("Game directory: {:?}", game_dir));
- // 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())?;
+ // Load version (supports both vanilla and modded versions with inheritance)
+ emit_log!(
+ window,
+ format!("Loading version details for {}...", version_id)
+ );
- emit_log!(
- window,
- format!(
- "Version details loaded: main class = {}",
- version_details.main_class
- )
- );
+ // 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
+ }
+ };
- // Determine the actual minecraft version for client.jar
- let minecraft_version = version_details
- .inherits_from
- .clone()
- .unwrap_or_else(|| version_id.clone());
+ // 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())?;
- // Prepare download tasks
- emit_log!(window, "Preparing download tasks...".to_string());
- let mut download_tasks = Vec::new();
+ emit_log!(
+ window,
+ format!(
+ "Version details loaded: main class = {}",
+ version_details.main_class
+ )
+ );
- // --- 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));
+ // 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));
- download_tasks.push(core::downloader::DownloadTask {
- url: client_jar.url.clone(),
- path: client_path.clone(),
- sha1: client_jar.sha1.clone(),
- sha256: None,
- });
+ download_tasks.push(core::downloader::DownloadTask {
+ url: client_jar.url.clone(),
+ path: client_path.clone(),
+ sha1: client_jar.sha1.clone(),
+ sha256: None,
+ });
- // --- 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")
- };
+ // --- Libraries ---
+ let libraries_dir = resolved_paths.libraries.clone();
- 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));
+ 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));
- let mut lib_path = libraries_dir.clone();
- lib_path.push(path_str);
-
- download_tasks.push(core::downloader::DownloadTask {
- url: artifact.url.clone(),
- path: lib_path,
- sha1: artifact.sha1.clone(),
- sha256: None,
- });
- }
+ 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");
- let asset_index = version_details
- .asset_index
- .as_ref()
- .ok_or("Version has no asset index information")?;
+ // --- 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_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 +1887,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 +1974,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 +2029,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 +2058,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 +2251,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 +2594,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 +2682,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 +2714,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 +2814,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 +2859,7 @@ fn main() {
})
.invoke_handler(tauri::generate_handler![
start_game,
+ stop_game,
get_versions,
get_versions_of_instance,
check_version_installed,
@@ -2700,6 +2917,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,