// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use serde::{Deserialize, Serialize};
use std::process::Stdio;
use std::sync::Mutex;
use tauri::{Emitter, Manager, State, Window}; // Added Emitter
use tokio::io::{AsyncBufReadExt, BufReader};
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")]
use std::os::windows::process::CommandExt;
/// Helper macro to emit launcher log events
macro_rules! emit_log {
($window:expr, $msg:expr) => {
let _ = $window.emit("launcher-log", $msg);
println!("[Launcher] {}", $msg);
};
}
mod core;
mod utils;
// Global storage for MS refresh token (not in Account struct to keep it separate)
pub struct MsRefreshTokenState {
pub token: Mutex>,
}
impl Default for MsRefreshTokenState {
fn default() -> Self {
Self::new()
}
}
impl MsRefreshTokenState {
pub fn new() -> Self {
Self {
token: Mutex::new(None),
}
}
}
struct RunningGameProcess {
child: Child,
instance_id: String,
version_id: String,
}
pub struct GameProcessState {
running_game: AsyncMutex >,
}
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,
was_stopped: bool,
}
/// Check if a string contains unresolved placeholders in the form ${...}
///
/// After the replacement phase, if a string still contains ${...}, it means
/// that placeholder variable was not found in the replacements map and is
/// therefore unresolved. We should skip adding such arguments to avoid
/// passing malformed arguments to the game launcher.
fn has_unresolved_placeholder(s: &str) -> bool {
// Look for the opening sequence
if let Some(start_pos) = s.find("${") {
// Check if there's a closing brace after the opening sequence
if s[start_pos + 2..].find('}').is_some() {
// Found a complete ${...} pattern - this is an unresolved placeholder
return true;
}
// Found ${ but no closing } - also treat as unresolved/malformed
return true;
}
// No ${ found - the string is fully resolved
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--
// 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(
window: Window,
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,
) -> Result {
emit_log!(
window,
format!(
"Starting game launch for version: {} in instance: {}",
version_id, instance_id
)
);
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
.active_account
.lock()
.unwrap()
.clone()
.ok_or("No active account found. Please login first.")?;
// Check if Microsoft account token is expired and refresh if needed
if let core::auth::Account::Microsoft(ms_account) = &account {
if core::auth::is_token_expired(ms_account.expires_at) {
emit_log!(window, "Token expired, refreshing...".to_string());
match core::auth::refresh_full_auth(
&ms_account
.refresh_token
.clone()
.ok_or("No refresh token available")?,
)
.await
{
Ok((refreshed_account, _new_ms_refresh)) => {
let refreshed_account = core::auth::Account::Microsoft(refreshed_account);
*auth_state.active_account.lock().unwrap() = Some(refreshed_account.clone());
account = refreshed_account;
emit_log!(window, "Token refreshed successfully".to_string());
}
Err(e) => {
emit_log!(window, format!("Token refresh failed: {}", e));
return Err(format!(
"Your login session has expired. Please login again: {}",
e
));
}
}
}
}
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 = async {
emit_log!(window, format!("Java path: {}", config.java_path));
emit_log!(
window,
format!("Memory: {}MB - {}MB", config.min_memory, config.max_memory)
);
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)
.await
.map_err(|e| e.to_string())?;
emit_log!(window, format!("Game directory: {:?}", game_dir));
// 1. Load version (supports both vanilla and modded versions with inheritance)
emit_log!(
window,
format!("Loading version details for {}...", version_id)
);
// First, load the local version to get the original inheritsFrom value
// (before merge clears it)
let original_inherits_from =
match core::manifest::load_local_version(&game_dir, &version_id).await {
Ok(local_version) => local_version.inherits_from.clone(),
Err(_) => None,
};
let version_details = core::manifest::load_version(&game_dir, &version_id)
.await
.map_err(|e| e.to_string())?;
emit_log!(
window,
format!(
"Version details loaded: main class = {}",
version_details.main_class
)
);
// Determine the actual minecraft version for client.jar
// (for modded versions, this is the parent vanilla version)
let minecraft_version = original_inherits_from.unwrap_or_else(|| version_id.clone());
// Get required Java version from version file's javaVersion field
// The version file (after merging with parent) should contain the correct javaVersion
let required_java_major = version_details
.java_version
.as_ref()
.map(|jv| jv.major_version);
// For older Minecraft versions (1.13.x and below), if javaVersion specifies Java 8,
// we should only allow Java 8 (not higher) due to compatibility issues with old Forge
// For newer versions, javaVersion.majorVersion is the minimum required version
let max_java_major = if let Some(required) = required_java_major {
// If version file specifies Java 8, enforce it as maximum (old versions need exactly Java 8)
// For Java 9+, allow that version or higher
if required <= 8 {
Some(8)
} else {
None // No upper bound for Java 9+
}
} else {
// If version file doesn't specify javaVersion, this shouldn't happen for modern versions
// But if it does, we can't determine compatibility - log a warning
emit_log!(
window,
"Warning: Version file does not specify javaVersion. Using system default Java."
.to_string()
);
None
};
// Resolve Java using priority-based resolution
// Priority: instance override > global config > user preference > auto-detect
// TODO: refactor into a separate function
let instance = instance_state
.get_instance(&instance_id)
.ok_or_else(|| format!("Instance {} not found", instance_id))?;
let java_installation = core::java::priority::resolve_java_for_launch(
app_handle,
instance.java_path_override.as_deref(),
Some(&config.java_path),
required_java_major,
max_java_major,
)
.await
.ok_or_else(|| {
let version_constraint = if let Some(max) = max_java_major {
if let Some(min) = required_java_major {
if min == max as u64 {
format!("Java {}", min)
} else {
format!("Java {} to {}", min, max)
}
} else {
format!("Java {} (or lower)", max)
}
} else if let Some(min) = required_java_major {
format!("Java {} or higher", min)
} else {
"any Java version".to_string()
};
format!(
"No compatible Java installation found. This version requires {}. Please install a compatible Java version in settings.",
version_constraint
)
})?;
emit_log!(
window,
format!(
"Using Java {} at: {}",
java_installation.version, java_installation.path
)
);
let java_path_to_use = java_installation.path;
// 2. Prepare download tasks
emit_log!(window, "Preparing download tasks...".to_string());
let mut download_tasks = Vec::new();
// --- Client Jar ---
// Get downloads from version_details (may be inherited)
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,
});
// --- Libraries ---
println!("Processing 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 {
if core::rules::is_library_allowed(&lib.rules, Some(&config.feature_flags)) {
// 1. Standard Library - check for explicit downloads first
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,
});
}
// 2. Native Library (classifiers)
// e.g. "natives-linux": { ... }
if let Some(classifiers) = &downloads.classifiers {
// Determine candidate keys based on OS and architecture
let arch = std::env::consts::ARCH;
let mut candidates: Vec = 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));
}
// Pick the first available classifier key
let mut chosen: Option = None;
for key in candidates {
if let Some(native_artifact_value) = classifiers.get(&key) {
if let Ok(artifact) =
serde_json::from_value::(
native_artifact_value.clone(),
)
{
chosen = Some(artifact);
break;
}
}
}
if let Some(native_artifact) = chosen {
let path_str = native_artifact.path.clone().unwrap(); // Natives usually have path
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,
});
native_libs_paths.push(native_path);
}
}
} else {
// 3. Library without explicit downloads (mod loader libraries)
// Use Maven coordinate resolution
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)
{
download_tasks.push(core::downloader::DownloadTask {
url,
path: lib_path,
sha1: None, // Maven libraries often don't have SHA1 in the JSON
sha256: None,
});
}
}
}
}
}
// --- Assets ---
println!("Fetching asset index...");
let assets_dir = resolved_paths.assets.clone();
let objects_dir = assets_dir.join("objects");
let indexes_dir = assets_dir.join("indexes");
// Get asset index (may be inherited from parent)
let asset_index = version_details
.asset_index
.as_ref()
.ok_or("Version has no asset index information")?;
// Download Asset Index JSON
let asset_index_path = indexes_dir.join(format!("{}.json", asset_index.id));
// Check if index exists or download it
// Note: We need the content of this file to parse it.
// If we just add it to download_tasks, we can't parse it *now*.
// So we must download it immediately (await) before processing objects.
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 {
println!("Downloading asset index from {}", asset_index.url);
let content = reqwest::get(&asset_index.url)
.await
.map_err(|e| e.to_string())?
.text()
.await
.map_err(|e| e.to_string())?;
// Save it for next time
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, Debug)]
struct AssetObject {
hash: String,
#[allow(dead_code)]
size: u64,
}
#[derive(serde::Deserialize, Debug)]
struct AssetIndexJson {
objects: std::collections::HashMap,
}
let asset_index_parsed: AssetIndexJson =
serde_json::from_str(&asset_index_content).map_err(|e| e.to_string())?;
println!("Processing {} assets...", asset_index_parsed.objects.len());
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
);
download_tasks.push(core::downloader::DownloadTask {
url,
path,
sha1: Some(hash),
sha256: None,
});
}
emit_log!(
window,
format!(
"Total download tasks: {} (Client + Libraries + Assets)",
download_tasks.len()
)
);
// 4. 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,
)
.await
.map_err(|e| e.to_string())?;
emit_log!(window, "All downloads completed successfully".to_string());
// 5. Extract Natives
emit_log!(window, "Extracting native libraries...".to_string());
let natives_dir = game_dir.join("versions").join(&version_id).join("natives");
// Clean old natives if they exist to prevent conflicts
if natives_dir.exists() {
tokio::fs::remove_dir_all(&natives_dir)
.await
.map_err(|e| e.to_string())?;
}
tokio::fs::create_dir_all(&natives_dir)
.await
.map_err(|e| e.to_string())?;
for path in native_libs_paths {
if path.exists() {
println!("Extracting native: {:?}", path);
utils::zip::extract_zip(&path, &natives_dir)?;
}
}
// 6. Construct Classpath
let cp_separator = if cfg!(target_os = "windows") {
";"
} else {
":"
};
let mut classpath_entries = Vec::new();
// Add libraries
for lib in &version_details.libraries {
if core::rules::is_library_allowed(&lib.rules, Some(&config.feature_flags)) {
if let Some(downloads) = &lib.downloads {
// Standard library with explicit downloads
if let Some(artifact) = &downloads.artifact {
let path_str = artifact
.path
.clone()
.unwrap_or_else(|| format!("{}.jar", lib.name));
let lib_path = libraries_dir.join(path_str);
classpath_entries.push(lib_path.to_string_lossy().to_string());
}
} else {
// Library without explicit downloads (mod loader libraries)
// Use Maven coordinate resolution
if let Some(lib_path) = core::maven::get_library_path(&lib.name, &libraries_dir) {
classpath_entries.push(lib_path.to_string_lossy().to_string());
}
}
}
}
// Add client jar
classpath_entries.push(client_path.to_string_lossy().to_string());
let classpath = classpath_entries.join(cp_separator);
// 7. Prepare Arguments
let mut args = Vec::new();
let natives_path = natives_dir.to_string_lossy().to_string();
// 7a. JVM Arguments - Parse from version.json for full compatibility
// First add arguments from version.json if available
if let Some(args_obj) = &version_details.arguments {
if let Some(jvm_args) = &args_obj.jvm {
parse_jvm_arguments(
jvm_args,
&mut args,
&natives_path,
&classpath,
&config.feature_flags,
);
}
}
// Add memory settings (these override any defaults)
args.push(format!("-Xmx{}M", config.max_memory));
args.push(format!("-Xms{}M", config.min_memory));
// Ensure natives path is set if not already in jvm args
if !args.iter().any(|a| a.contains("-Djava.library.path")) {
args.push(format!("-Djava.library.path={}", natives_path));
}
// Ensure classpath is set if not already
if !args.iter().any(|a| a == "-cp" || a == "-classpath") {
args.push("-cp".to_string());
args.push(classpath.clone());
}
// 7b. Main Class
args.push(version_details.main_class.clone());
// 7c. Game Arguments
// Replacements map
let mut replacements = std::collections::HashMap::new();
replacements.insert("${auth_player_name}", account.username());
replacements.insert("${version_name}", version_id.clone());
replacements.insert("${game_directory}", game_dir.to_string_lossy().to_string());
replacements.insert("${assets_root}", assets_dir.to_string_lossy().to_string());
replacements.insert("${assets_index_name}", asset_index.id.clone());
replacements.insert("${auth_uuid}", account.uuid());
replacements.insert("${auth_access_token}", account.access_token());
// Set user_type dynamically: "msa" for Microsoft accounts, "legacy" for offline
let user_type = match &account {
core::auth::Account::Microsoft(_) => "msa",
core::auth::Account::Offline(_) => "legacy",
};
replacements.insert("${user_type}", user_type.to_string());
// Use version_type from version JSON if available, fallback to "release"
let version_type_str = version_details
.version_type
.clone()
.unwrap_or_else(|| "release".to_string());
replacements.insert("${version_type}", version_type_str);
replacements.insert("${user_properties}", "{}".to_string()); // Correctly pass empty JSON object for user properties
if let Some(minecraft_arguments) = &version_details.minecraft_arguments {
// Legacy string
for part in minecraft_arguments.split_whitespace() {
let mut arg = part.to_string();
for (key, val) in &replacements {
arg = arg.replace(key, val);
}
args.push(arg);
}
} else if let Some(args_obj) = &version_details.arguments {
if let Some(game_args) = &args_obj.game {
// Can be array of strings or objects
if let Some(list) = game_args.as_array() {
for item in list {
if let Some(s) = item.as_str() {
let mut arg = s.to_string();
for (key, val) in &replacements {
arg = arg.replace(key, val);
}
args.push(arg);
} else if let Some(obj) = item.as_object() {
// Check rules
// Simplified: if it has "value", and rules pass.
// For now, assuming rules pass if no "rules" field or simplistic check
// Ideally we should implement a helper to check rules for args just like libs
let allow = if let Some(rules_val) = obj.get("rules") {
if let Ok(rules) = serde_json::from_value::>(
rules_val.clone(),
) {
core::rules::is_library_allowed(
&Some(rules),
Some(&config.feature_flags),
)
} else {
true // Parse error, assume allow? or disallow.
}
} else {
true
};
if allow {
if let Some(val) = obj.get("value") {
if let Some(s) = val.as_str() {
let mut arg = s.to_string();
for (key, replacement) in &replacements {
arg = arg.replace(key, replacement);
}
// Skip arguments with unresolved placeholders
if !has_unresolved_placeholder(&arg) {
args.push(arg);
}
} else if let Some(arr) = val.as_array() {
for sub in arr {
if let Some(s) = sub.as_str() {
let mut arg = s.to_string();
for (key, replacement) in &replacements {
arg = arg.replace(key, replacement);
}
// Skip arguments with unresolved placeholders
if !has_unresolved_placeholder(&arg) {
args.push(arg);
}
}
}
}
}
}
}
}
}
}
}
emit_log!(
window,
format!("Preparing to launch game with {} arguments...", args.len())
);
// Format Java command with sensitive information masked
let masked_args: Vec = args
.iter()
.enumerate()
.map(|(i, arg)| {
// Check if previous argument was a sensitive flag
if i > 0 {
let prev_arg = &args[i - 1];
if prev_arg == "--accessToken" || prev_arg == "--uuid" {
return "***".to_string();
}
}
// Mask sensitive argument values
if arg == "--accessToken" || arg == "--uuid" {
arg.clone()
} else if arg.starts_with("token:") {
// Mask token: prefix tokens (Session ID format)
"token:***".to_string()
} else if arg.len() > 100
&& arg.contains('.')
&& !arg.contains('/')
&& !arg.contains('\\')
&& !arg.contains(':')
{
// Likely a JWT token (very long string with dots, no paths)
"***".to_string()
} else if arg.len() == 36
&& arg.contains('-')
&& arg.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
{
// Likely a UUID (36 chars with dashes)
"***".to_string()
} else {
arg.clone()
}
})
.collect();
// Format as actual Java command (properly quote arguments with spaces)
let masked_args_str: Vec = masked_args
.iter()
.map(|arg| {
if arg.contains(' ') {
format!("\"{}\"", arg)
} else {
arg.clone()
}
})
.collect();
let java_command = format!("{} {}", java_path_to_use, masked_args_str.join(" "));
emit_log!(window, format!("Java Command: {}", java_command));
// Spawn the process
emit_log!(
window,
format!("Starting Java process: {}", java_path_to_use)
);
let mut command = Command::new(&java_path_to_use);
command.args(&args);
command.current_dir(&game_dir); // Run in game directory
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
// On Windows, use CREATE_NO_WINDOW flag to hide the console window
#[cfg(target_os = "windows")]
{
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(CREATE_NO_WINDOW);
emit_log!(
window,
"Applied CREATE_NO_WINDOW flag for Windows".to_string()
);
}
// Spawn and handle output
let mut child = command
.spawn()
.map_err(|e| format!("Failed to launch Java at '{}': {}\nPlease check your Java installation and path configuration in Settings.", java_path_to_use, e))?;
emit_log!(window, "Java process started successfully".to_string());
let stdout = child
.stdout
.take()
.expect("child did not have a handle to stdout");
let stderr = child
.stderr
.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,
"Game is now running, capturing output...".to_string()
);
let window_rx = window.clone();
let assistant_arc = assistant_state.assistant.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
assistant_arc.lock().unwrap().add_log(line.clone());
let _ = window_rx.emit("game-stdout", line);
}
// Emit log when stdout stream ends (game closing)
let _ = window_rx.emit("launcher-log", "Game stdout stream ended");
});
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 {
assistant_arc_err.lock().unwrap().add_log(line.clone());
let _ = window_rx_err.emit("game-stderr", line);
}
// Emit log when stderr stream ends
let _ = window_rx_err.emit("launcher-log", "Game stderr stream ended");
});
// Monitor game process exit
let launch_instance_id = instance_id.clone();
let launch_version_id = version_id.clone();
tokio::spawn(async move {
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 = window_exit.app_handle().state();
state.end_operation(&event.instance_id);
break;
}
sleep(Duration::from_millis(500)).await;
}
});
// Update instance's version_id to track last launched version
if let Some(mut instance) = instance_state.get_instance(&instance_id) {
instance.version_id = Some(version_id.clone());
let _ = instance_state.update_instance(instance);
}
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 {
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
fn parse_jvm_arguments(
jvm_args: &serde_json::Value,
args: &mut Vec,
natives_path: &str,
classpath: &str,
feature_flags: &core::config::FeatureFlags,
) {
let mut replacements = std::collections::HashMap::new();
replacements.insert("${natives_directory}", natives_path.to_string());
replacements.insert("${classpath}", classpath.to_string());
replacements.insert("${launcher_name}", "DropOut".to_string());
replacements.insert("${launcher_version}", env!("CARGO_PKG_VERSION").to_string());
if let Some(list) = jvm_args.as_array() {
for item in list {
if let Some(s) = item.as_str() {
// Simple string argument
let mut arg = s.to_string();
for (key, val) in &replacements {
arg = arg.replace(key, val);
}
// Skip memory args as we set them explicitly
if !arg.starts_with("-Xmx") && !arg.starts_with("-Xms") {
args.push(arg);
}
} else if let Some(obj) = item.as_object() {
// Conditional argument with rules
let allow = if let Some(rules_val) = obj.get("rules") {
if let Ok(rules) =
serde_json::from_value::>(rules_val.clone())
{
core::rules::is_library_allowed(&Some(rules), Some(feature_flags))
} else {
false
}
} else {
true
};
if allow {
if let Some(val) = obj.get("value") {
if let Some(s) = val.as_str() {
let mut arg = s.to_string();
for (key, replacement) in &replacements {
arg = arg.replace(key, replacement);
}
if !arg.starts_with("-Xmx") && !arg.starts_with("-Xms") {
args.push(arg);
}
} else if let Some(arr) = val.as_array() {
for sub in arr {
if let Some(s) = sub.as_str() {
let mut arg = s.to_string();
for (key, replacement) in &replacements {
arg = arg.replace(key, replacement);
}
if !arg.starts_with("-Xmx") && !arg.starts_with("-Xms") {
args.push(arg);
}
}
}
}
}
}
}
}
}
}
#[tauri::command]
#[dropout_macros::api]
async fn get_versions() -> Result, String> {
core::manifest::fetch_version_manifest()
.await
.map(|m| m.versions)
.map_err(|e| e.to_string())
}
/// Get all available versions from Mojang's version manifest
#[tauri::command]
#[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, String> {
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) => {
let mut versions = manifest.versions;
// 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 = resolved_paths.metadata_versions.join(&version.id);
let json_path = version_dir.join(format!("{}.json", 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();
version.is_installed = Some(is_installed);
// If installed, try to load the version JSON to get javaVersion
if is_installed {
if let Ok(game_version) =
core::manifest::load_local_version(&game_dir, &version.id).await
{
if let Some(java_ver) = game_version.java_version {
version.java_version = Some(java_ver.major_version);
}
}
}
}
Ok(versions)
}
Err(e) => Err(e.to_string()),
}
}
/// Check if a version is installed (has client.jar)
#[tauri::command]
#[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 {
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 = resolved_paths
.version_cache
.join(&minecraft_version)
.join(format!("{}.jar", minecraft_version));
Ok(client_jar.exists())
}
/// Install a version (download client, libraries, assets) without launching
#[tauri::command]
#[dropout_macros::api]
async fn install_version(
window: Window,
config_state: State<'_, core::config::ConfigState>,
instance_state: State<'_, core::instance::InstanceState>,
instance_id: String,
version_id: String,
) -> Result<(), String> {
emit_log!(
window,
format!(
"Starting installation for version: {} in instance: {}",
version_id, instance_id
)
);
let config = config_state.config.lock().unwrap().clone();
let app_handle = window.app_handle();
instance_state.begin_operation(&instance_id, core::instance::InstanceOperation::Install)?;
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();
// 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())?;
// 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
}
};
// 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!(
"Version details loaded: main class = {}",
version_details.main_class
)
);
// 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,
});
// --- 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));
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,
});
}
// 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 = 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));
}
// Pick the first available classifier key
let mut chosen: Option = None;
for key in candidates {
if let Some(native_artifact_value) = classifiers.get(&key) {
if let Ok(artifact) =
serde_json::from_value::(
native_artifact_value.clone(),
)
{
chosen = Some(artifact);
break;
}
}
}
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)
{
download_tasks.push(core::downloader::DownloadTask {
url,
path: lib_path,
sha1: None,
sha256: None,
});
}
}
}
}
}
// --- 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_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())?;
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 AssetObject {
hash: String,
}
#[derive(serde::Deserialize)]
struct AssetIndexJson {
objects: std::collections::HashMap,
}
let asset_index_parsed: AssetIndexJson =
serde_json::from_str(&asset_index_content).map_err(|e| e.to_string())?;
emit_log!(
window,
format!("Processing {} assets...", asset_index_parsed.objects.len())
);
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
);
download_tasks.push(core::downloader::DownloadTask {
url,
path,
sha1: Some(hash),
sha256: None,
});
}
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,
)
.await
.map_err(|e| e.to_string())?;
emit_log!(
window,
format!("Installation of {} completed successfully!", 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)?;
}
// 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]
#[dropout_macros::api]
async fn login_offline(
window: Window,
state: State<'_, core::auth::AccountState>,
username: String,
) -> Result {
let uuid = core::auth::generate_offline_uuid(&username);
let account = core::auth::Account::Offline(core::auth::OfflineAccount { username, uuid });
*state.active_account.lock().unwrap() = Some(account.clone());
// Save to storage
let app_handle = window.app_handle();
let app_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
let storage = core::account_storage::AccountStorage::new(app_dir);
storage.add_or_update_account(&account, None)?;
Ok(account)
}
#[tauri::command]
#[dropout_macros::api]
async fn get_active_account(
state: State<'_, core::auth::AccountState>,
) -> Result, String> {
Ok(state.active_account.lock().unwrap().clone())
}
#[tauri::command]
#[dropout_macros::api]
async fn logout(window: Window, state: State<'_, core::auth::AccountState>) -> Result<(), String> {
// Get current account UUID before clearing
let uuid = state
.active_account
.lock()
.unwrap()
.as_ref()
.map(|a| a.uuid());
*state.active_account.lock().unwrap() = None;
// Remove from storage
if let Some(uuid) = uuid {
let app_handle = window.app_handle();
let app_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
let storage = core::account_storage::AccountStorage::new(app_dir);
storage.remove_account(&uuid)?;
}
Ok(())
}
#[tauri::command]
#[dropout_macros::api]
async fn get_settings(
state: State<'_, core::config::ConfigState>,
) -> Result {
Ok(state.config.lock().unwrap().clone())
}
#[tauri::command]
#[dropout_macros::api]
async fn save_settings(
state: State<'_, core::config::ConfigState>,
config: core::config::LauncherConfig,
) -> Result<(), String> {
*state.config.lock().unwrap() = config;
state.save()?;
Ok(())
}
#[tauri::command]
#[dropout_macros::api]
async fn get_config_path(state: State<'_, core::config::ConfigState>) -> Result {
Ok(state.file_path.to_string_lossy().to_string())
}
#[tauri::command]
#[dropout_macros::api]
async fn read_raw_config(state: State<'_, core::config::ConfigState>) -> Result {
tokio::fs::read_to_string(&state.file_path)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
#[dropout_macros::api]
async fn save_raw_config(
state: State<'_, core::config::ConfigState>,
content: String,
) -> Result<(), String> {
// Validate JSON
let new_config: core::config::LauncherConfig =
serde_json::from_str(&content).map_err(|e| format!("Invalid JSON: {}", e))?;
// Save to file
tokio::fs::write(&state.file_path, &content)
.await
.map_err(|e| e.to_string())?;
// Update in-memory state
*state.config.lock().unwrap() = new_config;
Ok(())
}
#[tauri::command]
#[dropout_macros::api]
async fn start_microsoft_login() -> Result {
core::auth::start_device_flow().await
}
#[tauri::command]
#[dropout_macros::api]
async fn complete_microsoft_login(
window: Window,
state: State<'_, core::auth::AccountState>,
ms_refresh_state: State<'_, MsRefreshTokenState>,
device_code: String,
) -> Result {
// Helper to emit auth progress
let emit_progress = |step: &str| {
let _ = window.emit("auth-progress", step);
};
// 1. Poll (once) for token
emit_progress("Receiving token from Microsoft...");
let token_resp = core::auth::exchange_code_for_token(&device_code).await?;
emit_progress("Token received successfully!");
// Store MS refresh token
let ms_refresh_token = token_resp.refresh_token.clone();
*ms_refresh_state.token.lock().unwrap() = ms_refresh_token.clone();
// 2. Xbox Live Auth
emit_progress("Authenticating with Xbox Live...");
let (xbl_token, uhs) = core::auth::method_xbox_live(&token_resp.access_token).await?;
emit_progress("Xbox Live authentication successful!");
// 3. XSTS Auth
emit_progress("Authenticating with XSTS...");
let xsts_token = core::auth::method_xsts(&xbl_token).await?;
emit_progress("XSTS authentication successful!");
// 4. Minecraft Auth
emit_progress("Authenticating with Minecraft...");
let mc_token = core::auth::login_minecraft(&xsts_token, &uhs).await?;
emit_progress("Minecraft authentication successful!");
// 5. Get Profile
emit_progress("Fetching Minecraft profile...");
let profile = core::auth::fetch_profile(&mc_token).await?;
emit_progress(&format!("Welcome, {}!", profile.name));
// 6. Create Account
let account = core::auth::Account::Microsoft(core::auth::MicrosoftAccount {
username: profile.name,
uuid: profile.id,
access_token: mc_token, // This is the MC Access Token
refresh_token: token_resp.refresh_token.clone(),
expires_at: (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ token_resp.expires_in) as i64,
});
// 7. Save to state
*state.active_account.lock().unwrap() = Some(account.clone());
// 8. Save to storage
let app_handle = window.app_handle();
let app_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
let storage = core::account_storage::AccountStorage::new(app_dir);
storage.add_or_update_account(&account, ms_refresh_token)?;
Ok(account)
}
/// Refresh token for current Microsoft account
#[tauri::command]
#[dropout_macros::api]
async fn refresh_account(
window: Window,
state: State<'_, core::auth::AccountState>,
ms_refresh_state: State<'_, MsRefreshTokenState>,
) -> Result {
// Get stored MS refresh token
let app_handle = window.app_handle();
let app_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
let storage = core::account_storage::AccountStorage::new(app_dir.clone());
let (_stored_account, ms_refresh) = storage
.get_active_account()
.ok_or("No active account found")?;
let ms_refresh_token = ms_refresh.ok_or("No refresh token available")?;
// Perform full refresh
let (new_account, new_ms_refresh) = core::auth::refresh_full_auth(&ms_refresh_token).await?;
let account = core::auth::Account::Microsoft(new_account);
// Update state
*state.active_account.lock().unwrap() = Some(account.clone());
*ms_refresh_state.token.lock().unwrap() = Some(new_ms_refresh.clone());
// Update storage
storage.add_or_update_account(&account, Some(new_ms_refresh))?;
Ok(account)
}
/// Detect Java installations on the system
#[tauri::command]
#[dropout_macros::api]
async fn detect_all_java_installations(
app_handle: tauri::AppHandle,
) -> Result, String> {
Ok(core::java::detect_all_java_installations(&app_handle).await)
}
/// Alias for detect_all_java_installations (for backward compatibility)
#[tauri::command]
#[dropout_macros::api]
async fn detect_java(
app_handle: tauri::AppHandle,
) -> Result, String> {
Ok(core::java::detect_all_java_installations(&app_handle).await)
}
/// Get recommended Java for a specific Minecraft version
#[tauri::command]
#[dropout_macros::api]
async fn get_recommended_java(
required_major_version: Option,
) -> Result, String> {
Ok(core::java::get_recommended_java(required_major_version).await)
}
/// Get Adoptium Java download info
#[tauri::command]
#[dropout_macros::api]
async fn fetch_adoptium_java(
major_version: u32,
image_type: String,
) -> Result {
let img_type = match image_type.to_lowercase().as_str() {
"jdk" => core::java::ImageType::Jdk,
_ => core::java::ImageType::Jre,
};
core::java::fetch_java_release(major_version, img_type)
.await
.map_err(|e| e.to_string())
}
/// Download and install Adoptium Java
#[tauri::command]
#[dropout_macros::api]
async fn download_adoptium_java(
app_handle: tauri::AppHandle,
major_version: u32,
image_type: String,
custom_path: Option,
) -> Result {
let img_type = match image_type.to_lowercase().as_str() {
"jdk" => core::java::ImageType::Jdk,
_ => core::java::ImageType::Jre,
};
let path = custom_path.map(std::path::PathBuf::from);
core::java::download_and_install_java(&app_handle, major_version, img_type, path)
.await
.map_err(|e| e.to_string())
}
/// Get available Adoptium Java versions
#[tauri::command]
#[dropout_macros::api]
async fn fetch_available_java_versions() -> Result, String> {
core::java::fetch_available_versions()
.await
.map_err(|e| e.to_string())
}
/// Fetch Java catalog with platform availability (uses cache)
#[tauri::command]
#[dropout_macros::api]
async fn fetch_java_catalog(
app_handle: tauri::AppHandle,
) -> Result {
core::java::fetch_java_catalog(&app_handle, false)
.await
.map_err(|e| e.to_string())
}
/// Refresh Java catalog (bypass cache)
#[tauri::command]
#[dropout_macros::api]
async fn refresh_java_catalog(
app_handle: tauri::AppHandle,
) -> Result {
core::java::fetch_java_catalog(&app_handle, true)
.await
.map_err(|e| e.to_string())
}
/// Cancel current Java download
#[tauri::command]
#[dropout_macros::api]
async fn cancel_java_download() -> Result<(), String> {
core::java::cancel_current_download();
Ok(())
}
/// Get pending Java downloads
#[tauri::command]
#[dropout_macros::api]
async fn get_pending_java_downloads(
app_handle: tauri::AppHandle,
) -> Result, String> {
Ok(core::java::get_pending_downloads(&app_handle))
}
/// Resume pending Java downloads
#[tauri::command]
#[dropout_macros::api]
async fn resume_java_downloads(
app_handle: tauri::AppHandle,
) -> Result, String> {
core::java::resume_pending_downloads(&app_handle).await
}
/// Get Minecraft versions supported by Fabric
#[tauri::command]
#[dropout_macros::api]
async fn get_fabric_game_versions() -> Result, String> {
core::fabric::fetch_supported_game_versions()
.await
.map_err(|e| e.to_string())
}
/// Get available Fabric loader versions
#[tauri::command]
#[dropout_macros::api]
async fn get_fabric_loader_versions() -> Result, String> {
core::fabric::fetch_loader_versions()
.await
.map_err(|e| e.to_string())
}
/// Get Fabric loaders available for a specific Minecraft version
#[tauri::command]
#[dropout_macros::api]
async fn get_fabric_loaders_for_version(
game_version: String,
) -> Result, String> {
core::fabric::fetch_loaders_for_game_version(&game_version)
.await
.map_err(|e| e.to_string())
}
/// Install Fabric loader for a specific Minecraft version
#[tauri::command]
#[dropout_macros::api]
async fn install_fabric(
window: Window,
instance_state: State<'_, core::instance::InstanceState>,
instance_id: String,
game_version: String,
loader_version: String,
) -> Result {
emit_log!(
window,
format!(
"Installing Fabric {} for Minecraft {} in instance {}...",
loader_version, game_version, instance_id
)
);
instance_state.begin_operation(&instance_id, core::instance::InstanceOperation::Install)?;
let install_result: Result = async {
let game_dir = instance_state
.get_instance_game_dir(&instance_id)
.ok_or_else(|| format!("Instance {} not found", instance_id))?;
let result = core::fabric::install_fabric(&game_dir, &game_version, &loader_version)
.await
.map_err(|e| e.to_string())?;
emit_log!(
window,
format!("Fabric installed successfully: {}", 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)?;
}
// 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
#[tauri::command]
#[dropout_macros::api]
async fn list_installed_fabric_versions(
_window: Window,
instance_state: State<'_, core::instance::InstanceState>,
instance_id: String,
) -> Result, String> {
let game_dir = instance_state
.get_instance_game_dir(&instance_id)
.ok_or_else(|| format!("Instance {} not found", instance_id))?;
core::fabric::list_installed_fabric_versions(&game_dir)
.await
.map_err(|e| e.to_string())
}
/// Get Java version requirement for a specific version
#[tauri::command]
#[dropout_macros::api]
async fn get_version_java_version(
_window: Window,
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))?;
// Try to load the version JSON to get javaVersion
match core::manifest::load_version(&game_dir, &version_id).await {
Ok(game_version) => Ok(game_version.java_version.map(|jv| jv.major_version)),
Err(_) => Ok(None), // Version not found or can't be loaded
}
}
/// Version metadata for display in the UI
#[derive(serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "core.ts")]
struct VersionMetadata {
id: String,
java_version: Option,
is_installed: bool,
}
/// Delete a version (remove version directory)
#[tauri::command]
#[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 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));
}
// Remove the entire version directory
tokio::fs::remove_dir_all(&version_dir)
.await
.map_err(|e| format!("Failed to delete version: {}", e))?;
// Clean up Instance state if necessary
if let Some(mut instance) = instance_state.get_instance(&instance_id) {
let mut updated = false;
// If deleted version is the current selected version
if instance.version_id.as_ref() == Some(&version_id) {
instance.version_id = None;
updated = true;
}
// If deleted version is a modded version, clear mod_loader
if (version_id.starts_with("fabric-loader-")
&& instance.mod_loader == Some("fabric".to_string()))
|| (version_id.contains("-forge-") && instance.mod_loader == Some("forge".to_string()))
{
instance.mod_loader = None;
instance.mod_loader_version = None;
updated = true;
}
if updated {
instance_state.update_instance(instance)?;
}
}
// Emit event to notify frontend
let _ = window.emit("version-deleted", &version_id);
Ok(())
}
/// Get detailed metadata for a specific version
#[tauri::command]
#[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 {
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 {
id: version_id.clone(),
java_version: None,
is_installed: false,
};
// Check if version is in manifest and get Java version if available
if let Ok(manifest) = core::manifest::fetch_version_manifest().await {
if let Some(version_entry) = manifest.versions.iter().find(|v| v.id == version_id) {
// Note: version_entry.java_version is only set if version is installed locally
// For uninstalled versions, we'll fetch from remote below
if let Some(java_ver) = version_entry.java_version {
metadata.java_version = Some(java_ver);
}
}
}
// Check if version is installed (both JSON and client jar must exist)
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 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();
// Try to get Java version - from local if installed, or from remote if not
if metadata.is_installed {
// If installed, load from local version JSON
if let Ok(game_version) = core::manifest::load_version(&game_dir, &version_id).await {
if let Some(java_ver) = game_version.java_version {
metadata.java_version = Some(java_ver.major_version);
}
}
} else if metadata.java_version.is_none() {
// If not installed and we don't have Java version yet, try to fetch from remote
// This is for vanilla versions that are not installed
if !version_id.starts_with("fabric-loader-") && !version_id.contains("-forge-") {
if let Ok(game_version) = core::manifest::fetch_vanilla_version(&version_id).await {
if let Some(java_ver) = game_version.java_version {
metadata.java_version = Some(java_ver.major_version);
}
}
}
}
Ok(metadata)
}
/// Installed version info
#[derive(serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "core.ts")]
struct InstalledVersion {
id: String,
#[serde(rename = "type")]
version_type: String, // "release", "snapshot", "fabric", "forge", "modpack"
}
/// List all installed versions from the data directory
/// Simply lists all folders in the versions directory without validation
#[tauri::command]
#[dropout_macros::api]
async fn list_installed_versions(
_window: Window,
instance_state: State<'_, core::instance::InstanceState>,
instance_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 versions_dir = game_dir.join("versions");
let mut installed = Vec::new();
if !versions_dir.exists() {
return Ok(installed);
}
let mut entries = tokio::fs::read_dir(&versions_dir)
.await
.map_err(|e| e.to_string())?;
while let Some(entry) = entries.next_entry().await.map_err(|e| e.to_string())? {
// Only include directories
if !entry.file_type().await.map_err(|e| e.to_string())?.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let version_dir = entry.path();
// Determine version type based on folder name or JSON content
let version_type = if name.starts_with("fabric-loader-") {
"fabric".to_string()
} else if name.contains("-forge") || name.contains("forge-") {
"forge".to_string()
} else {
// Try to read JSON to get type, otherwise guess from name
let json_path = version_dir.join(format!("{}.json", name));
if json_path.exists() {
if let Ok(content) = tokio::fs::read_to_string(&json_path).await {
if let Ok(json) = serde_json::from_str::(&content) {
json.get("type")
.and_then(|t| t.as_str())
.unwrap_or("modpack")
.to_string()
} else {
"modpack".to_string()
}
} else {
"modpack".to_string()
}
} else {
// No JSON file - treat as modpack/custom
"modpack".to_string()
}
};
installed.push(InstalledVersion {
id: name,
version_type,
});
}
// Sort: modded/modpack first, then by version id descending
installed.sort_by(|a, b| {
let a_priority = match a.version_type.as_str() {
"fabric" | "forge" => 0,
"modpack" => 1,
_ => 2,
};
let b_priority = match b.version_type.as_str() {
"fabric" | "forge" => 0,
"modpack" => 1,
_ => 2,
};
match a_priority.cmp(&b_priority) {
std::cmp::Ordering::Equal => b.id.cmp(&a.id), // Descending order
other => other,
}
});
Ok(installed)
}
/// Check if Fabric is installed for a specific version
#[tauri::command]
#[dropout_macros::api]
async fn is_fabric_installed(
_window: Window,
instance_state: State<'_, core::instance::InstanceState>,
instance_id: String,
game_version: String,
loader_version: String,
) -> Result {
let game_dir = instance_state
.get_instance_game_dir(&instance_id)
.ok_or_else(|| format!("Instance {} not found", instance_id))?;
Ok(core::fabric::is_fabric_installed(
&game_dir,
&game_version,
&loader_version,
))
}
/// Get Minecraft versions supported by Forge
#[tauri::command]
#[dropout_macros::api]
async fn get_forge_game_versions() -> Result, String> {
core::forge::fetch_supported_game_versions()
.await
.map_err(|e| e.to_string())
}
/// Get available Forge versions for a specific Minecraft version
#[tauri::command]
#[dropout_macros::api]
async fn get_forge_versions_for_game(
game_version: String,
) -> Result, String> {
core::forge::fetch_forge_versions(&game_version)
.await
.map_err(|e| e.to_string())
}
/// Install Forge for a specific Minecraft version
#[tauri::command]
#[dropout_macros::api]
async fn install_forge(
window: Window,
config_state: State<'_, core::config::ConfigState>,
instance_state: State<'_, core::instance::InstanceState>,
instance_id: String,
game_version: String,
forge_version: String,
) -> Result {
emit_log!(
window,
format!(
"Installing Forge {} for Minecraft {} in instance {}...",
forge_version, game_version, instance_id
)
);
instance_state.begin_operation(&instance_id, core::instance::InstanceOperation::Install)?;
let install_result: Result = 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 {
// 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());
// 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()
);
// 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())?
};
emit_log!(
window,
format!("Forge installed successfully: {}", 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)?;
}
// Emit event to notify frontend
let _ = window.emit("forge-installed", &result.id);
Ok(result)
}
.await;
instance_state.end_operation(&instance_id);
install_result
}
#[derive(serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "core.ts")]
struct GithubRelease {
tag_name: String,
name: String,
published_at: String,
body: String,
html_url: String,
}
#[tauri::command]
#[dropout_macros::api]
async fn get_github_releases() -> Result, String> {
let client = reqwest::Client::new();
let res = client
.get("https://api.github.com/repos/HydroRoll-Team/DropOut/releases")
.header("User-Agent", "DropOut-Launcher")
.send()
.await
.map_err(|e| e.to_string())?;
if !res.status().is_success() {
return Err(format!("GitHub API returned status: {}", res.status()));
}
let releases: Vec = res.json().await.map_err(|e| e.to_string())?;
let mut result = Vec::new();
for r in releases {
if let (Some(tag), Some(name), Some(date), Some(body), Some(url)) = (
r["tag_name"].as_str(),
r["name"].as_str(),
r["published_at"].as_str(),
r["body"].as_str(),
r["html_url"].as_str(),
) {
result.push(GithubRelease {
tag_name: tag.to_string(),
name: name.to_string(),
published_at: date.to_string(),
body: body.to_string(),
html_url: url.to_string(),
});
}
}
Ok(result)
}
#[derive(Serialize, TS)]
#[ts(export, export_to = "core.ts")]
struct PastebinResponse {
url: String,
}
#[tauri::command]
#[dropout_macros::api]
async fn upload_to_pastebin(
state: State<'_, core::config::ConfigState>,
content: String,
) -> Result {
// Check content length limit
if content.len() > 500 * 1024 {
return Err("Log file too large (max 500KB)".to_string());
}
// Extract config values before any async calls to avoid holding MutexGuard across await
let (service, api_key) = {
let config = state.config.lock().unwrap();
(
config.log_upload_service.clone(),
config.pastebin_api_key.clone(),
)
};
let client = reqwest::Client::new();
match service.as_str() {
"pastebin.com" => {
let api_key = api_key.ok_or("Pastebin API Key not configured in settings")?;
let res = client
.post("https://pastebin.com/api/api_post.php")
.form(&[
("api_dev_key", api_key.as_str()),
("api_option", "paste"),
("api_paste_code", content.as_str()),
("api_paste_private", "1"), // Unlisted
("api_paste_name", "DropOut Launcher Log"),
("api_paste_expire_date", "1W"),
])
.send()
.await
.map_err(|e| e.to_string())?;
if !res.status().is_success() {
return Err(format!("Pastebin upload failed: {}", res.status()));
}
let url = res.text().await.map_err(|e| e.to_string())?;
if url.starts_with("Bad API Request") {
return Err(format!("Pastebin API error: {}", url));
}
Ok(PastebinResponse { url })
}
// Default to paste.rs
_ => {
let res = client
.post("https://paste.rs/")
.body(content)
.send()
.await
.map_err(|e| e.to_string())?;
if !res.status().is_success() {
return Err(format!("paste.rs upload failed: {}", res.status()));
}
let url = res.text().await.map_err(|e| e.to_string())?;
let url = url.trim().to_string();
Ok(PastebinResponse { url })
}
}
}
#[tauri::command]
#[dropout_macros::api]
async fn assistant_check_health(
assistant_state: State<'_, core::assistant::AssistantState>,
config_state: State<'_, core::config::ConfigState>,
) -> Result {
let assistant = assistant_state.assistant.lock().unwrap().clone();
let config = config_state.config.lock().unwrap().clone();
Ok(assistant.check_health(&config.assistant).await)
}
#[tauri::command]
#[dropout_macros::api]
async fn assistant_chat(
assistant_state: State<'_, core::assistant::AssistantState>,
config_state: State<'_, core::config::ConfigState>,
messages: Vec,
) -> Result {
let assistant = assistant_state.assistant.lock().unwrap().clone();
let config = config_state.config.lock().unwrap().clone();
assistant.chat(messages, &config.assistant).await
}
#[tauri::command]
#[dropout_macros::api]
async fn list_ollama_models(
assistant_state: State<'_, core::assistant::AssistantState>,
endpoint: String,
) -> Result, String> {
let assistant = assistant_state.assistant.lock().unwrap().clone();
assistant.list_ollama_models(&endpoint).await
}
#[tauri::command]
#[dropout_macros::api]
async fn list_openai_models(
assistant_state: State<'_, core::assistant::AssistantState>,
config_state: State<'_, core::config::ConfigState>,
) -> Result, String> {
let assistant = assistant_state.assistant.lock().unwrap().clone();
let config = config_state.config.lock().unwrap().clone();
assistant.list_openai_models(&config.assistant).await
}
// ==================== Instance Management Commands ====================
/// Create a new instance
#[tauri::command]
#[dropout_macros::api]
async fn create_instance(
window: Window,
state: State<'_, core::instance::InstanceState>,
name: String,
) -> Result {
let app_handle = window.app_handle();
state.create_instance(name, app_handle)
}
/// Delete an instance
#[tauri::command]
#[dropout_macros::api]
async fn delete_instance(
state: State<'_, core::instance::InstanceState>,
instance_id: String,
) -> Result<(), String> {
state.delete_instance(&instance_id)
}
/// Update an instance
#[tauri::command]
#[dropout_macros::api]
async fn update_instance(
state: State<'_, core::instance::InstanceState>,
instance: core::instance::Instance,
) -> Result<(), String> {
state.update_instance(instance)
}
/// Get all instances
#[tauri::command]
#[dropout_macros::api]
async fn list_instances(
state: State<'_, core::instance::InstanceState>,
) -> Result, String> {
Ok(state.list_instances())
}
/// Get a single instance by ID
#[tauri::command]
#[dropout_macros::api]
async fn get_instance(
state: State<'_, core::instance::InstanceState>,
instance_id: String,
) -> Result {
state
.get_instance(&instance_id)
.ok_or_else(|| format!("Instance {} not found", instance_id))
}
/// Set the active instance
#[tauri::command]
#[dropout_macros::api]
async fn set_active_instance(
state: State<'_, core::instance::InstanceState>,
instance_id: String,
) -> Result<(), String> {
state.set_active_instance(&instance_id)
}
/// Get the active instance
#[tauri::command]
#[dropout_macros::api]
async fn get_active_instance(
state: State<'_, core::instance::InstanceState>,
) -> Result, String> {
Ok(state.get_active_instance())
}
/// Duplicate an instance
#[tauri::command]
#[dropout_macros::api]
async fn duplicate_instance(
window: Window,
state: State<'_, core::instance::InstanceState>,
instance_id: String,
new_name: String,
) -> Result {
let app_handle = window.app_handle();
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 {
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,
) -> Result {
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 {
let app_handle = window.app_handle();
state.repair_instances(app_handle)
}
#[tauri::command]
#[dropout_macros::api]
async fn assistant_chat_stream(
window: tauri::Window,
assistant_state: State<'_, core::assistant::AssistantState>,
config_state: State<'_, core::config::ConfigState>,
messages: Vec,
) -> Result {
let assistant = assistant_state.assistant.lock().unwrap().clone();
let config = config_state.config.lock().unwrap().clone();
assistant
.chat_stream(messages, &config.assistant, &window)
.await
}
/// Migrate instance caches to shared global caches
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "core.ts")]
struct MigrationResult {
moved_files: usize,
hardlinks: usize,
copies: usize,
saved_bytes: u64,
saved_mb: f64,
}
#[tauri::command]
#[dropout_macros::api]
async fn migrate_shared_caches(
window: Window,
instance_state: State<'_, core::instance::InstanceState>,
config_state: State<'_, core::config::ConfigState>,
) -> Result {
emit_log!(window, "Starting migration to shared caches...".to_string());
let app_handle = window.app_handle();
let (moved, hardlinks, copies, saved_bytes) =
core::instance::migrate_to_shared_caches(app_handle, &instance_state)?;
let saved_mb = saved_bytes as f64 / (1024.0 * 1024.0);
emit_log!(
window,
format!(
"Migration complete: {} files moved ({} hardlinks, {} copies), {:.2} MB saved",
moved, hardlinks, copies, saved_mb
)
);
// Automatically enable shared caches config
{
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 {
moved_files: moved,
hardlinks,
copies,
saved_bytes,
saved_mb,
})
}
/// File information for instance file browser
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "core.ts")]
struct FileInfo {
name: String,
path: String,
is_directory: bool,
size: u64,
modified: i64,
}
/// List files in an instance subdirectory (mods, resourcepacks, shaderpacks, saves, screenshots)
#[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, String> {
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
.map_err(|e| e.to_string())?;
}
let mut files = Vec::new();
let mut entries = tokio::fs::read_dir(&target_dir)
.await
.map_err(|e| e.to_string())?;
while let Some(entry) = entries.next_entry().await.map_err(|e| e.to_string())? {
let metadata = entry.metadata().await.map_err(|e| e.to_string())?;
let modified = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
files.push(FileInfo {
name: entry.file_name().to_string_lossy().to_string(),
path: entry.path().to_string_lossy().to_string(),
is_directory: metadata.is_dir(),
size: metadata.len(),
modified,
});
}
// Sort: directories first, then by name
files.sort_by(|a, b| {
b.is_directory
.cmp(&a.is_directory)
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(files)
}
/// Delete a file in an instance directory
#[tauri::command]
#[dropout_macros::api]
async fn delete_instance_file(path: String) -> Result<(), String> {
let path_buf = std::path::PathBuf::from(&path);
if path_buf.is_dir() {
tokio::fs::remove_dir_all(&path_buf)
.await
.map_err(|e| e.to_string())?;
} else {
tokio::fs::remove_file(&path_buf)
.await
.map_err(|e| e.to_string())?;
}
Ok(())
}
/// Open instance directory in system file explorer
#[tauri::command]
#[dropout_macros::api]
async fn open_file_explorer(path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.arg(&path)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(&path)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(&path)
.spawn()
.map_err(|e| e.to_string())?;
}
Ok(())
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.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());
app.manage(config_state);
// Initialize instance state
let instance_state = core::instance::InstanceState::new(app.handle());
// Migrate legacy data if needed
if let Err(e) = core::instance::migrate_legacy_data(app.handle(), &instance_state) {
eprintln!("[Startup] Warning: Failed to migrate legacy data: {}", e);
}
app.manage(instance_state);
// Load saved account on startup
let app_dir = app.path().app_data_dir().unwrap();
let storage = core::account_storage::AccountStorage::new(app_dir);
if let Some((stored_account, ms_refresh)) = storage.get_active_account() {
let account = stored_account.to_account();
let auth_state: State = app.state();
*auth_state.active_account.lock().unwrap() = Some(account);
// Store MS refresh token
if let Some(token) = ms_refresh {
let ms_state: State = app.state();
*ms_state.token.lock().unwrap() = Some(token);
}
println!("[Startup] Loaded saved account");
}
// Check for pending Java downloads and notify frontend
let pending = core::java::get_pending_downloads(app.app_handle());
if !pending.is_empty() {
println!("[Startup] Found {} pending Java download(s)", pending.len());
let _ = app.emit("pending-java-downloads", pending.len());
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
start_game,
stop_game,
get_versions,
get_versions_of_instance,
check_version_installed,
install_version,
list_installed_versions,
get_version_java_version,
get_version_metadata,
delete_version,
login_offline,
get_active_account,
logout,
get_settings,
save_settings,
get_config_path,
read_raw_config,
save_raw_config,
start_microsoft_login,
complete_microsoft_login,
refresh_account,
// Java commands
detect_java,
get_recommended_java,
fetch_adoptium_java,
download_adoptium_java,
fetch_available_java_versions,
fetch_java_catalog,
refresh_java_catalog,
cancel_java_download,
get_pending_java_downloads,
resume_java_downloads,
// Fabric commands
get_fabric_game_versions,
get_fabric_loader_versions,
get_fabric_loaders_for_version,
install_fabric,
list_installed_fabric_versions,
is_fabric_installed,
// Forge commands
get_forge_game_versions,
get_forge_versions_for_game,
install_forge,
get_github_releases,
upload_to_pastebin,
assistant_check_health,
assistant_chat,
assistant_chat_stream,
list_ollama_models,
list_openai_models,
// Instance management commands
create_instance,
delete_instance,
update_instance,
list_instances,
get_instance,
set_active_instance,
get_active_instance,
duplicate_instance,
export_instance,
import_instance,
repair_instances,
migrate_shared_caches,
list_instance_directory,
delete_instance_file,
open_file_explorer
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}