LCE-Emerald-Launcher/src-tauri/src/lib.rs
2026-04-08 11:16:53 +03:00

1299 lines
49 KiB
Rust

use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;
#[cfg(target_os = "macos")]
use std::process::Stdio;
use tauri::{AppHandle, Emitter, State, Manager};
use futures_util::StreamExt;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use tauri_plugin_opener::OpenerExt;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct McServer {
pub name: String,
pub ip: String,
pub port: u16,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct SkinLibraryItem {
pub id: String,
pub name: String,
pub skin_base64: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CustomEdition {
pub id: String,
pub name: String,
pub desc: String,
pub url: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct AppConfig {
pub username: String,
pub linux_runner: Option<String>,
pub skin_base64: Option<String>,
pub skin_library: Option<Vec<SkinLibraryItem>>,
pub theme_style_id: Option<String>,
pub theme_palette_id: Option<String>,
pub apple_silicon_performance_boost: Option<bool>,
pub custom_editions: Option<Vec<CustomEdition>>,
pub profile: Option<String>,
pub animations_enabled: Option<bool>,
pub vfx_enabled: Option<bool>,
pub rpc_enabled: Option<bool>,
pub music_vol: Option<u32>,
pub sfx_vol: Option<u32>,
pub legacy_mode: Option<bool>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ThemePalette {
pub id: String,
pub name: String,
pub colors: serde_json::Value,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Runner {
pub id: String,
pub name: String,
pub path: String,
pub r#type: String,
}
pub struct DownloadState { pub token: Arc<Mutex<Option<CancellationToken>>> }
pub struct GameState { pub child: Arc<Mutex<Option<tokio::process::Child>>> }
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
#[cfg(target_os = "macos")]
struct MacosSetupProgressPayload {
stage: String,
message: String,
percent: Option<f64>,
}
fn get_app_dir(app: &AppHandle) -> PathBuf {
app.path().app_local_data_dir().unwrap_or_else(|_| {
std::env::current_dir().unwrap_or_default()
})
}
#[cfg(target_os = "macos")]
fn get_macos_runtime_dir(app: &AppHandle) -> PathBuf {
let home = app
.path()
.home_dir()
.ok()
.or_else(|| std::env::var("HOME").ok().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("/"));
home.join("Library")
.join("Application Support")
.join("com.emerald.legacy")
.join("runtime")
}
#[cfg(target_os = "macos")]
fn emit_macos_setup_progress(window: &tauri::Window, stage: &str, message: String, percent: Option<f64>) {
let _ = window.emit(
"macos-setup-progress",
MacosSetupProgressPayload {
stage: stage.to_string(),
message,
percent,
},
);
}
#[cfg(target_os = "macos")]
fn find_executable_recursive(root: &PathBuf, file_name: &str) -> Option<PathBuf> {
let entries = fs::read_dir(root).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(found) = find_executable_recursive(&path, file_name) {
return Some(found);
}
continue;
}
if path.file_name().and_then(|n| n.to_str()) == Some(file_name) {
return Some(path);
}
}
None
}
#[cfg(unix)]
fn unix_path_to_wine_z_path(unix_path: &PathBuf) -> String {
let p = unix_path.to_string_lossy();
let mut out = String::with_capacity(p.len() + 3);
out.push_str("Z:");
for ch in p.chars() {
if ch == '/' {
out.push('\\');
} else {
out.push(ch);
}
}
out
}
fn get_config_path(app: &AppHandle) -> PathBuf {
get_app_dir(app).join("emerald_legacy_config.json")
}
#[tauri::command]
fn save_config(app: AppHandle, config: AppConfig) {
let path = get_config_path(&app);
let _ = fs::create_dir_all(path.parent().unwrap());
if let Ok(json) = serde_json::to_string(&config) {
let _ = fs::write(path, json);
}
}
#[tauri::command]
fn load_config(app: AppHandle) -> AppConfig {
let path = get_config_path(&app);
if let Ok(content) = fs::read_to_string(path) {
if let Ok(config) = serde_json::from_str(&content) {
return config;
}
}
let old_path = get_app_dir(&app).join("emerald_legacy_config.txt");
let username = fs::read_to_string(old_path).unwrap_or_else(|_| "Player".into());
AppConfig {
username,
linux_runner: None,
skin_base64: None,
skin_library: None,
theme_style_id: None,
theme_palette_id: None,
apple_silicon_performance_boost: None,
custom_editions: None,
profile: Some("legacy_evolved".into()),
animations_enabled: Some(true),
vfx_enabled: Some(true),
rpc_enabled: Some(true),
music_vol: Some(50),
sfx_vol: Some(100),
legacy_mode: Some(false),
}
}
#[tauri::command]
fn get_external_palettes(app: AppHandle) -> Vec<ThemePalette> {
let themes_dir = get_app_dir(&app).join("themes");
let _ = fs::create_dir_all(&themes_dir);
let mut palettes = Vec::new();
if let Ok(entries) = fs::read_dir(themes_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(palette) = serde_json::from_str::<ThemePalette>(&content) {
palettes.push(palette);
}
}
}
}
}
palettes
}
#[tauri::command]
fn import_theme(app: AppHandle) -> Result<String, String> {
let file = rfd::FileDialog::new()
.add_filter("JSON Theme", &["json"])
.set_title("Import Theme Palette")
.pick_file();
if let Some(src_path) = file {
let content = fs::read_to_string(&src_path).map_err(|e| e.to_string())?;
let palette: ThemePalette = serde_json::from_str(&content).map_err(|_| "Invalid theme JSON format".to_string())?;
let themes_dir = get_app_dir(&app).join("themes");
let _ = fs::create_dir_all(&themes_dir);
let dest_path = themes_dir.join(format!("{}.json", palette.id));
fs::write(dest_path, content).map_err(|e| e.to_string())?;
Ok(palette.name)
} else {
Err("CANCELED".into())
}
}
#[tauri::command]
fn get_available_runners(app: AppHandle) -> Vec<Runner> {
let mut runners = Vec::new();
let mut seen_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
#[cfg(target_os = "linux")]
{
if let Ok(output) = Command::new("which").arg("wine").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !seen_paths.contains(&path) {
seen_paths.insert(path.clone());
runners.push(Runner {
id: "wine".to_string(),
name: "System Wine".to_string(),
path,
r#type: "wine".to_string(),
});
}
}
}
if let Ok(output) = Command::new("ls").arg("/usr/share/emerald-legacy-launcher/wine/bin/wine").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !seen_paths.contains(&path) {
seen_paths.insert(path.clone());
runners.push(Runner {
id: "flatpaksucks".to_string(),
name: "Default for Flatpak".to_string(),
path,
r#type: "wine".to_string(),
});
}
}
}
let home = std::env::var("HOME").unwrap_or_default();
let steam_paths = [
format!("{}/.local/share/Steam/compatibilitytools.d", home),
format!("{}/.local/share/Steam/steamapps/common", home),
];
for base_path in steam_paths {
if let Ok(entries) = fs::read_dir(base_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = entry.file_name().to_string_lossy().to_string();
if name.contains("Proton") {
let path_str = path.to_string_lossy().to_string();
if !seen_paths.contains(&path_str) {
seen_paths.insert(path_str.clone());
runners.push(Runner {
id: format!("proton_{}", name),
name: name,
path: path_str,
r#type: "proton".to_string(),
});
}
}
}
}
}
}
let runners_dir = get_app_dir(&app).join("runners");
let _ = fs::create_dir_all(&runners_dir);
if let Ok(entries) = fs::read_dir(&runners_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let dir_name = entry.file_name().to_string_lossy().to_string();
let wine_bin = path.join("bin").join("wine");
let proton_bin = path.join("proton");
if proton_bin.exists() {
let path_str = path.to_string_lossy().to_string();
if !seen_paths.contains(&path_str) {
seen_paths.insert(path_str.clone());
runners.push(Runner {
id: format!("downloaded_{}", dir_name),
name: format!("{} (downloaded)", dir_name),
path: path_str,
r#type: "proton".to_string(),
});
}
} else if wine_bin.exists() {
let path_str = wine_bin.to_string_lossy().to_string();
if !seen_paths.contains(&path_str) {
seen_paths.insert(path_str.clone());
runners.push(Runner {
id: format!("downloaded_{}", dir_name),
name: format!("{} (downloaded)", dir_name),
path: path_str,
r#type: "wine".to_string(),
});
}
}
}
}
}
}
runners
}
#[tauri::command]
async fn download_runner(app: AppHandle, state: State<'_, DownloadState>, name: String, url: String) -> Result<String, String> {
let runners_dir = get_app_dir(&app).join("runners");
fs::create_dir_all(&runners_dir).map_err(|e| e.to_string())?;
let runner_dir = runners_dir.join(&name);
if runner_dir.exists() {
let _ = fs::remove_dir_all(&runner_dir);
}
fs::create_dir_all(&runner_dir).map_err(|e| e.to_string())?;
let token = CancellationToken::new();
let child_token = token.clone();
{
let mut lock = state.token.lock().await;
if let Some(old_token) = lock.take() {
old_token.cancel();
}
*lock = Some(token);
}
let tarball_path = runners_dir.join(format!("{}.tar.gz", name));
let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("Download failed: {}", response.status()));
}
let total_size = response.content_length().unwrap_or(0) as f64;
let mut file = fs::File::create(&tarball_path).map_err(|e| e.to_string())?;
let mut downloaded = 0.0;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
if child_token.is_cancelled() {
drop(file);
let _ = fs::remove_file(&tarball_path);
let _ = fs::remove_dir_all(&runner_dir);
return Err("CANCELLED".into());
}
let chunk = chunk.map_err(|e| e.to_string())?;
file.write_all(&chunk).map_err(|e| e.to_string())?;
downloaded += chunk.len() as f64;
if total_size > 0.0 {
let _ = app.emit("runner-download-progress", downloaded / total_size * 100.0);
}
}
drop(file);
{ *state.token.lock().await = None; }
let status = Command::new("tar")
.args(["-zxf", tarball_path.to_str().unwrap(), "-C", runner_dir.to_str().unwrap(), "--strip-components=1"])
.status()
.map_err(|e| e.to_string())?;
let _ = fs::remove_file(&tarball_path);
if !status.success() {
let _ = fs::remove_dir_all(&runner_dir);
return Err("Extraction failed".into());
}
Ok(name)
}
#[tauri::command]
#[allow(non_snake_case)]
fn check_game_installed(app: AppHandle, instance_id: String) -> bool {
get_app_dir(&app).join("instances").join(&instance_id).join("Minecraft.Client.exe").exists()
}
#[tauri::command]
#[allow(non_snake_case)]
fn open_instance_folder(app: AppHandle, instance_id: String) {
let dir = get_app_dir(&app).join("instances").join(&instance_id);
if dir.exists() {
let _ = app.opener().open_path(dir.to_str().unwrap(), None::<&str>);
}
}
#[tauri::command]
#[allow(non_snake_case)]
fn delete_instance(app: AppHandle, instance_id: String) -> Result<(), String> {
let dir = get_app_dir(&app).join("instances").join(&instance_id);
if dir.exists() {
let _ = fs::remove_dir_all(dir);
}
Ok(())
}
#[tauri::command]
async fn cancel_download(state: State<'_, DownloadState>) -> Result<(), String> {
if let Some(token) = state.token.lock().await.take() { token.cancel(); }
Ok(())
}
#[tauri::command]
async fn setup_macos_runtime(window: tauri::Window, app: AppHandle) -> Result<(), String> {
#[cfg(not(target_os = "macos"))]
{
let _ = window;
let _ = app;
return Err("macOS runtime setup is only supported on macOS.".into());
}
#[cfg(target_os = "macos")]
{
#[derive(Deserialize)]
struct GithubAsset {
name: String,
browser_download_url: String,
}
#[derive(Deserialize)]
struct GithubRelease {
tag_name: String,
assets: Vec<GithubAsset>,
}
emit_macos_setup_progress(&window, "resolving", "Resolving macOS compatibility runtime…".into(), None);
let client = reqwest::Client::new();
let release_text = client
.get("https://api.github.com/repos/Gcenx/game-porting-toolkit/releases/latest")
.header("User-Agent", "Emerald-Legacy-Launcher")
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?
.text()
.await
.map_err(|e| e.to_string())?;
let release: GithubRelease = serde_json::from_str(&release_text).map_err(|e| e.to_string())?;
let asset = release
.assets
.iter()
.find(|a| a.name.ends_with(".tar.xz") || a.name.ends_with(".tar.gz"))
.ok_or_else(|| "No compatible runtime asset found in latest release.".to_string())?;
let runtime_dir = get_macos_runtime_dir(&app);
let toolkit_dir = runtime_dir.join("toolkit");
let prefix_dir = runtime_dir.join("prefix");
fs::create_dir_all(&runtime_dir).map_err(|e| e.to_string())?;
if toolkit_dir.exists() {
let _ = fs::remove_dir_all(&toolkit_dir);
}
fs::create_dir_all(&toolkit_dir).map_err(|e| e.to_string())?;
emit_macos_setup_progress(
&window,
"downloading",
format!("Downloading runtime ({})…", release.tag_name),
Some(0.0),
);
let archive_path = runtime_dir.join(format!("gptk_{}", asset.name));
let response = client
.get(&asset.browser_download_url)
.header("User-Agent", "Emerald-Legacy-Launcher")
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let total_size = response.content_length().unwrap_or(0) as f64;
let mut file = fs::File::create(&archive_path).map_err(|e| e.to_string())?;
let mut downloaded = 0.0;
let mut last_percent_sent: i64 = -1;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| e.to_string())?;
file.write_all(&chunk).map_err(|e| e.to_string())?;
downloaded += chunk.len() as f64;
if total_size > 0.0 {
let percent = (downloaded / total_size * 100.0).clamp(0.0, 100.0);
let rounded = percent.floor() as i64;
if rounded != last_percent_sent {
last_percent_sent = rounded;
emit_macos_setup_progress(
&window,
"downloading",
format!("Downloading runtime… {}%", rounded),
Some(percent),
);
}
}
}
drop(file);
emit_macos_setup_progress(&window, "extracting", "Extracting runtime…".into(), None);
let archive_metadata = fs::metadata(&archive_path).map_err(|e| format!("Cannot read archive: {}", e))?;
println!("Archive size: {} bytes", archive_metadata.len());
if archive_metadata.len() < 100_000_000 {
return Err(format!("Archive too small: {} bytes", archive_metadata.len()));
}
let status = Command::new("tar")
.args([
"-xf",
archive_path.to_str().ok_or_else(|| "Invalid archive path".to_string())?,
"-C",
toolkit_dir.to_str().ok_or_else(|| "Invalid toolkit path".to_string())?,
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.status()
.map_err(|e| e.to_string())?;
println!("Tar exit status: {:?}", status);
let _ = fs::remove_file(&archive_path);
if !status.success() {
return Err(format!("Extraction failed with status: {:?}", status));
}
fs::create_dir_all(&prefix_dir).map_err(|e| e.to_string())?;
let wine_binary = find_executable_recursive(&toolkit_dir, "wine64")
.or_else(|| find_executable_recursive(&toolkit_dir, "wine"))
.ok_or_else(|| "Unable to locate wine binary inside runtime.".to_string())?;
let wine_bin_dir = wine_binary
.parent()
.map(|pp| pp.to_path_buf())
.ok_or_else(|| "Unable to locate wine bin directory inside runtime.".to_string())?;
emit_macos_setup_progress(&window, "initializing", "Initializing Wine prefix…".into(), None);
let mut cmd = Command::new(&wine_binary);
cmd.arg("wineboot");
cmd.arg("-u");
cmd.env("WINEPREFIX", &prefix_dir);
cmd.env("WINEARCH", "win64");
cmd.env("WINEDEBUG", "-all");
cmd.env("WINEESYNC", "1");
cmd.env("WINEDLLOVERRIDES", "winemenubuilder.exe=d;mscoree,mshtml=");
cmd.env("MTL_HUD_ENABLED", "0");
cmd.env(
"PATH",
format!(
"{}:{}",
wine_bin_dir.to_string_lossy(),
std::env::var("PATH").unwrap_or_default()
),
);
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let status = cmd.status().map_err(|e| e.to_string())?;
if !status.success() {
return Err("Wine prefix initialization failed".into());
}
emit_macos_setup_progress(&window, "done", "Setup complete.".into(), Some(100.0));
Ok(())
}
}
fn copy_dir_all(src: impl AsRef<std::path::Path>, dst: impl AsRef<std::path::Path>) -> std::io::Result<()> {
fs::create_dir_all(&dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_dir() {
copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
} else {
fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
}
}
Ok(())
}
#[tauri::command]
#[allow(non_snake_case)]
async fn download_and_install(app: AppHandle, state: State<'_, DownloadState>, url: String, instance_id: String) -> Result<String, String> {
let root = get_app_dir(&app);
let instance_dir = root.join("instances").join(&instance_id);
let token = CancellationToken::new();
let child_token = token.clone();
{
let mut lock = state.token.lock().await;
if let Some(old_token) = lock.take() {
old_token.cancel();
}
*lock = Some(token);
}
if !instance_dir.exists() {
fs::create_dir_all(&instance_dir).map_err(|e| e.to_string())?;
} else {
let workshop_files: std::collections::HashSet<String> = {
let wf_path = instance_dir.join("workshop_files.json");
fs::read_to_string(&wf_path)
.ok()
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.unwrap_or_default()
.into_iter()
.collect()
};
if let Ok(entries) = fs::read_dir(&instance_dir) {
for entry in entries.flatten() {
let file_name = entry.file_name();
let name_str = file_name.to_string_lossy();
let keep_list = ["Windows64", "Windows64Media", "uid.dat", "username.txt", "settings.dat", "servers.dat", "servers.txt", "server.properties", "Common", "options.txt", "servers.db", "workshop_files.json"];
let entry_path_str = entry.path().to_string_lossy().to_string();
let is_workshop_file = workshop_files.iter().any(|wf| entry_path_str.starts_with(wf) || wf.starts_with(&entry_path_str));
if !keep_list.contains(&name_str.as_ref()) && !is_workshop_file {
let path = entry.path();
if path.is_dir() {
let _ = fs::remove_dir_all(path);
} else {
let _ = fs::remove_file(path);
}
} else if name_str == "Common" {
let common_dir = entry.path();
if let Ok(common_entries) = fs::read_dir(&common_dir) {
for c_entry in common_entries.flatten() {
let c_name = c_entry.file_name();
if c_name.to_string_lossy() != "res" {
let c_path = c_entry.path();
if c_path.is_dir() { let _ = fs::remove_dir_all(c_path); } else { let _ = fs::remove_file(c_path); }
} else {
let res_dir = c_entry.path();
if let Ok(res_entries) = fs::read_dir(&res_dir) {
for r_entry in res_entries.flatten() {
let r_name = r_entry.file_name();
if r_name.to_string_lossy() != "mob" {
let r_path = r_entry.path();
if r_path.is_dir() { let _ = fs::remove_dir_all(r_path); } else { let _ = fs::remove_file(r_path); }
} else {
let mob_dir = r_entry.path();
if let Ok(mob_entries) = fs::read_dir(&mob_dir) {
for m_entry in mob_entries.flatten() {
let m_name = m_entry.file_name();
if m_name.to_string_lossy() != "char.png" {
let m_path = m_entry.path();
if m_path.is_dir() { let _ = fs::remove_dir_all(m_path); } else { let _ = fs::remove_file(m_path); }
}
}
}
}
}
}
}
}
}
}
}
}
}
let zip_path = root.join(format!("temp_{}.zip", instance_id));
let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("Download failed: {}", response.status()));
}
let total_size = response.content_length().unwrap_or(0) as f64;
let mut file = fs::File::create(&zip_path).map_err(|e| e.to_string())?;
let mut downloaded = 0.0;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
if child_token.is_cancelled() {
drop(file); let _ = fs::remove_file(&zip_path);
return Err("CANCELLED".into());
}
let chunk = chunk.map_err(|e| e.to_string())?;
file.write_all(&chunk).map_err(|e| e.to_string())?;
downloaded += chunk.len() as f64;
if total_size > 0.0 { let _ = app.emit("download-progress", downloaded / total_size * 100.0); }
}
drop(file);
{ *state.token.lock().await = None; }
#[cfg(target_os = "linux")]
{
let unzip_check = Command::new("unzip").arg("-v").status();
if unzip_check.is_err() || !unzip_check.unwrap().success() {
return Err("The 'unzip' command was not found. Please install it (e.g., 'sudo apt install unzip') to continue.".into());
}
let status = Command::new("unzip")
.args(["-o", "-q", zip_path.to_str().unwrap(), "-d", instance_dir.to_str().unwrap()])
.status()
.map_err(|e| e.to_string())?;
if !status.success() {
return Err("Extraction failed".into());
}
}
#[cfg(not(target_os = "linux"))]
{
let status = Command::new("tar")
.args(["-xf", zip_path.to_str().unwrap(), "-C", instance_dir.to_str().unwrap()])
.status()
.map_err(|e| e.to_string())?;
if !status.success() {
return Err("Extraction failed".into());
}
}
let _ = fs::remove_file(&zip_path);
if let Ok(entries) = fs::read_dir(&instance_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let check_exe = path.join("Minecraft.Client.exe");
if check_exe.exists() {
let inner_dir = path;
if let Ok(inner_entries) = fs::read_dir(&inner_dir) {
for inner_entry in inner_entries.flatten() {
let file_name = inner_entry.file_name();
let dest_path = instance_dir.join(file_name);
if fs::rename(inner_entry.path(), &dest_path).is_err() {
if inner_entry.path().is_dir() {
let _ = copy_dir_all(inner_entry.path(), &dest_path);
let _ = fs::remove_dir_all(inner_entry.path());
} else {
let _ = fs::copy(inner_entry.path(), &dest_path);
let _ = fs::remove_file(inner_entry.path());
}
}
}
}
let _ = fs::remove_dir_all(&inner_dir);
break;
}
}
}
}
Ok("Success".into())
}
fn ensure_server_list(instance_dir: &PathBuf, servers: Vec<McServer>) {
let servers_db = instance_dir.join("servers.db");
let mut all_servers = Vec::new();
if let Ok(content) = fs::read(&servers_db) {
if content.len() >= 12 && &content[0..4] == b"MCSV" {
let count = u32::from_le_bytes(content[8..12].try_into().unwrap_or([0; 4]));
let mut pos = 12;
for _ in 0..count {
if pos + 2 > content.len() { break; }
let ip_len = u16::from_le_bytes(content[pos..pos+2].try_into().unwrap_or([0; 2])) as usize;
pos += 2;
if pos + ip_len > content.len() { break; }
let ip = String::from_utf8_lossy(&content[pos..pos+ip_len]).to_string();
pos += ip_len;
if pos + 2 > content.len() { break; }
let port = u16::from_le_bytes(content[pos..pos+2].try_into().unwrap_or([0; 2]));
pos += 2;
if pos + 2 > content.len() { break; }
let name_len = u16::from_le_bytes(content[pos..pos+2].try_into().unwrap_or([0; 2])) as usize;
pos += 2;
if pos + name_len > content.len() { break; }
let name = String::from_utf8_lossy(&content[pos..pos+name_len]).to_string();
pos += name_len;
all_servers.push(McServer { name, ip, port });
}
}
}
for s in servers {
all_servers.push(s);
}
let mut unique_servers = Vec::new();
let mut seen: std::collections::HashSet<(String, u16)> = std::collections::HashSet::new();
for s in all_servers {
let key = (s.ip.clone(), s.port);
if seen.insert(key) {
unique_servers.push(s);
}
}
let mut file_content = Vec::new();
file_content.extend_from_slice(b"MCSV");
file_content.extend_from_slice(&1u32.to_le_bytes());
file_content.extend_from_slice(&(unique_servers.len() as u32).to_le_bytes());
for server in unique_servers {
let ip_bytes = server.ip.as_bytes();
let name_bytes = server.name.as_bytes();
file_content.extend_from_slice(&(ip_bytes.len() as u16).to_le_bytes());
file_content.extend_from_slice(ip_bytes);
file_content.extend_from_slice(&server.port.to_le_bytes());
file_content.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
file_content.extend_from_slice(name_bytes);
}
let _ = fs::create_dir_all(instance_dir);
let _ = fs::write(&servers_db, file_content);
}
fn perform_dlc_sync(app: &AppHandle, instance_dir: &PathBuf) -> Result<(), String> {
let mut dlc_src = None;
let root = get_app_dir(app);
use tauri::path::BaseDirectory;
if let Ok(p) = app.path().resolve("resources/DLC", BaseDirectory::Resource) {
if p.exists() {
dlc_src = Some(p);
} else {
if let Ok(p2) = app.path().resolve("DLC", BaseDirectory::Resource) {
if p2.exists() { dlc_src = Some(p2); }
}
}
}
if dlc_src.is_none() {
let current = std::env::current_dir().unwrap_or_default();
let p3 = current.join("src-tauri").join("resources").join("DLC");
let p4 = current.join("resources").join("DLC");
if p3.exists() { dlc_src = Some(p3); }
else if p4.exists() { dlc_src = Some(p4); }
}
if dlc_src.is_none() {
let p5 = root.join("DLC");
if p5.exists() { dlc_src = Some(p5); }
}
match dlc_src {
Some(src) => {
let dlc_dest = instance_dir.join("Windows64Media").join("DLC");
let _ = fs::create_dir_all(&dlc_dest);
if let Ok(entries) = fs::read_dir(&src) {
for entry in entries.flatten() {
let name = entry.file_name();
let dest_path = dlc_dest.join(&name);
if !dest_path.exists() {
if let Err(e) = if entry.path().is_dir() {
copy_dir_all(entry.path(), &dest_path)
} else {
fs::copy(entry.path(), &dest_path).map(|_| ())
} {
eprintln!("[DLC Sync] Failed to copy {:?} to {:?}: {}", entry.path(), dest_path, e);
} else {
println!("[DLC Sync] Copied to {:?}", dest_path);
}
} else {
println!("[DLC Sync] Skipping {:?}: Already exists in instance", name);
}
}
}
Ok(())
},
None => {
println!("[DLC Sync] Skipping sync: No DLC source found.");
Ok(())
}
}
}
#[tauri::command]
async fn sync_dlc(app: AppHandle, instance_id: String) -> Result<(), String> {
let root = get_app_dir(&app);
let instance_dir = root.join("instances").join(&instance_id);
perform_dlc_sync(&app, &instance_dir)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WorkshopInstallRequest {
instance_id: String,
zips: std::collections::HashMap<String, String>,
package_id: String,
}
#[tauri::command]
async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -> Result<(), String> {
let root = get_app_dir(&app);
let instance_dir = root.join("instances").join(&request.instance_id);
if !instance_dir.exists() {
return Err("Instance not installed".into());
}
let media_dir = instance_dir.join("Windows64Media");
let dlc_dir = media_dir.join("DLC");
let game_hdd = instance_dir.join("Windows64").join("GameHDD");
let mob_dir = instance_dir.join("Common").join("res").join("mob");
let wf_path = instance_dir.join("workshop_files.json");
let mut workshop_files: Vec<String> = fs::read_to_string(&wf_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let raw_base = format!("https://raw.githubusercontent.com/Emerald-Legacy-Launcher/Workshop/refs/heads/main/{}", request.package_id);
let tmp_dir = root.join(format!("workshop_tmp_{}", request.package_id));
fs::create_dir_all(&tmp_dir).map_err(|e| e.to_string())?;
for (zip_name, placeholder) in &request.zips {
let zip_url = format!("{}/{}", raw_base, zip_name);
let zip_tmp = tmp_dir.join(zip_name);
let response = reqwest::get(&zip_url).await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
let _ = fs::remove_dir_all(&tmp_dir);
return Err(format!("Failed to download {}: HTTP {}", zip_name, response.status()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
fs::write(&zip_tmp, &bytes).map_err(|e| e.to_string())?;
let dest_dir = if placeholder.is_empty() {
instance_dir.clone()
} else {
let resolved = placeholder
.replace("{MediaDir}", media_dir.to_str().unwrap_or(""))
.replace("{DLCDir}", dlc_dir.to_str().unwrap_or(""))
.replace("{GameHDD}", game_hdd.to_str().unwrap_or(""))
.replace("{MobDir}", mob_dir.to_str().unwrap_or(""));
PathBuf::from(resolved)
};
fs::create_dir_all(&dest_dir).map_err(|e| e.to_string())?;
#[cfg(target_os = "linux")]
{
let status = Command::new("unzip")
.args(["-o", "-q", zip_tmp.to_str().unwrap(), "-d", dest_dir.to_str().unwrap()])
.status()
.map_err(|e| e.to_string())?;
if !status.success() {
let _ = fs::remove_dir_all(&tmp_dir);
return Err(format!("Extraction failed for {}", zip_name));
}
}
#[cfg(not(target_os = "linux"))]
{
let status = Command::new("tar")
.args(["-xf", zip_tmp.to_str().unwrap(), "-C", dest_dir.to_str().unwrap()])
.status()
.map_err(|e| e.to_string())?;
if !status.success() {
let _ = fs::remove_dir_all(&tmp_dir);
return Err(format!("Extraction failed for {}", zip_name));
}
}
let dest_str = dest_dir.to_string_lossy().to_string();
if !workshop_files.contains(&dest_str) {
workshop_files.push(dest_str);
}
}
let _ = fs::remove_dir_all(&tmp_dir);
if let Ok(json) = serde_json::to_string(&workshop_files) {
let _ = fs::write(&wf_path, json);
}
Ok(())
}
#[tauri::command]
#[allow(non_snake_case)]
async fn launch_game(app: AppHandle, state: State<'_, GameState>, instance_id: String, servers: Vec<McServer>) -> Result<(), String> {
let root = get_app_dir(&app);
let instance_dir = root.join("instances").join(&instance_id);
let config = load_config(app.clone());
let username = config.username;
let _ = fs::write(instance_dir.join("username.txt"), &username);
ensure_server_list(&instance_dir, servers);
if let Some(skin_data) = config.skin_base64 {
use base64::{Engine as _, engine::general_purpose};
let base64_str = skin_data.split(',').nth(1).unwrap_or(&skin_data);
if let Ok(bytes) = general_purpose::STANDARD.decode(base64_str) {
let skin_dir = instance_dir.join("Common").join("res").join("mob");
let _ = fs::create_dir_all(&skin_dir);
let _ = fs::write(skin_dir.join("char.png"), bytes);
}
}
let _ = perform_dlc_sync(&app, &instance_dir)?;
let game_exe = instance_dir.join("Minecraft.Client.exe");
if !game_exe.exists() {
return Err("Game executable not found in instance folder.".into());
}
#[cfg(target_os = "linux")]
{
if let Some(runner_id) = config.linux_runner {
let runners = get_available_runners(app.clone());
if let Some(runner) = runners.into_iter().find(|r| r.id == runner_id) {
let mut cmd = if runner.r#type == "proton" {
let proton_exe = PathBuf::from(&runner.path).join("proton");
let mut c = tokio::process::Command::new(proton_exe);
let compat_data = instance_dir.join("proton_prefix");
fs::create_dir_all(&compat_data).map_err(|e| e.to_string())?;
c.env("STEAM_COMPAT_CLIENT_INSTALL_PATH", "");
c.env("STEAM_COMPAT_DATA_PATH", compat_data.to_str().unwrap());
c.env("SteamAppId", "480");
c.arg("run");
c
} else {
tokio::process::Command::new(runner.path)
};
#[cfg(unix)]
cmd.process_group(0);
cmd.arg(&game_exe)
.current_dir(&instance_dir);
let child = cmd.spawn().map_err(|e| e.to_string())?;
{
let mut lock = state.child.lock().await;
*lock = Some(child);
}
let status = loop {
{
let mut lock = state.child.lock().await;
if let Some(ref mut c) = *lock {
if let Some(s) = c.try_wait().map_err(|e| e.to_string())? {
break s;
}
} else {
return Ok(());
}
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
};
{
let mut lock = state.child.lock().await;
*lock = None;
}
return if status.success() || status.code() == Some(253) || status.code() == Some(96) { Ok(()) } else { Err(format!("Game exited with status: {}", status)) };
}
}
Err("No Linux runner selected in settings.".into())
}
#[cfg(not(target_os = "linux"))]
{
#[cfg(target_os = "macos")]
{
let runtime_dir = get_macos_runtime_dir(&app);
let toolkit_dir = runtime_dir.join("toolkit");
let prefix_dir = runtime_dir.join("prefix");
if !toolkit_dir.exists() || !prefix_dir.exists() {
return Err("macOS Compatibility is not set up. Open Settings and run Setup macOS Compatibility.".into());
}
let gptk_no_hud = find_executable_recursive(&toolkit_dir, "gameportingtoolkit-no-hud")
.or_else(|| find_executable_recursive(&toolkit_dir, "gameportingtoolkit"));
let wine_binary = find_executable_recursive(&toolkit_dir, "wine64")
.or_else(|| find_executable_recursive(&toolkit_dir, "wine"))
.ok_or_else(|| "Unable to locate wine binary inside runtime.".to_string())?;
let wine_bin_dir = wine_binary
.parent()
.map(|pp| pp.to_path_buf())
.ok_or_else(|| "Unable to locate wine bin directory inside runtime.".to_string())?;
let mut cmd = if let Some(wrapper) = gptk_no_hud {
let win_path = unix_path_to_wine_z_path(&game_exe);
let mut c = tokio::process::Command::new(wrapper);
c.arg(&prefix_dir);
c.arg(win_path);
c
} else {
let mut c = tokio::process::Command::new(&wine_binary);
c.env("WINEPREFIX", &prefix_dir);
c.arg(&game_exe);
c
};
#[cfg(unix)]
cmd.process_group(0);
cmd.current_dir(&instance_dir);
cmd.env("WINEPREFIX", &prefix_dir);
cmd.env("WINEDEBUG", "-all");
let perf_boost = config.apple_silicon_performance_boost.unwrap_or(false);
if perf_boost {
#[cfg(target_arch = "aarch64")]
{
cmd.env("WINE_MSYNC", "1");
cmd.env("MVK_ALLOW_METAL_FENCES", "1");
}
#[cfg(not(target_arch = "aarch64"))]
{
cmd.env("WINEESYNC", "1");
}
} else {
cmd.env("WINEESYNC", "1");
}
cmd.env("WINEDLLOVERRIDES", "winemenubuilder.exe=d;mscoree,mshtml=");
cmd.env("MTL_HUD_ENABLED", "0");
cmd.env("MVK_CONFIG_RESUME_LOST_DEVICE", "1");
cmd.env(
"PATH",
format!(
"{}:{}",
wine_bin_dir.to_string_lossy(),
std::env::var("PATH").unwrap_or_default()
),
);
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let child = cmd.spawn().map_err(|e| e.to_string())?;
{
let mut lock = state.child.lock().await;
*lock = Some(child);
}
let status = loop {
{
let mut lock = state.child.lock().await;
if let Some(ref mut c) = *lock {
if let Some(s) = c.try_wait().map_err(|e| e.to_string())? {
break s;
}
} else {
return Ok(());
}
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
};
{
let mut lock = state.child.lock().await;
*lock = None;
}
return if status.success() || status.code() == Some(253) || status.code() == Some(96) { Ok(()) } else { Err(format!("Game exited with status: {}", status)) };
}
#[cfg(all(not(target_os = "macos"), not(target_os = "linux")))]
{
let mut cmd = tokio::process::Command::new(&game_exe);
#[cfg(unix)]
cmd.process_group(0);
cmd.current_dir(&instance_dir);
let child = cmd.spawn().map_err(|e| e.to_string())?;
{
let mut lock = state.child.lock().await;
*lock = Some(child);
}
let status = loop {
{
let mut lock = state.child.lock().await;
if let Some(ref mut c) = *lock {
if let Some(s) = c.try_wait().map_err(|e| e.to_string())? {
break s;
}
} else {
return Ok(());
}
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
};
{
let mut lock = state.child.lock().await;
*lock = None;
}
return if status.success() || status.code() == Some(253) || status.code() == Some(96) { Ok(()) } else { Err(format!("Game exited with status: {}", status)) };
}
}
}
#[cfg(unix)]
fn kill_process_tree(app: &AppHandle, instance_id: &str) {
let root = get_app_dir(&app);
let instance_dir = root.join("instances").join(instance_id);
let target = unix_path_to_wine_z_path(&instance_dir.join("Minecraft.Client.exe"));
let Ok(entries) = fs::read_dir("/proc") else { return };
for entry in entries.flatten() {
let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else { continue };
let cmdline = fs::read_to_string(format!("/proc/{}/cmdline", pid))
.unwrap_or_default();
if cmdline.contains(&*target) {
unsafe { libc::kill(pid as i32, libc::SIGKILL); }
}
}
}
#[tauri::command]
async fn stop_game(#[allow(unused_variables)] app: AppHandle, #[allow(unused_variables)] instance_id: String, state: State<'_, GameState>) -> Result<(), String> {
let mut lock = state.child.lock().await;
if let Some(mut child) = lock.take() {
#[cfg(unix)] kill_process_tree(&app, &instance_id);
let _ = child.kill().await;
}
Ok(())
}
#[tauri::command]
async fn fetch_skin(username: String) -> Result<(String, String), String> {
let client = reqwest::Client::new();
let mojang_url = format!("https://api.mojang.com/users/profiles/minecraft/{}", username);
let mojang_res = client.get(&mojang_url).send().await.map_err(|e| format!("Failed request to mojang: {}", e))?;
if !mojang_res.status().is_success() {
return Err("Player not found".to_string());
}
let mojang_text = mojang_res.text().await.map_err(|e| format!("Failed to read mojang text: {}", e))?;
let mojang_data: serde_json::Value = serde_json::from_str(&mojang_text).map_err(|e| format!("Invalid Mojang JSON: {}", e))?;
let id = mojang_data.get("id").and_then(|v| v.as_str()).ok_or_else(|| "Invalid Moajng response format".to_string())?;
let name_exact = mojang_data.get("name").and_then(|v| v.as_str()).unwrap_or(&username).to_string();
let mc_api_url = format!("https://api.minecraftapi.net/v3/profile/{}", id);
let mc_api_res = client.get(&mc_api_url).send().await.map_err(|e| format!("Failed request to mc api: {}", e))?;
if !mc_api_res.status().is_success() {
return Err("Error fetching skin data".to_string());
}
let mc_api_text = mc_api_res.text().await.map_err(|e| format!("Failed to read mc api text: {}", e))?;
let mc_api_data: serde_json::Value = serde_json::from_str(&mc_api_text).map_err(|e| format!("Invalid MC API JSON: {}", e))?;
let image_b64 = mc_api_data.get("skin")
.and_then(|s| s.get("image"))
.and_then(|v| v.as_str())
.ok_or_else(|| "No skin found".to_string())?;
Ok((image_b64.to_string(), name_exact))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(DownloadState { token: Arc::new(Mutex::new(None)) })
.manage(GameState { child: Arc::new(Mutex::new(None)) })
.plugin(tauri_plugin_gamepad::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_drpc::init())
.invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}