aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src-tauri/src/core
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/core
parentb63cf2e9cdba4dd4960aba61756bc2dca5666fa9 (diff)
downloadDropOut-4a504c7e3d0c50cb90907d7903bc325d7daaf369.tar.gz
DropOut-4a504c7e3d0c50cb90907d7903bc325d7daaf369.zip
feat(instance): finish multi instances system
Diffstat (limited to 'src-tauri/src/core')
-rw-r--r--src-tauri/src/core/instance.rs502
1 files changed, 472 insertions, 30 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