aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src-tauri/src/main.rs
diff options
context:
space:
mode:
author苏向夜 <46275354+fu050409@users.noreply.github.com>2026-03-26 09:02:10 +0800
committerGitHub <noreply@github.com>2026-03-26 09:02:10 +0800
commit94b0d8e208363c802c12b56d8bdbef574dd1fb91 (patch)
treee86c8d46e73262c67c1755aaf4202cbcd1f8f844 /src-tauri/src/main.rs
parent7d0e92e6d3b172adfe552ffae9b97f8dad6f63ae (diff)
parent3a31d3004b2814cd8a26d49a0f8a96636411dcd2 (diff)
downloadDropOut-94b0d8e208363c802c12b56d8bdbef574dd1fb91.tar.gz
DropOut-94b0d8e208363c802c12b56d8bdbef574dd1fb91.zip
Add game lifecycle management and instance import/export tools (#117)
## Summary by Sourcery Add centralized game process and instance lifecycle management, shared cache-aware path resolution, and instance import/export/repair capabilities across backend and UI. New Features: - Track a single running game process in the backend, expose stop-game control, and emit structured game-exited events with instance and version context. - Introduce instance path resolution that supports shared caches for versions, libraries, and assets, and use it across game start, install, and version management APIs. - Add import, export, and repair operations for instances, including zip-based archive support and automatic recovery of on-disk instances. - Expose new instance lifecycle and repair APIs to the frontend and wire them through the client and instance store. - Add per-instance start/stop controls in the instances view and instance selection in the bottom bar for launching games. Enhancements: - Guard instance operations with per-instance locks and track active operations such as launch, install, delete, and import/export. - Improve handling of Microsoft login errors and polling status, with clearer user feedback and safer interval management. - Simplify config mutation during shared cache migration and centralize instance directory resolution in the backend. - Initialize a game lifecycle listener at app startup to keep UI state in sync with backend game exit events. Build: - Configure the Vite dev server to use a fixed localhost host and port for the UI dev environment.
Diffstat (limited to 'src-tauri/src/main.rs')
-rw-r--r--src-tauri/src/main.rs1124
1 files changed, 675 insertions, 449 deletions
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index 33c94fe..63287cd 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -6,7 +6,9 @@ use std::process::Stdio;
use std::sync::Mutex;
use tauri::{Emitter, Manager, State, Window}; // Added Emitter
use tokio::io::{AsyncBufReadExt, BufReader};
-use tokio::process::Command;
+use tokio::process::{Child, Command};
+use tokio::sync::Mutex as AsyncMutex;
+use tokio::time::{Duration, sleep};
use ts_rs::TS; // Added Serialize
#[cfg(target_os = "windows")]
@@ -42,6 +44,40 @@ impl MsRefreshTokenState {
}
}
+struct RunningGameProcess {
+ child: Child,
+ instance_id: String,
+ version_id: String,
+}
+
+pub struct GameProcessState {
+ running_game: AsyncMutex<Option<RunningGameProcess>>,
+}
+
+impl Default for GameProcessState {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl GameProcessState {
+ pub fn new() -> Self {
+ Self {
+ running_game: AsyncMutex::new(None),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(export, export_to = "core.ts")]
+struct GameExitedEvent {
+ instance_id: String,
+ version_id: String,
+ exit_code: Option<i32>,
+ was_stopped: bool,
+}
+
/// Check if a string contains unresolved placeholders in the form ${...}
///
/// After the replacement phase, if a string still contains ${...}, it means
@@ -63,6 +99,29 @@ fn has_unresolved_placeholder(s: &str) -> bool {
false
}
+fn resolve_minecraft_version(version_id: &str) -> String {
+ if let Some(rest) = version_id.strip_prefix("fabric-loader-") {
+ // Fabric version IDs are of the form: fabric-loader-<loader>-<mc>
+ // After stripping the prefix, we split once to separate loader vs mc
+ let mut parts = rest.splitn(2, '-');
+ let _loader_version = parts.next();
+ if let Some(mc_version) = parts.next() {
+ mc_version.to_string()
+ } else {
+ // Malformed Fabric ID, fall back to original
+ version_id.to_string()
+ }
+ } else if version_id.contains("-forge-") {
+ version_id
+ .split("-forge-")
+ .next()
+ .unwrap_or(version_id)
+ .to_string()
+ } else {
+ version_id.to_string()
+ }
+}
+
#[tauri::command]
#[dropout_macros::api]
async fn start_game(
@@ -70,6 +129,7 @@ async fn start_game(
auth_state: State<'_, core::auth::AccountState>,
config_state: State<'_, core::config::ConfigState>,
assistant_state: State<'_, core::assistant::AssistantState>,
+ game_process_state: State<'_, GameProcessState>,
instance_state: State<'_, core::instance::InstanceState>,
instance_id: String,
version_id: String,
@@ -82,6 +142,52 @@ async fn start_game(
)
);
+ let stale_instance_to_unlock = {
+ let mut running_game = game_process_state.running_game.lock().await;
+
+ if let Some(existing_game) = running_game.as_mut() {
+ match existing_game.child.try_wait() {
+ Ok(Some(status)) => {
+ emit_log!(
+ window,
+ format!(
+ "Clearing stale game process for instance {} (exit code: {:?})",
+ existing_game.instance_id,
+ status.code()
+ )
+ );
+ let stale_instance_id = existing_game.instance_id.clone();
+ *running_game = None;
+ Some(stale_instance_id)
+ }
+ Ok(None) => {
+ return Err(format!(
+ "A game is already running for instance {}",
+ existing_game.instance_id
+ ));
+ }
+ Err(error) => {
+ emit_log!(
+ window,
+ format!(
+ "Clearing broken game process state for instance {}: {}",
+ existing_game.instance_id, error
+ )
+ );
+ let stale_instance_id = existing_game.instance_id.clone();
+ *running_game = None;
+ Some(stale_instance_id)
+ }
+ }
+ } else {
+ None
+ }
+ };
+
+ if let Some(stale_instance_id) = stale_instance_to_unlock {
+ instance_state.end_operation(&stale_instance_id);
+ }
+
// Check for active account
emit_log!(window, "Checking for active account...".to_string());
let mut account = auth_state
@@ -123,16 +229,18 @@ async fn start_game(
emit_log!(window, "Account found".to_string());
let config = config_state.config.lock().unwrap().clone();
+ let app_handle = window.app_handle();
+ instance_state.begin_operation(&instance_id, core::instance::InstanceOperation::Launch)?;
+
+ let launch_result: Result<String, String> = async {
emit_log!(window, format!("Java path: {}", config.java_path));
emit_log!(
window,
format!("Memory: {}MB - {}MB", config.min_memory, config.max_memory)
);
- // Get game directory from instance
- let game_dir = instance_state
- .get_instance_game_dir(&instance_id)
- .ok_or_else(|| format!("Instance {} not found", instance_id))?;
+ let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?;
+ let game_dir = resolved_paths.root.clone();
// Ensure game directory exists
tokio::fs::create_dir_all(&game_dir)
@@ -203,7 +311,6 @@ async fn start_game(
// Resolve Java using priority-based resolution
// Priority: instance override > global config > user preference > auto-detect
// TODO: refactor into a separate function
- let app_handle = window.app_handle();
let instance = instance_state
.get_instance(&instance_id)
.ok_or_else(|| format!("Instance {} not found", instance_id))?;
@@ -260,12 +367,7 @@ async fn start_game(
.as_ref()
.ok_or("Version has no downloads information")?;
let client_jar = &downloads.client;
- // Use shared caches for versions if enabled
- let mut client_path = if config.use_shared_caches {
- app_handle.path().app_data_dir().unwrap().join("versions")
- } else {
- game_dir.join("versions")
- };
+ let mut client_path = resolved_paths.version_cache.clone();
client_path.push(&minecraft_version);
client_path.push(format!("{}.jar", minecraft_version));
@@ -278,12 +380,7 @@ async fn start_game(
// --- Libraries ---
println!("Processing libraries...");
- // Use shared caches for libraries if enabled
- let libraries_dir = if config.use_shared_caches {
- app_handle.path().app_data_dir().unwrap().join("libraries")
- } else {
- game_dir.join("libraries")
- };
+ let libraries_dir = resolved_paths.libraries.clone();
let mut native_libs_paths = Vec::new(); // Store paths to native jars for extraction
for lib in &version_details.libraries {
@@ -380,12 +477,7 @@ async fn start_game(
// --- Assets ---
println!("Fetching asset index...");
- // Use shared caches for assets if enabled
- let assets_dir = if config.use_shared_caches {
- app_handle.path().app_data_dir().unwrap().join("assets")
- } else {
- game_dir.join("assets")
- };
+ let assets_dir = resolved_paths.assets.clone();
let objects_dir = assets_dir.join("objects");
let indexes_dir = assets_dir.join("indexes");
@@ -772,6 +864,15 @@ async fn start_game(
.take()
.expect("child did not have a handle to stderr");
+ {
+ let mut running_game = game_process_state.running_game.lock().await;
+ *running_game = Some(RunningGameProcess {
+ child,
+ instance_id: instance_id.clone(),
+ version_id: version_id.clone(),
+ });
+ }
+
// Emit launcher log that game is running
emit_log!(
window,
@@ -793,6 +894,7 @@ async fn start_game(
let window_rx_err = window.clone();
let assistant_arc_err = assistant_state.assistant.clone();
let window_exit = window.clone();
+ let app_handle_exit = app_handle.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stderr).lines();
while let Ok(Some(line)) = reader.next_line().await {
@@ -804,17 +906,64 @@ async fn start_game(
});
// Monitor game process exit
+ let launch_instance_id = instance_id.clone();
+ let launch_version_id = version_id.clone();
tokio::spawn(async move {
- match child.wait().await {
- Ok(status) => {
- let msg = format!("Game process exited with status: {}", status);
- let _ = window_exit.emit("launcher-log", &msg);
- let _ = window_exit.emit("game-exited", status.code().unwrap_or(-1));
- }
- Err(e) => {
- let msg = format!("Error waiting for game process: {}", e);
+ loop {
+ let exit_event = {
+ let state: State<'_, GameProcessState> = app_handle_exit.state();
+ let mut running_game = state.running_game.lock().await;
+
+ let Some(active_game) = running_game.as_mut() else {
+ break;
+ };
+
+ if active_game.instance_id != launch_instance_id {
+ break;
+ }
+
+ match active_game.child.try_wait() {
+ Ok(Some(status)) => {
+ let exit_code = status.code();
+ *running_game = None;
+ Some(GameExitedEvent {
+ instance_id: launch_instance_id.clone(),
+ version_id: launch_version_id.clone(),
+ exit_code,
+ was_stopped: false,
+ })
+ }
+ Ok(None) => None,
+ Err(error) => {
+ let _ = window_exit.emit(
+ "launcher-log",
+ format!("Error waiting for game process: {}", error),
+ );
+ *running_game = None;
+ Some(GameExitedEvent {
+ instance_id: launch_instance_id.clone(),
+ version_id: launch_version_id.clone(),
+ exit_code: None,
+ was_stopped: false,
+ })
+ }
+ }
+ };
+
+ if let Some(event) = exit_event {
+ let msg = format!(
+ "Game process exited for instance {} with status {:?}",
+ event.instance_id, event.exit_code
+ );
let _ = window_exit.emit("launcher-log", &msg);
+ let _ = window_exit.emit("game-exited", &event);
+
+ let state: State<core::instance::InstanceState> = window_exit.app_handle().state();
+ state.end_operation(&event.instance_id);
+ break;
}
+
+ sleep(Duration::from_millis(500)).await;
}
});
@@ -825,6 +974,70 @@ async fn start_game(
}
Ok(format!("Launched Minecraft {} successfully!", version_id))
+ }
+ .await;
+
+ if launch_result.is_err() {
+ instance_state.end_operation(&instance_id);
+ }
+
+ launch_result
+}
+
+#[tauri::command]
+#[dropout_macros::api]
+async fn stop_game(
+ window: Window,
+ game_process_state: State<'_, GameProcessState>,
+ instance_state: State<'_, core::instance::InstanceState>,
+) -> Result<String, String> {
+ let mut running_game = {
+ let mut state = game_process_state.running_game.lock().await;
+ state.take().ok_or("No running game process found")?
+ };
+
+ emit_log!(
+ window,
+ format!(
+ "Stopping game process for instance {}...",
+ running_game.instance_id
+ )
+ );
+
+ let exit_code = match running_game.child.try_wait() {
+ Ok(Some(status)) => status.code(),
+ Ok(None) => {
+ running_game
+ .child
+ .start_kill()
+ .map_err(|e| format!("Failed to stop game process: {}", e))?;
+
+ running_game
+ .child
+ .wait()
+ .await
+ .map_err(|e| format!("Failed while waiting for the game to stop: {}", e))?
+ .code()
+ }
+ Err(error) => {
+ return Err(format!("Failed to inspect running game process: {}", error));
+ }
+ };
+
+ let event = GameExitedEvent {
+ instance_id: running_game.instance_id.clone(),
+ version_id: running_game.version_id.clone(),
+ exit_code,
+ was_stopped: true,
+ };
+
+ let _ = window.emit("game-exited", &event);
+ instance_state.end_operation(&running_game.instance_id);
+
+ Ok(format!(
+ "Stopped Minecraft {} for instance {}",
+ running_game.version_id, running_game.instance_id
+ ))
}
/// Parse JVM arguments from version.json
@@ -911,12 +1124,14 @@ async fn get_versions() -> Result<Vec<core::manifest::Version>, String> {
#[dropout_macros::api]
async fn get_versions_of_instance(
_window: Window,
+ config_state: State<'_, core::config::ConfigState>,
instance_state: State<'_, core::instance::InstanceState>,
instance_id: String,
) -> Result<Vec<core::manifest::Version>, String> {
- let game_dir = instance_state
- .get_instance_game_dir(&instance_id)
- .ok_or_else(|| format!("Instance {} not found", instance_id))?;
+ let config = config_state.config.lock().unwrap().clone();
+ let app_handle = _window.app_handle();
+ let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?;
+ let game_dir = resolved_paths.root.clone();
match core::manifest::fetch_version_manifest().await {
Ok(manifest) => {
@@ -925,9 +1140,12 @@ async fn get_versions_of_instance(
// For each version, try to load Java version info and check installation status
for version in &mut versions {
// Check if version is installed
- let version_dir = game_dir.join("versions").join(&version.id);
+ let version_dir = resolved_paths.metadata_versions.join(&version.id);
let json_path = version_dir.join(format!("{}.json", version.id));
- let client_jar_path = version_dir.join(format!("{}.jar", version.id));
+ let client_jar_path = resolved_paths
+ .version_cache
+ .join(&version.id)
+ .join(format!("{}.jar", version.id));
// Version is installed if both JSON and client jar exist
let is_installed = json_path.exists() && client_jar_path.exists();
@@ -956,35 +1174,18 @@ async fn get_versions_of_instance(
#[dropout_macros::api]
async fn check_version_installed(
_window: Window,
+ config_state: State<'_, core::config::ConfigState>,
instance_state: State<'_, core::instance::InstanceState>,
instance_id: String,
version_id: String,
) -> Result<bool, String> {
- let game_dir = instance_state
- .get_instance_game_dir(&instance_id)
- .ok_or_else(|| format!("Instance {} not found", instance_id))?;
-
- // For modded versions, check the parent vanilla version
- let minecraft_version = if version_id.starts_with("fabric-loader-") {
- // Format: fabric-loader-X.X.X-1.20.4
- version_id
- .split('-')
- .next_back()
- .unwrap_or(&version_id)
- .to_string()
- } else if version_id.contains("-forge-") {
- // Format: 1.20.4-forge-49.0.38
- version_id
- .split("-forge-")
- .next()
- .unwrap_or(&version_id)
- .to_string()
- } else {
- version_id.clone()
- };
+ let config = config_state.config.lock().unwrap().clone();
+ let app_handle = _window.app_handle();
+ let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?;
+ let minecraft_version = resolve_minecraft_version(&version_id);
- let client_jar = game_dir
- .join("versions")
+ let client_jar = resolved_paths
+ .version_cache
.join(&minecraft_version)
.join(format!("{}.jar", minecraft_version));
@@ -1010,310 +1211,295 @@ async fn install_version(
);
let config = config_state.config.lock().unwrap().clone();
+ let app_handle = window.app_handle();
+ instance_state.begin_operation(&instance_id, core::instance::InstanceOperation::Install)?;
- // Get game directory from instance
- let game_dir = instance_state
- .get_instance_game_dir(&instance_id)
- .ok_or_else(|| format!("Instance {} not found", instance_id))?;
-
- // Ensure game directory exists
- tokio::fs::create_dir_all(&game_dir)
- .await
- .map_err(|e| e.to_string())?;
-
- emit_log!(window, format!("Game directory: {:?}", game_dir));
-
- // Load version (supports both vanilla and modded versions with inheritance)
- emit_log!(
- window,
- format!("Loading version details for {}...", version_id)
- );
-
- // First, try to fetch the vanilla version from Mojang and save it locally
- let _version_details = match core::manifest::load_local_version(&game_dir, &version_id).await {
- Ok(v) => v,
- Err(_) => {
- // Not found locally, fetch from Mojang
- emit_log!(
- window,
- format!("Fetching version {} from Mojang...", version_id)
- );
- let fetched = core::manifest::fetch_vanilla_version(&version_id)
- .await
- .map_err(|e| e.to_string())?;
+ let install_result: Result<(), String> = async {
+ let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?;
+ let game_dir = resolved_paths.root.clone();
- // Save the version JSON locally
- emit_log!(window, format!("Saving version JSON..."));
- core::manifest::save_local_version(&game_dir, &fetched)
- .await
- .map_err(|e| e.to_string())?;
-
- fetched
- }
- };
+ // Ensure game directory exists
+ tokio::fs::create_dir_all(&game_dir)
+ .await
+ .map_err(|e| e.to_string())?;
- // Now load the full version with inheritance resolved
- let version_details = core::manifest::load_version(&game_dir, &version_id)
- .await
- .map_err(|e| e.to_string())?;
+ emit_log!(window, format!("Game directory: {:?}", game_dir));
- emit_log!(
- window,
- format!(
- "Version details loaded: main class = {}",
- version_details.main_class
- )
- );
+ // Load version (supports both vanilla and modded versions with inheritance)
+ emit_log!(
+ window,
+ format!("Loading version details for {}...", version_id)
+ );
- // Determine the actual minecraft version for client.jar
- let minecraft_version = version_details
- .inherits_from
- .clone()
- .unwrap_or_else(|| version_id.clone());
+ // First, try to fetch the vanilla version from Mojang and save it locally
+ let _version_details =
+ match core::manifest::load_local_version(&game_dir, &version_id).await {
+ Ok(v) => v,
+ Err(_) => {
+ // Not found locally, fetch from Mojang
+ emit_log!(
+ window,
+ format!("Fetching version {} from Mojang...", version_id)
+ );
+ let fetched = core::manifest::fetch_vanilla_version(&version_id)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ // Save the version JSON locally
+ emit_log!(window, format!("Saving version JSON..."));
+ core::manifest::save_local_version(&game_dir, &fetched)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ fetched
+ }
+ };
- // Prepare download tasks
- emit_log!(window, "Preparing download tasks...".to_string());
- let mut download_tasks = Vec::new();
+ // Now load the full version with inheritance resolved
+ let version_details = core::manifest::load_version(&game_dir, &version_id)
+ .await
+ .map_err(|e| e.to_string())?;
- // --- Client Jar ---
- let downloads = version_details
- .downloads
- .as_ref()
- .ok_or("Version has no downloads information")?;
- let client_jar = &downloads.client;
- // Use shared caches for versions if enabled
- let mut client_path = if config.use_shared_caches {
- window
- .app_handle()
- .path()
- .app_data_dir()
- .unwrap()
- .join("versions")
- } else {
- game_dir.join("versions")
- };
- client_path.push(&minecraft_version);
- client_path.push(format!("{}.jar", minecraft_version));
+ emit_log!(
+ window,
+ format!(
+ "Version details loaded: main class = {}",
+ version_details.main_class
+ )
+ );
- download_tasks.push(core::downloader::DownloadTask {
- url: client_jar.url.clone(),
- path: client_path.clone(),
- sha1: client_jar.sha1.clone(),
- sha256: None,
- });
+ // Determine the actual minecraft version for client.jar
+ let minecraft_version = version_details
+ .inherits_from
+ .clone()
+ .unwrap_or_else(|| version_id.clone());
+
+ // Prepare download tasks
+ emit_log!(window, "Preparing download tasks...".to_string());
+ let mut download_tasks = Vec::new();
+
+ // --- Client Jar ---
+ let downloads = version_details
+ .downloads
+ .as_ref()
+ .ok_or("Version has no downloads information")?;
+ let client_jar = &downloads.client;
+ let mut client_path = resolved_paths.version_cache.clone();
+ client_path.push(&minecraft_version);
+ client_path.push(format!("{}.jar", minecraft_version));
- // --- Libraries ---
- // Use shared caches for libraries if enabled
- let libraries_dir = if config.use_shared_caches {
- window
- .app_handle()
- .path()
- .app_data_dir()
- .unwrap()
- .join("libraries")
- } else {
- game_dir.join("libraries")
- };
+ download_tasks.push(core::downloader::DownloadTask {
+ url: client_jar.url.clone(),
+ path: client_path.clone(),
+ sha1: client_jar.sha1.clone(),
+ sha256: None,
+ });
- for lib in &version_details.libraries {
- if core::rules::is_library_allowed(&lib.rules, Some(&config.feature_flags)) {
- if let Some(downloads) = &lib.downloads {
- if let Some(artifact) = &downloads.artifact {
- let path_str = artifact
- .path
- .clone()
- .unwrap_or_else(|| format!("{}.jar", lib.name));
+ // --- Libraries ---
+ let libraries_dir = resolved_paths.libraries.clone();
- let mut lib_path = libraries_dir.clone();
- lib_path.push(path_str);
+ for lib in &version_details.libraries {
+ if core::rules::is_library_allowed(&lib.rules, Some(&config.feature_flags)) {
+ if let Some(downloads) = &lib.downloads {
+ if let Some(artifact) = &downloads.artifact {
+ let path_str = artifact
+ .path
+ .clone()
+ .unwrap_or_else(|| format!("{}.jar", lib.name));
- download_tasks.push(core::downloader::DownloadTask {
- url: artifact.url.clone(),
- path: lib_path,
- sha1: artifact.sha1.clone(),
- sha256: None,
- });
- }
+ let mut lib_path = libraries_dir.clone();
+ lib_path.push(path_str);
- // Native Library (classifiers)
- if let Some(classifiers) = &downloads.classifiers {
- // Determine candidate keys based on OS and architecture
- let arch = std::env::consts::ARCH;
- let mut candidates: Vec<String> = Vec::new();
- if cfg!(target_os = "linux") {
- candidates.push("natives-linux".to_string());
- candidates.push(format!("natives-linux-{}", arch));
- if arch == "aarch64" {
- candidates.push("natives-linux-arm64".to_string());
- }
- } else if cfg!(target_os = "windows") {
- candidates.push("natives-windows".to_string());
- candidates.push(format!("natives-windows-{}", arch));
- } else if cfg!(target_os = "macos") {
- candidates.push("natives-osx".to_string());
- candidates.push("natives-macos".to_string());
- candidates.push(format!("natives-macos-{}", arch));
+ download_tasks.push(core::downloader::DownloadTask {
+ url: artifact.url.clone(),
+ path: lib_path,
+ sha1: artifact.sha1.clone(),
+ sha256: None,
+ });
}
- // Pick the first available classifier key
- let mut chosen: Option<core::game_version::DownloadArtifact> = None;
- for key in candidates {
- if let Some(native_artifact_value) = classifiers.get(&key) {
- if let Ok(artifact) =
- serde_json::from_value::<core::game_version::DownloadArtifact>(
- native_artifact_value.clone(),
- )
- {
- chosen = Some(artifact);
- break;
+ // Native Library (classifiers)
+ if let Some(classifiers) = &downloads.classifiers {
+ // Determine candidate keys based on OS and architecture
+ let arch = std::env::consts::ARCH;
+ let mut candidates: Vec<String> = Vec::new();
+ if cfg!(target_os = "linux") {
+ candidates.push("natives-linux".to_string());
+ candidates.push(format!("natives-linux-{}", arch));
+ if arch == "aarch64" {
+ candidates.push("natives-linux-arm64".to_string());
}
+ } else if cfg!(target_os = "windows") {
+ candidates.push("natives-windows".to_string());
+ candidates.push(format!("natives-windows-{}", arch));
+ } else if cfg!(target_os = "macos") {
+ candidates.push("natives-osx".to_string());
+ candidates.push("natives-macos".to_string());
+ candidates.push(format!("natives-macos-{}", arch));
}
- }
- if let Some(native_artifact) = chosen {
- let path_str = native_artifact.path.clone().unwrap();
- let mut native_path = libraries_dir.clone();
- native_path.push(&path_str);
+ // Pick the first available classifier key
+ let mut chosen: Option<core::game_version::DownloadArtifact> = None;
+ for key in candidates {
+ if let Some(native_artifact_value) = classifiers.get(&key) {
+ if let Ok(artifact) =
+ serde_json::from_value::<core::game_version::DownloadArtifact>(
+ native_artifact_value.clone(),
+ )
+ {
+ chosen = Some(artifact);
+ break;
+ }
+ }
+ }
- download_tasks.push(core::downloader::DownloadTask {
- url: native_artifact.url,
- path: native_path.clone(),
- sha1: native_artifact.sha1,
- sha256: None,
- });
+ if let Some(native_artifact) = chosen {
+ let path_str = native_artifact.path.clone().unwrap();
+ let mut native_path = libraries_dir.clone();
+ native_path.push(&path_str);
+
+ download_tasks.push(core::downloader::DownloadTask {
+ url: native_artifact.url,
+ path: native_path.clone(),
+ sha1: native_artifact.sha1,
+ sha256: None,
+ });
+ }
}
- }
- } else {
- // Library without explicit downloads (mod loader libraries)
- if let Some(url) =
- core::maven::resolve_library_url(&lib.name, None, lib.url.as_deref())
- {
- if let Some(lib_path) = core::maven::get_library_path(&lib.name, &libraries_dir)
+ } else {
+ // Library without explicit downloads (mod loader libraries)
+ if let Some(url) =
+ core::maven::resolve_library_url(&lib.name, None, lib.url.as_deref())
{
- download_tasks.push(core::downloader::DownloadTask {
- url,
- path: lib_path,
- sha1: None,
- sha256: None,
- });
+ if let Some(lib_path) =
+ core::maven::get_library_path(&lib.name, &libraries_dir)
+ {
+ download_tasks.push(core::downloader::DownloadTask {
+ url,
+ path: lib_path,
+ sha1: None,
+ sha256: None,
+ });
+ }
}
}
}
}
- }
- // --- Assets ---
- // Use shared caches for assets if enabled
- let assets_dir = if config.use_shared_caches {
- window
- .app_handle()
- .path()
- .app_data_dir()
- .unwrap()
- .join("assets")
- } else {
- game_dir.join("assets")
- };
- let objects_dir = assets_dir.join("objects");
- let indexes_dir = assets_dir.join("indexes");
+ // --- Assets ---
+ let assets_dir = resolved_paths.assets.clone();
+ let objects_dir = assets_dir.join("objects");
+ let indexes_dir = assets_dir.join("indexes");
- let asset_index = version_details
- .asset_index
- .as_ref()
- .ok_or("Version has no asset index information")?;
-
- let asset_index_path = indexes_dir.join(format!("{}.json", asset_index.id));
+ let asset_index = version_details
+ .asset_index
+ .as_ref()
+ .ok_or("Version has no asset index information")?;
- let asset_index_content: String = if asset_index_path.exists() {
- tokio::fs::read_to_string(&asset_index_path)
- .await
- .map_err(|e| e.to_string())?
- } else {
- emit_log!(window, format!("Downloading asset index..."));
- let content = reqwest::get(&asset_index.url)
- .await
- .map_err(|e| e.to_string())?
- .text()
- .await
- .map_err(|e| e.to_string())?;
+ let asset_index_path = indexes_dir.join(format!("{}.json", asset_index.id));
- tokio::fs::create_dir_all(&indexes_dir)
- .await
- .map_err(|e| e.to_string())?;
- tokio::fs::write(&asset_index_path, &content)
- .await
- .map_err(|e| e.to_string())?;
- content
- };
+ let asset_index_content: String = if asset_index_path.exists() {
+ tokio::fs::read_to_string(&asset_index_path)
+ .await
+ .map_err(|e| e.to_string())?
+ } else {
+ emit_log!(window, format!("Downloading asset index..."));
+ let content = reqwest::get(&asset_index.url)
+ .await
+ .map_err(|e| e.to_string())?
+ .text()
+ .await
+ .map_err(|e| e.to_string())?;
- #[derive(serde::Deserialize)]
- struct AssetObject {
- hash: String,
- }
+ tokio::fs::create_dir_all(&indexes_dir)
+ .await
+ .map_err(|e| e.to_string())?;
+ tokio::fs::write(&asset_index_path, &content)
+ .await
+ .map_err(|e| e.to_string())?;
+ content
+ };
- #[derive(serde::Deserialize)]
- struct AssetIndexJson {
- objects: std::collections::HashMap<String, AssetObject>,
- }
+ #[derive(serde::Deserialize)]
+ struct AssetObject {
+ hash: String,
+ }
- let asset_index_parsed: AssetIndexJson =
- serde_json::from_str(&asset_index_content).map_err(|e| e.to_string())?;
+ #[derive(serde::Deserialize)]
+ struct AssetIndexJson {
+ objects: std::collections::HashMap<String, AssetObject>,
+ }
- emit_log!(
- window,
- format!("Processing {} assets...", asset_index_parsed.objects.len())
- );
+ let asset_index_parsed: AssetIndexJson =
+ serde_json::from_str(&asset_index_content).map_err(|e| e.to_string())?;
- for (_name, object) in asset_index_parsed.objects {
- let hash = object.hash;
- let prefix = &hash[0..2];
- let path = objects_dir.join(prefix).join(&hash);
- let url = format!(
- "https://resources.download.minecraft.net/{}/{}",
- prefix, hash
+ emit_log!(
+ window,
+ format!("Processing {} assets...", asset_index_parsed.objects.len())
);
- download_tasks.push(core::downloader::DownloadTask {
- url,
- path,
- sha1: Some(hash),
- sha256: None,
- });
- }
+ for (_name, object) in asset_index_parsed.objects {
+ let hash = object.hash;
+ let prefix = &hash[0..2];
+ let path = objects_dir.join(prefix).join(&hash);
+ let url = format!(
+ "https://resources.download.minecraft.net/{}/{}",
+ prefix, hash
+ );
- emit_log!(
- window,
- format!(
- "Total download tasks: {} (Client + Libraries + Assets)",
- download_tasks.len()
- )
- );
+ download_tasks.push(core::downloader::DownloadTask {
+ url,
+ path,
+ sha1: Some(hash),
+ sha256: None,
+ });
+ }
- // Start Download
- emit_log!(
- window,
- format!(
- "Starting downloads with {} concurrent threads...",
- config.download_threads
+ emit_log!(
+ window,
+ format!(
+ "Total download tasks: {} (Client + Libraries + Assets)",
+ download_tasks.len()
+ )
+ );
+
+ // Start Download
+ emit_log!(
+ window,
+ format!(
+ "Starting downloads with {} concurrent threads...",
+ config.download_threads
+ )
+ );
+ core::downloader::download_files(
+ window.clone(),
+ download_tasks,
+ config.download_threads as usize,
)
- );
- core::downloader::download_files(
- window.clone(),
- download_tasks,
- config.download_threads as usize,
- )
- .await
- .map_err(|e| e.to_string())?;
+ .await
+ .map_err(|e| e.to_string())?;
- emit_log!(
- window,
- format!("Installation of {} completed successfully!", version_id)
- );
+ emit_log!(
+ window,
+ format!("Installation of {} completed successfully!", version_id)
+ );
- // Emit event to notify frontend that version installation is complete
- let _ = window.emit("version-installed", &version_id);
+ if let Some(mut instance) = instance_state.get_instance(&instance_id) {
+ instance.version_id = Some(version_id.clone());
+ instance.mod_loader = Some("vanilla".to_string());
+ instance.mod_loader_version = None;
+ instance_state.update_instance(instance)?;
+ }
- Ok(())
+ // Emit event to notify frontend that version installation is complete
+ let _ = window.emit("version-installed", &version_id);
+
+ Ok(())
+ }
+ .await;
+
+ instance_state.end_operation(&instance_id);
+ install_result
}
#[tauri::command]
@@ -1707,31 +1893,39 @@ async fn install_fabric(
)
);
- let game_dir = instance_state
- .get_instance_game_dir(&instance_id)
- .ok_or_else(|| format!("Instance {} not found", instance_id))?;
+ instance_state.begin_operation(&instance_id, core::instance::InstanceOperation::Install)?;
- let result = core::fabric::install_fabric(&game_dir, &game_version, &loader_version)
- .await
- .map_err(|e| e.to_string())?;
+ let install_result: Result<core::fabric::InstalledFabricVersion, String> = async {
+ let game_dir = instance_state
+ .get_instance_game_dir(&instance_id)
+ .ok_or_else(|| format!("Instance {} not found", instance_id))?;
- emit_log!(
- window,
- format!("Fabric installed successfully: {}", result.id)
- );
+ let result = core::fabric::install_fabric(&game_dir, &game_version, &loader_version)
+ .await
+ .map_err(|e| e.to_string())?;
- // Update Instance's mod_loader metadata and version_id
- if let Some(mut instance) = instance_state.get_instance(&instance_id) {
- instance.mod_loader = Some("fabric".to_string());
- instance.mod_loader_version = Some(loader_version.clone());
- instance.version_id = Some(result.id.clone());
- instance_state.update_instance(instance)?;
- }
+ emit_log!(
+ window,
+ format!("Fabric installed successfully: {}", result.id)
+ );
- // Emit event to notify frontend
- let _ = window.emit("fabric-installed", &result.id);
+ // Update Instance's mod_loader metadata and version_id
+ if let Some(mut instance) = instance_state.get_instance(&instance_id) {
+ instance.mod_loader = Some("fabric".to_string());
+ instance.mod_loader_version = Some(loader_version.clone());
+ instance.version_id = Some(result.id.clone());
+ instance_state.update_instance(instance)?;
+ }
- Ok(result)
+ // Emit event to notify frontend
+ let _ = window.emit("fabric-installed", &result.id);
+
+ Ok(result)
+ }
+ .await;
+
+ instance_state.end_operation(&instance_id);
+ install_result
}
/// List installed Fabric versions
@@ -1786,15 +1980,15 @@ struct VersionMetadata {
#[dropout_macros::api]
async fn delete_version(
window: Window,
+ config_state: State<'_, core::config::ConfigState>,
instance_state: State<'_, core::instance::InstanceState>,
instance_id: String,
version_id: String,
) -> Result<(), String> {
- let game_dir = instance_state
- .get_instance_game_dir(&instance_id)
- .ok_or_else(|| format!("Instance {} not found", instance_id))?;
-
- let version_dir = game_dir.join("versions").join(&version_id);
+ let config = config_state.config.lock().unwrap().clone();
+ let app_handle = window.app_handle();
+ let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?;
+ let version_dir = resolved_paths.metadata_versions.join(&version_id);
if !version_dir.exists() {
return Err(format!("Version {} not found", version_id));
@@ -1841,13 +2035,15 @@ async fn delete_version(
#[dropout_macros::api]
async fn get_version_metadata(
_window: Window,
+ config_state: State<'_, core::config::ConfigState>,
instance_state: State<'_, core::instance::InstanceState>,
instance_id: String,
version_id: String,
) -> Result<VersionMetadata, String> {
- let game_dir = instance_state
- .get_instance_game_dir(&instance_id)
- .ok_or_else(|| format!("Instance {} not found", instance_id))?;
+ let config = config_state.config.lock().unwrap().clone();
+ let app_handle = _window.app_handle();
+ let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?;
+ let game_dir = resolved_paths.root.clone();
// Initialize metadata
let mut metadata = VersionMetadata {
@@ -1868,35 +2064,15 @@ async fn get_version_metadata(
}
// Check if version is installed (both JSON and client jar must exist)
- let version_dir = game_dir.join("versions").join(&version_id);
+ let version_dir = resolved_paths.metadata_versions.join(&version_id);
let json_path = version_dir.join(format!("{}.json", version_id));
// For modded versions, check the parent vanilla version's client jar
- let client_jar_path = if version_id.starts_with("fabric-loader-") {
- // Format: fabric-loader-X.X.X-1.20.4
- let minecraft_version = version_id
- .split('-')
- .next_back()
- .unwrap_or(&version_id)
- .to_string();
- game_dir
- .join("versions")
- .join(&minecraft_version)
- .join(format!("{}.jar", minecraft_version))
- } else if version_id.contains("-forge-") {
- // Format: 1.20.4-forge-49.0.38
- let minecraft_version = version_id
- .split("-forge-")
- .next()
- .unwrap_or(&version_id)
- .to_string();
- game_dir
- .join("versions")
- .join(&minecraft_version)
- .join(format!("{}.jar", minecraft_version))
- } else {
- version_dir.join(format!("{}.jar", version_id))
- };
+ let minecraft_version = resolve_minecraft_version(&version_id);
+ let client_jar_path = resolved_paths
+ .version_cache
+ .join(&minecraft_version)
+ .join(format!("{}.jar", minecraft_version));
metadata.is_installed = json_path.exists() && client_jar_path.exists();
@@ -2081,83 +2257,91 @@ async fn install_forge(
)
);
- let game_dir = instance_state
- .get_instance_game_dir(&instance_id)
- .ok_or_else(|| format!("Instance {} not found", instance_id))?;
+ instance_state.begin_operation(&instance_id, core::instance::InstanceOperation::Install)?;
- // Get Java path from config or detect
- let config = config_state.config.lock().unwrap().clone();
- let app_handle = window.app_handle();
- let java_path_str = if !config.java_path.is_empty() && config.java_path != "java" {
- config.java_path.clone()
- } else {
- // Try to find a suitable Java installation
- let javas = core::java::detect_all_java_installations(app_handle).await;
- if let Some(java) = javas.first() {
- java.path.clone()
+ let install_result: Result<core::forge::InstalledForgeVersion, String> = async {
+ let game_dir = instance_state
+ .get_instance_game_dir(&instance_id)
+ .ok_or_else(|| format!("Instance {} not found", instance_id))?;
+
+ // Get Java path from config or detect
+ let config = config_state.config.lock().unwrap().clone();
+ let app_handle = window.app_handle();
+ let java_path_str = if !config.java_path.is_empty() && config.java_path != "java" {
+ config.java_path.clone()
} else {
- return Err(
- "No Java installation found. Please configure Java in settings.".to_string(),
- );
- }
- };
- let java_path = utils::path::normalize_java_path(&java_path_str)?;
+ // Try to find a suitable Java installation
+ let javas = core::java::detect_all_java_installations(app_handle).await;
+ if let Some(java) = javas.first() {
+ java.path.clone()
+ } else {
+ return Err(
+ "No Java installation found. Please configure Java in settings.".to_string(),
+ );
+ }
+ };
+ let java_path = utils::path::normalize_java_path(&java_path_str)?;
- emit_log!(window, "Running Forge installer...".to_string());
+ emit_log!(window, "Running Forge installer...".to_string());
- // Run the Forge installer to properly patch the client
- core::forge::run_forge_installer(&game_dir, &game_version, &forge_version, &java_path)
- .await
- .map_err(|e| format!("Forge installer failed: {}", e))?;
+ // Run the Forge installer to properly patch the client
+ core::forge::run_forge_installer(&game_dir, &game_version, &forge_version, &java_path)
+ .await
+ .map_err(|e| format!("Forge installer failed: {}", e))?;
- emit_log!(
- window,
- "Forge installer completed, creating version profile...".to_string()
- );
+ emit_log!(
+ window,
+ "Forge installer completed, creating version profile...".to_string()
+ );
- // Check if the version JSON already exists
- let version_id = core::forge::generate_version_id(&game_version, &forge_version);
- let json_path = game_dir
- .join("versions")
- .join(&version_id)
- .join(format!("{}.json", version_id));
+ // Check if the version JSON already exists
+ let version_id = core::forge::generate_version_id(&game_version, &forge_version);
+ let json_path = game_dir
+ .join("versions")
+ .join(&version_id)
+ .join(format!("{}.json", version_id));
+
+ let result = if json_path.exists() {
+ // Version JSON was created by the installer, load it
+ emit_log!(
+ window,
+ "Using version profile created by Forge installer".to_string()
+ );
+ core::forge::InstalledForgeVersion {
+ id: version_id,
+ minecraft_version: game_version.clone(),
+ forge_version: forge_version.clone(),
+ path: json_path,
+ }
+ } else {
+ // Installer didn't create JSON, create it manually
+ core::forge::install_forge(&game_dir, &game_version, &forge_version)
+ .await
+ .map_err(|e| e.to_string())?
+ };
- let result = if json_path.exists() {
- // Version JSON was created by the installer, load it
emit_log!(
window,
- "Using version profile created by Forge installer".to_string()
+ format!("Forge installed successfully: {}", result.id)
);
- core::forge::InstalledForgeVersion {
- id: version_id,
- minecraft_version: game_version.clone(),
- forge_version: forge_version.clone(),
- path: json_path,
+
+ // Update Instance's mod_loader metadata and version_id
+ if let Some(mut instance) = instance_state.get_instance(&instance_id) {
+ instance.mod_loader = Some("forge".to_string());
+ instance.mod_loader_version = Some(forge_version.clone());
+ instance.version_id = Some(result.id.clone());
+ instance_state.update_instance(instance)?;
}
- } else {
- // Installer didn't create JSON, create it manually
- core::forge::install_forge(&game_dir, &game_version, &forge_version)
- .await
- .map_err(|e| e.to_string())?
- };
- emit_log!(
- window,
- format!("Forge installed successfully: {}", result.id)
- );
+ // Emit event to notify frontend
+ let _ = window.emit("forge-installed", &result.id);
- // Update Instance's mod_loader metadata and version_id
- if let Some(mut instance) = instance_state.get_instance(&instance_id) {
- instance.mod_loader = Some("forge".to_string());
- instance.mod_loader_version = Some(forge_version.clone());
- instance.version_id = Some(result.id.clone());
- instance_state.update_instance(instance)?;
+ Ok(result)
}
+ .await;
- // Emit event to notify frontend
- let _ = window.emit("forge-installed", &result.id);
-
- Ok(result)
+ instance_state.end_operation(&instance_id);
+ install_result
}
#[derive(serde::Serialize, TS)]
@@ -2416,6 +2600,43 @@ async fn duplicate_instance(
state.duplicate_instance(&instance_id, new_name, app_handle)
}
+/// Export an instance to a zip archive
+#[tauri::command]
+#[dropout_macros::api]
+async fn export_instance(
+ state: State<'_, core::instance::InstanceState>,
+ instance_id: String,
+ archive_path: String,
+) -> Result<String, String> {
+ state
+ .export_instance(&instance_id, std::path::Path::new(&archive_path))
+ .map(|path| path.to_string_lossy().to_string())
+}
+
+/// Import an instance from a zip archive
+#[tauri::command]
+#[dropout_macros::api]
+async fn import_instance(
+ window: Window,
+ state: State<'_, core::instance::InstanceState>,
+ archive_path: String,
+ new_name: Option<String>,
+) -> Result<core::instance::Instance, String> {
+ let app_handle = window.app_handle();
+ state.import_instance(std::path::Path::new(&archive_path), app_handle, new_name)
+}
+
+/// Repair instance index from on-disk directories
+#[tauri::command]
+#[dropout_macros::api]
+async fn repair_instances(
+ window: Window,
+ state: State<'_, core::instance::InstanceState>,
+) -> Result<core::instance::InstanceRepairResult, String> {
+ let app_handle = window.app_handle();
+ state.repair_instances(app_handle)
+}
+
#[tauri::command]
#[dropout_macros::api]
async fn assistant_chat_stream(
@@ -2467,11 +2688,11 @@ async fn migrate_shared_caches(
);
// Automatically enable shared caches config
- let mut config = config_state.config.lock().unwrap().clone();
- config.use_shared_caches = true;
- drop(config);
- *config_state.config.lock().unwrap() = config_state.config.lock().unwrap().clone();
- config_state.config.lock().unwrap().use_shared_caches = true;
+ {
+ let mut config = config_state.config.lock().unwrap();
+ config.use_shared_caches = true;
+ config.keep_legacy_per_instance_storage = false;
+ }
config_state.save()?;
Ok(MigrationResult {
@@ -2499,15 +2720,15 @@ struct FileInfo {
#[tauri::command]
#[dropout_macros::api]
async fn list_instance_directory(
+ app: Window,
+ config_state: State<'_, core::config::ConfigState>,
instance_state: State<'_, core::instance::InstanceState>,
instance_id: String,
folder: String, // "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots"
) -> Result<Vec<FileInfo>, String> {
- let game_dir = instance_state
- .get_instance_game_dir(&instance_id)
- .ok_or_else(|| format!("Instance {} not found", instance_id))?;
-
- let target_dir = game_dir.join(&folder);
+ let config = config_state.config.lock().unwrap().clone();
+ let target_dir =
+ instance_state.resolve_directory(&instance_id, &folder, &config, app.app_handle())?;
if !target_dir.exists() {
tokio::fs::create_dir_all(&target_dir)
.await
@@ -2599,6 +2820,7 @@ fn main() {
.plugin(tauri_plugin_shell::init())
.manage(core::auth::AccountState::new())
.manage(MsRefreshTokenState::new())
+ .manage(GameProcessState::new())
.manage(core::assistant::AssistantState::new())
.setup(|app| {
let config_state = core::config::ConfigState::new(app.handle());
@@ -2643,6 +2865,7 @@ fn main() {
})
.invoke_handler(tauri::generate_handler![
start_game,
+ stop_game,
get_versions,
get_versions_of_instance,
check_version_installed,
@@ -2700,6 +2923,9 @@ fn main() {
set_active_instance,
get_active_instance,
duplicate_instance,
+ export_instance,
+ import_instance,
+ repair_instances,
migrate_shared_caches,
list_instance_directory,
delete_instance_file,