From a647f0c7dc23dfc91d1c42773124858f744d4d86 Mon Sep 17 00:00:00 2001 From: neoapps-dev Date: Sat, 23 May 2026 18:56:16 +0300 Subject: [PATCH] feat(rust): full refactor to everything. now code is readable --- src-tauri/src/commands/config.rs | 55 + src-tauri/src/commands/download.rs | 263 +++ src-tauri/src/commands/file_dialogs.rs | 51 + src-tauri/src/commands/game.rs | 484 +++++ src-tauri/src/commands/macos_setup.rs | 216 ++ src-tauri/src/commands/mod.rs | 10 + src-tauri/src/commands/proxy_cmd.rs | 30 + src-tauri/src/commands/runners.rs | 113 ++ src-tauri/src/commands/skin.rs | 185 ++ src-tauri/src/commands/steam.rs | 122 ++ src-tauri/src/commands/workshop.rs | 208 ++ src-tauri/src/config.rs | 42 + src-tauri/src/lib.rs | 2563 +----------------------- src-tauri/src/networking/direct.rs | 93 + src-tauri/src/networking/mod.rs | 3 + src-tauri/src/networking/relay.rs | 314 +++ src-tauri/src/networking/stun.rs | 59 + src-tauri/src/platform/linux.rs | 16 + src-tauri/src/platform/macos.rs | 55 + src-tauri/src/platform/mod.rs | 6 + src-tauri/src/platform/windows.rs | 1 + src-tauri/src/state.rs | 16 + src-tauri/src/types.rs | 119 ++ src-tauri/src/util.rs | 63 + 24 files changed, 2603 insertions(+), 2484 deletions(-) create mode 100644 src-tauri/src/commands/config.rs create mode 100644 src-tauri/src/commands/download.rs create mode 100644 src-tauri/src/commands/file_dialogs.rs create mode 100644 src-tauri/src/commands/game.rs create mode 100644 src-tauri/src/commands/macos_setup.rs create mode 100644 src-tauri/src/commands/mod.rs create mode 100644 src-tauri/src/commands/proxy_cmd.rs create mode 100644 src-tauri/src/commands/runners.rs create mode 100644 src-tauri/src/commands/skin.rs create mode 100644 src-tauri/src/commands/steam.rs create mode 100644 src-tauri/src/commands/workshop.rs create mode 100644 src-tauri/src/config.rs create mode 100644 src-tauri/src/networking/direct.rs create mode 100644 src-tauri/src/networking/mod.rs create mode 100644 src-tauri/src/networking/relay.rs create mode 100644 src-tauri/src/networking/stun.rs create mode 100644 src-tauri/src/platform/linux.rs create mode 100644 src-tauri/src/platform/macos.rs create mode 100644 src-tauri/src/platform/mod.rs create mode 100644 src-tauri/src/platform/windows.rs create mode 100644 src-tauri/src/state.rs create mode 100644 src-tauri/src/types.rs create mode 100644 src-tauri/src/util.rs diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs new file mode 100644 index 0000000..b5ee00e --- /dev/null +++ b/src-tauri/src/commands/config.rs @@ -0,0 +1,55 @@ +use std::fs; +use tauri::AppHandle; +use crate::config; +use crate::types::{AppConfig, ThemePalette}; +use crate::util; +#[tauri::command] +pub fn save_config(app: AppHandle, config_val: AppConfig) { + config::save_config_raw(&app, &config_val); +} + +#[tauri::command] +pub fn load_config(app: AppHandle) -> AppConfig { + config::load_config_raw(app) +} + +#[tauri::command] +pub fn get_external_palettes(app: AppHandle) -> Vec { + let themes_dir = util::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::(&content) { + palettes.push(palette); + } + } + } + } + } + palettes +} + +#[tauri::command] +pub fn import_theme(app: AppHandle) -> Result { + 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 = util::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()) + } +} diff --git a/src-tauri/src/commands/download.rs b/src-tauri/src/commands/download.rs new file mode 100644 index 0000000..1dfb160 --- /dev/null +++ b/src-tauri/src/commands/download.rs @@ -0,0 +1,263 @@ +use std::fs; +use std::io::Write; +use futures_util::StreamExt; +use tauri::{AppHandle, Emitter, State}; +use tokio_util::sync::CancellationToken; +use crate::state::DownloadState; +use crate::util; +#[tauri::command] +#[allow(non_snake_case)] +pub async fn download_and_install( + app: AppHandle, + state: State<'_, DownloadState>, + url: String, + instance_id: String, +) -> Result { + let root = util::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); + } + + let keep_list: std::collections::HashSet<&str> = [ + "Windows64", "Windows64Media", "uid.dat", "username.txt", "settings.dat", + "servers.dat", "servers.txt", "server.properties", "options.txt", "servers.db", + "workshop_files.json", "screenshots", "update_timestamp.txt", + "profile0.dat", "profile1.dat", "profile2.dat", "profile3.dat", + "profile4.dat", "profile5.dat", "profile6.dat", "profile7.dat", + "profile8.dat", "profile9.dat", "profile10.dat" + ].iter().copied().collect(); + if !instance_dir.exists() { + fs::create_dir_all(&instance_dir).map_err(|e| e.to_string())?; + } else { + let workshop_files: std::collections::HashSet = { + let wf_path = instance_dir.join("workshop_files.json"); + fs::read_to_string(&wf_path) + .ok() + .and_then(|s| serde_json::from_str::>(&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 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); + } + } + } + } + } + + 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 last_modified = response.headers().get(reqwest::header::LAST_MODIFIED) + .and_then(|h| h.to_str().ok()) + .unwrap_or("") + .to_string(); + if !last_modified.is_empty() { + let _ = fs::write(instance_dir.join("update_timestamp.txt"), last_modified); + } + + 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 status = std::process::Command::new("bsdtar") + .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()); + } + } + + #[cfg(not(target_os = "linux"))] + { + let mut cmd = std::process::Command::new("tar"); + cmd.args(["-xf", zip_path.to_str().unwrap(), "-C", instance_dir.to_str().unwrap()]); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); + } + let status = cmd.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 name_str = file_name.to_string_lossy(); + let dest_path = instance_dir.join(&file_name); + if keep_list.contains(name_str.as_ref()) && dest_path.exists() { + continue; + } + if fs::rename(inner_entry.path(), &dest_path).is_err() { + if inner_entry.path().is_dir() { + let _ = util::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()) +} + +#[tauri::command] +pub async fn cancel_download(state: State<'_, DownloadState>) -> Result<(), String> { + if let Some(token) = state.token.lock().await.take() { token.cancel(); } + Ok(()) +} + +#[tauri::command] +pub async fn download_runner( + app: AppHandle, + state: State<'_, DownloadState>, + name: String, + url: String, +) -> Result { + let runners_dir = util::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 = std::process::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] +pub async fn check_game_update( + app: AppHandle, + instance_id: String, + url: String, +) -> Result { + let instance_dir = util::get_instance_working_dir(&app, &instance_id); + let timestamp_file = instance_dir.join("update_timestamp.txt"); + let local_timestamp = fs::read_to_string(×tamp_file).unwrap_or_default(); + if local_timestamp.is_empty() { + return Ok(true); + } + + let response = reqwest::Client::new() + .head(&url) + .send() + .await + .map_err(|e| e.to_string())?; + if let Some(remote_header) = response.headers().get(reqwest::header::LAST_MODIFIED) { + if let Ok(remote_timestamp) = remote_header.to_str() { + if remote_timestamp != local_timestamp { + return Ok(true); + } + } + } + + Ok(false) +} diff --git a/src-tauri/src/commands/file_dialogs.rs b/src-tauri/src/commands/file_dialogs.rs new file mode 100644 index 0000000..cff7e80 --- /dev/null +++ b/src-tauri/src/commands/file_dialogs.rs @@ -0,0 +1,51 @@ +use std::fs; +#[tauri::command] +pub fn pick_folder() -> Result { + let folder = rfd::FileDialog::new() + .set_title("Select Export Folder") + .pick_folder(); + + if let Some(path) = folder { + Ok(path.to_string_lossy().to_string()) + } else { + Err("CANCELED".into()) + } +} + +#[tauri::command] +pub fn pick_file(title: String, filters: Vec) -> Result { + let mut dialog = rfd::FileDialog::new().set_title(&title); + if !filters.is_empty() { + let filters_ref: Vec<&str> = filters.iter().map(|s| s.as_str()).collect(); + dialog = dialog.add_filter("Files", &filters_ref); + } + if let Some(path) = dialog.pick_file() { + Ok(path.to_string_lossy().to_string()) + } else { + Err("CANCELED".into()) + } +} + +#[tauri::command] +pub fn save_file_dialog(title: String, filename: String, filters: Vec) -> Result { + let mut dialog = rfd::FileDialog::new().set_title(&title).set_file_name(&filename); + if !filters.is_empty() { + let filters_ref: Vec<&str> = filters.iter().map(|s| s.as_str()).collect(); + dialog = dialog.add_filter("Files", &filters_ref); + } + if let Some(path) = dialog.save_file() { + Ok(path.to_string_lossy().to_string()) + } else { + Err("CANCELED".into()) + } +} + +#[tauri::command] +pub fn write_binary_file(path: String, data: Vec) -> Result<(), String> { + fs::write(path, data).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn read_binary_file(path: String) -> Result, String> { + fs::read(path).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/game.rs b/src-tauri/src/commands/game.rs new file mode 100644 index 0000000..2f5c7a0 --- /dev/null +++ b/src-tauri/src/commands/game.rs @@ -0,0 +1,484 @@ +use std::fs; +use std::path::PathBuf; +use tauri::{AppHandle, Manager, State}; +use tauri_plugin_opener::OpenerExt; +use crate::commands::runners; +use crate::config; +#[cfg(target_os = "macos")] +use crate::platform::macos; +#[cfg(unix)] +use crate::platform::linux; +use crate::state::GameState; +use crate::types::McServer; +use crate::util; +#[tauri::command] +#[allow(non_snake_case)] +pub async fn launch_game( + app: AppHandle, + state: State<'_, GameState>, + instance_id: String, + mut servers: Vec, + extra_args: Vec, +) -> Result<(), String> { + perform_instance_sync(&app, &instance_id).await?; + let working_dir = util::get_instance_working_dir(&app, &instance_id); + let config_val = config::load_config_raw(app.clone()); + let lce_live = McServer { name: "LCELive Game".into(), ip: "127.0.0.1".into(), port: 61000 }; + if !servers.iter().any(|s| s.ip == lce_live.ip && s.port == lce_live.port) { + servers.push(lce_live); + } + if let Some(ref saved) = config_val.saved_servers { + for s in saved { + if !servers.iter().any(|existing| existing.ip == s.ip && existing.port == s.port) { + servers.push(s.clone()); + } + } + } + ensure_server_list(&working_dir, servers); + let game_exe = working_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_val.linux_runner { + let runners_list = runners::get_available_runners(app.clone()); + if let Some(runner) = runners_list.into_iter().find(|r| r.id == runner_id) { + let is_proton = runner.r#type == "proton"; + let program = if is_proton { + PathBuf::from(&runner.path).join("proton").to_string_lossy().to_string() + } else { + runner.path.clone() + }; + let mut args: Vec = Vec::new(); + if is_proton { + args.push("run".to_string()); + } + let compat_data = if is_proton { + let cd = working_dir.join("proton_prefix"); + fs::create_dir_all(&cd).map_err(|e| e.to_string())?; + Some(cd) + } else { + None + }; + + let mangohud = config_val.mangohud_enabled.unwrap_or(false); + let (prog, runner_args): (&str, &[&str]) = if mangohud { + ("mangohud", &[&program]) + } else { + (&program, &[]) + }; + + let mut cmd = tokio::process::Command::new(prog); + for a in runner_args { + cmd.arg(a); + } + for a in &args { + cmd.arg(a); + } + + if is_proton { + let cd = compat_data.as_ref().unwrap(); + if std::env::var("STEAM_COMPAT_CLIENT_INSTALL_PATH").is_err() { + cmd.env("STEAM_COMPAT_CLIENT_INSTALL_PATH", ""); + } + cmd.env("STEAM_COMPAT_DATA_PATH", cd.to_str().unwrap()); + if std::env::var("SteamAppId").is_err() { + cmd.env("SteamAppId", "480"); + } + } + + #[cfg(unix)] + { + cmd.process_group(0); + cmd.env_remove("LD_PRELOAD"); + cmd.env_remove("PYTHONPATH"); + cmd.env_remove("PYTHONHOME"); + cmd.env_remove("LD_LIBRARY_PATH"); + cmd.env_remove("QT_PLUGIN_PATH"); + } + + cmd.arg(&game_exe); + for a in &extra_args { + cmd.arg(a); + } + cmd.current_dir(&working_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 = macos::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 = macos::find_executable_recursive(&toolkit_dir, "gameportingtoolkit-no-hud") + .or_else(|| macos::find_executable_recursive(&toolkit_dir, "gameportingtoolkit")); + + let wine_binary = macos::find_executable_recursive(&toolkit_dir, "wine64") + .or_else(|| macos::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 = util::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 + }; + for a in &extra_args { + cmd.arg(a); + } + + #[cfg(unix)] + cmd.process_group(0); + + cmd.current_dir(&working_dir); + cmd.env("WINEPREFIX", &prefix_dir); + cmd.env("WINEDEBUG", "-all"); + let perf_boost = config_val.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(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::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); + for a in &extra_args { + cmd.arg(a); + } + #[cfg(unix)] + cmd.process_group(0); + cmd.current_dir(&working_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)) + }; + } + } +} + +#[tauri::command] +pub async fn stop_game( + app: AppHandle, + instance_id: String, + state: State<'_, GameState>, +) -> Result<(), String> { + let mut lock = state.child.lock().await; + if let Some(mut child) = lock.take() { + #[cfg(unix)] + linux::kill_process_tree(&app, &instance_id); + let _ = child.kill().await; + } + Ok(()) +} + +#[tauri::command] +#[allow(non_snake_case)] +pub fn check_game_installed(app: AppHandle, instance_id: String) -> bool { + util::get_instance_working_dir(&app, &instance_id) + .join("Minecraft.Client.exe") + .exists() +} + +#[tauri::command] +#[allow(non_snake_case)] +pub fn open_instance_folder(app: AppHandle, instance_id: String) { + let folder = util::get_instance_working_dir(&app, &instance_id); + if folder.exists() { + let _ = app.opener().open_path(folder.to_str().unwrap(), None::<&str>); + } +} + +#[tauri::command] +#[allow(non_snake_case)] +pub fn delete_instance(app: AppHandle, instance_id: String) -> Result<(), String> { + let config_val = config::load_config_raw(app.clone()); + if let Some(ref editions) = config_val.custom_editions { + if let Some(edition) = editions.iter().find(|e| e.id == instance_id) { + if edition.path.is_some() { + return Ok(()); + } + } + } + let dir = util::get_app_dir(&app).join("instances").join(&instance_id); + if dir.exists() { + let _ = fs::remove_dir_all(dir); + } + Ok(()) +} + +#[tauri::command] +pub async fn sync_dlc(app: AppHandle, instance_id: String) -> Result<(), String> { + perform_instance_sync(&app, &instance_id).await +} + +#[tauri::command] +pub fn get_instance_path(app: AppHandle, instance_id: String) -> String { + util::get_instance_working_dir(&app, &instance_id) + .to_string_lossy() + .to_string() +} + +pub fn ensure_server_list(instance_dir: &PathBuf, servers: Vec) { + 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 = util::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() { + util::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(()) + } + } +} + +async fn perform_instance_sync(app: &AppHandle, instance_id: &str) -> Result<(), String> { + let target_dir = util::get_instance_working_dir(app, instance_id); + if !target_dir.exists() { + return Err("Instance directory not found".into()); + } + + let config_val = config::load_config_raw(app.clone()); + let _ = fs::write(target_dir.join("username.txt"), &config_val.username); + let skin_pck_path = util::get_app_dir(app).join("Skin.pck"); + if skin_pck_path.exists() { + let skin_dlc_dir = target_dir.join("Windows64Media").join("DLC").join("Custom Skins"); + let _ = fs::create_dir_all(&skin_dlc_dir); + let _ = fs::copy(&skin_pck_path, skin_dlc_dir.join("Skin.pck")); + } + + perform_dlc_sync(app, &target_dir)?; + Ok(()) +} diff --git a/src-tauri/src/commands/macos_setup.rs b/src-tauri/src/commands/macos_setup.rs new file mode 100644 index 0000000..5de9cee --- /dev/null +++ b/src-tauri/src/commands/macos_setup.rs @@ -0,0 +1,216 @@ +use tauri::AppHandle; +#[cfg(target_os = "macos")] +use std::io::Write; +#[cfg(target_os = "macos")] +use std::process::Command; +#[cfg(target_os = "macos")] +use std::process::Stdio; +#[cfg(target_os = "macos")] +use futures_util::StreamExt; +#[cfg(target_os = "macos")] +use tauri::Emitter; +#[cfg(target_os = "macos")] +use serde::Deserialize; +#[cfg(target_os = "macos")] +use crate::platform::macos; +#[tauri::command] +pub fn check_macos_runtime_installed(_app: AppHandle) -> bool { + #[cfg(target_os = "macos")] + { macos::is_macos_runtime_installed(&_app) } + #[cfg(not(target_os = "macos"))] + { false } +} + +#[tauri::command] +pub fn check_macos_runtime_installed_fast(_app: AppHandle) -> bool { + #[cfg(target_os = "macos")] + { macos::is_macos_runtime_installed(&_app) } + #[cfg(not(target_os = "macos"))] + { false } +} + +#[tauri::command] +pub 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, + } + + macos::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 = macos::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())?; + + macos::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; + macos::emit_macos_setup_progress( + &window, + "downloading", + format!("Downloading runtime… {}%", rounded), + Some(percent), + ); + } + } + } + drop(file); + macos::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 = macos::find_executable_recursive(&toolkit_dir, "wine64") + .or_else(|| macos::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())?; + + macos::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()); + } + + macos::emit_macos_setup_progress(&window, "done", "Setup complete.".into(), Some(100.0)); + Ok(()) + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..b5d0d5f --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,10 @@ +pub mod config; +pub mod download; +pub mod file_dialogs; +pub mod game; +pub mod macos_setup; +pub mod proxy_cmd; +pub mod runners; +pub mod skin; +pub mod steam; +pub mod workshop; diff --git a/src-tauri/src/commands/proxy_cmd.rs b/src-tauri/src/commands/proxy_cmd.rs new file mode 100644 index 0000000..5002c82 --- /dev/null +++ b/src-tauri/src/commands/proxy_cmd.rs @@ -0,0 +1,30 @@ +use crate::types::HttpResponse; +#[tauri::command] +pub async fn http_proxy_request( + method: String, + url: String, + body: Option, + headers: std::collections::HashMap, +) -> Result { + let client = reqwest::Client::new(); + let mut req = match method.to_uppercase().as_str() { + "GET" => client.get(&url), + "POST" => client.post(&url), + "PUT" => client.put(&url), + "DELETE" => client.delete(&url), + _ => return Err(format!("Unsupported method: {}", method)), + }; + for (k, v) in headers { + req = req.header(k, v); + } + if let Some(b) = body { + req = req.body(b); + } + let res = req.send().await.map_err(|e| e.to_string())?; + let status = res.status().as_u16(); + let text = res.text().await.map_err(|e| e.to_string())?; + Ok(HttpResponse { + status, + body: text, + }) +} diff --git a/src-tauri/src/commands/runners.rs b/src-tauri/src/commands/runners.rs new file mode 100644 index 0000000..6965d96 --- /dev/null +++ b/src-tauri/src/commands/runners.rs @@ -0,0 +1,113 @@ +use std::fs; +use std::process::Command; +use tauri::AppHandle; +use crate::types::Runner; +use crate::util; +#[tauri::command] +pub fn get_available_runners(app: AppHandle) -> Vec { + let mut runners = Vec::new(); + let mut seen_paths: std::collections::HashSet = 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), + "/usr/share/Steam/compatibilitytools.d".to_string() + ]; + + 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.to_lowercase().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 = util::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 +} diff --git a/src-tauri/src/commands/skin.rs b/src-tauri/src/commands/skin.rs new file mode 100644 index 0000000..2108c20 --- /dev/null +++ b/src-tauri/src/commands/skin.rs @@ -0,0 +1,185 @@ +use std::fs; +use std::path::PathBuf; +use tauri::AppHandle; +use tauri_plugin_opener::OpenerExt; +use base64::Engine; +use crate::types::ScreenshotInfo; +use crate::config; +use crate::util; +#[tauri::command] +pub 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)) +} + +#[tauri::command] +pub async fn download_logo(app: AppHandle, id: String, url: String) -> Result { + let logos_dir = util::get_app_dir(&app).join("logos"); + fs::create_dir_all(&logos_dir).map_err(|e| e.to_string())?; + let file_ext = if url.to_lowercase().ends_with(".png") { + "png" + } else if url.to_lowercase().ends_with(".jpg") || url.to_lowercase().ends_with(".jpeg") { + "jpg" + } else { + "png" + }; + + let filename = format!("{}.{}", id, file_ext); + let dest_path = logos_dir.join(&filename); + let response = reqwest::get(&url).await.map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("Failed to download logo: {}", response.status())); + } + + let bytes = response.bytes().await.map_err(|e| e.to_string())?; + fs::write(&dest_path, bytes).map_err(|e| e.to_string())?; + Ok(dest_path.to_string_lossy().to_string()) +} + +#[tauri::command] +pub fn get_screenshots(app: AppHandle) -> Vec { + let mut screenshots = Vec::new(); + let root = util::get_app_dir(&app); + let mut instance_dirs = vec![root.join("instances")]; + let config_val = config::load_config_raw(app.clone()); + if let Some(ref editions) = config_val.custom_editions { + for ed in editions { + if let Some(path) = &ed.path { + instance_dirs.push(PathBuf::from(path)); + } + } + } + + for base_dir in instance_dirs { + if base_dir.ends_with("instances") { + if let Ok(entries) = fs::read_dir(&base_dir) { + for entry in entries.flatten() { + if entry.path().is_dir() { + let instance_id = entry.file_name().to_string_lossy().to_string(); + let dirs_to_check = [ + entry.path().join("screenshots"), + entry.path().join("Windows64").join("GameHDD"), + ]; + for screenshots_dir in dirs_to_check { + if let Ok(files) = fs::read_dir(screenshots_dir) { + for file in files.flatten() { + let path = file.path(); + if path.extension().and_then(|s| s.to_str()) == Some("png") { + let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + let date = path.metadata().and_then(|m| m.modified()).ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + screenshots.push(ScreenshotInfo { + path: path.to_string_lossy().to_string(), + instance_id: instance_id.clone(), + name, + date, + }); + } + } + } + } + } + } + } + } else { + let instance_id = base_dir.file_name().and_then(|n| n.to_str()).unwrap_or("custom").to_string(); + let final_id = config_val.custom_editions.as_ref() + .and_then(|eds| eds.iter().find(|e| e.path.as_deref() == base_dir.to_str()).map(|e| e.id.clone())) + .unwrap_or(instance_id); + + let dirs_to_check = [ + base_dir.join("screenshots"), + base_dir.join("Windows64").join("GameHDD"), + ]; + for screenshots_dir in dirs_to_check { + if let Ok(files) = fs::read_dir(screenshots_dir) { + for file in files.flatten() { + let path = file.path(); + if path.extension().and_then(|s| s.to_str()) == Some("png") { + let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + let date = path.metadata().and_then(|m| m.modified()).ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + screenshots.push(ScreenshotInfo { + path: path.to_string_lossy().to_string(), + instance_id: final_id.clone(), + name, + date, + }); + } + } + } + } + } + } + screenshots.sort_by(|a, b| b.date.cmp(&a.date)); + screenshots +} + +#[tauri::command] +pub fn delete_screenshot(path: String) -> Result<(), String> { + fs::remove_file(path).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn open_screenshot_folder(app: AppHandle, path: String) { + let p = std::path::Path::new(&path); + if let Some(parent) = p.parent() { + if parent.exists() { + let _ = app.opener().open_path(parent.to_str().unwrap(), None::<&str>); + } + } +} + +#[tauri::command] +pub async fn save_global_skin_pck(app: AppHandle, pck_data: Vec) -> Result<(), String> { + let app_dir = util::get_app_dir(&app); + let _ = fs::write(app_dir.join("Skin.pck"), pck_data); + Ok(()) +} + +#[tauri::command] +pub fn read_screenshot_as_data_url(path: String) -> Result { + let data = fs::read(&path).map_err(|e| e.to_string())?; + let mime = if path.to_lowercase().ends_with(".png") { + "image/png" + } else if path.to_lowercase().ends_with(".jpg") || path.to_lowercase().ends_with(".jpeg") { + "image/jpeg" + } else if path.to_lowercase().ends_with(".gif") { + "image/gif" + } else if path.to_lowercase().ends_with(".webp") { + "image/webp" + } else if data.len() > 4 && data[..4] == [0x89, 0x50, 0x4E, 0x47] { + "image/png" + } else if data.len() > 2 && data[..2] == [0xFF, 0xD8] { + "image/jpeg" + } else { + "image/png" + }; + let b64 = base64::engine::general_purpose::STANDARD.encode(&data); + Ok(format!("data:{};base64,{}", mime, b64)) +} diff --git a/src-tauri/src/commands/steam.rs b/src-tauri/src/commands/steam.rs new file mode 100644 index 0000000..6d52494 --- /dev/null +++ b/src-tauri/src/commands/steam.rs @@ -0,0 +1,122 @@ +use std::fs; +use std::path::PathBuf; +use base64::Engine; +use steam_shortcuts_util::{Shortcut, parse_shortcuts, shortcuts_to_bytes}; +use tauri::AppHandle; +#[tauri::command] +pub async fn add_to_steam( + _app: AppHandle, + instance_id: String, + name: String, + title_base64: String, + panorama_base64: String, +) -> Result<(), String> { + let in_flatpak = std::path::Path::new("/.flatpak-info").exists(); + let (exe_str, launch_options, start_dir) = if in_flatpak { + ( + "/usr/bin/flatpak".to_string(), + format!("run io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher \"{}\"", instance_id), + std::env::var("HOME").unwrap_or_default(), + ) + } else { + let exe_path = std::env::current_exe().map_err(|e| e.to_string())?; + let s = exe_path.to_string_lossy().to_string(); + let dir = exe_path.parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_default(); + (s, format!("\"{}\"", instance_id), dir) + }; + let app_id_32 = steam_shortcuts_util::app_id_generator::calculate_app_id(&exe_str, &name); + let mut userdata_dirs: Vec = Vec::new(); + #[cfg(target_os = "linux")] + { + if let Ok(home) = std::env::var("HOME") { + let h = PathBuf::from(home); + userdata_dirs.push(h.join(".steam/steam/userdata")); + userdata_dirs.push(h.join(".local/share/Steam/userdata")); + userdata_dirs.push(h.join(".var/app/com.valvesoftware.Steam/.local/share/Steam/userdata")); + } + } + #[cfg(target_os = "windows")] + { + userdata_dirs.push(PathBuf::from("C:\\Program Files\\Steam\\userdata")); + userdata_dirs.push(PathBuf::from("C:\\Program Files (x86)\\Steam\\userdata")); + } + #[cfg(target_os = "macos")] + { + if let Ok(home) = std::env::var("HOME") { + let h = PathBuf::from(home); + userdata_dirs.push(h.join("Library/Application Support/Steam/userdata")); + } + } + + let valid_userdata_dirs: Vec = userdata_dirs.into_iter().filter(|d| d.exists()).collect(); + if valid_userdata_dirs.is_empty() { + return Err("Steam userdata directory not found.".into()); + } + + for userdata_root in valid_userdata_dirs { + let entries = fs::read_dir(&userdata_root).map_err(|e| e.to_string())?; + for entry in entries.flatten() { + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + let user_config_dir = entry.path().join("config"); + let shortcuts_path = user_config_dir.join("shortcuts.vdf"); + if !user_config_dir.exists() { continue; } + let content = if shortcuts_path.exists() { + fs::read(&shortcuts_path).unwrap_or_default() + } else { + Vec::new() + }; + + let shortcuts = if !content.is_empty() { + parse_shortcuts(&content).map_err(|e| e.to_string())? + } else { + Vec::new() + }; + + let mut owned_shortcuts: Vec = shortcuts.iter().map(|s| s.to_owned()).collect(); + if owned_shortcuts.iter().any(|s| s.app_name == name && s.exe == exe_str) { + continue; + } + + owned_shortcuts.push(steam_shortcuts_util::shortcut::ShortcutOwned { + order: owned_shortcuts.len().to_string(), + app_id: app_id_32, + app_name: name.clone(), + exe: exe_str.clone(), + start_dir: start_dir.clone(), + icon: "".to_string(), + shortcut_path: "".to_string(), + launch_options: launch_options.clone(), + is_hidden: false, + allow_desktop_config: true, + allow_overlay: true, + open_vr: 0, + dev_kit: 0, + dev_kit_game_id: "".to_string(), + dev_kit_overrite_app_id: 0, + last_play_time: 0, + tags: Vec::new(), + }); + + let final_shortcuts: Vec = owned_shortcuts.iter().map(|s| s.borrow()).collect(); + let new_content = shortcuts_to_bytes(&final_shortcuts); + fs::write(&shortcuts_path, new_content).map_err(|e| e.to_string())?; + let grid_dir = user_config_dir.join("grid"); + if !grid_dir.exists() { + let _ = fs::create_dir_all(&grid_dir); + } + + if grid_dir.exists() { + let panorama_data = base64::engine::general_purpose::STANDARD.decode(panorama_base64.clone()).map_err(|e| e.to_string())?; + let title_data = base64::engine::general_purpose::STANDARD.decode(title_base64.clone()).map_err(|e| e.to_string())?; + let _ = fs::write(grid_dir.join(format!("{}p.png", app_id_32)), &panorama_data); + let _ = fs::write(grid_dir.join(format!("{}_hero.png", app_id_32)), &panorama_data); + let _ = fs::write(grid_dir.join(format!("{}.png", app_id_32)), &panorama_data); + let _ = fs::write(grid_dir.join(format!("{}_logo.png", app_id_32)), &title_data); + let _ = fs::write(grid_dir.join(format!("{}.json", app_id_32)), "{\"nVersion\":1,\"logoPosition\":{\"pinnedPosition\":\"CenterCenter\",\"nWidthPct\":50,\"nHeightPct\":50}}".as_bytes()); + } + } + } + } + + Ok(()) +} diff --git a/src-tauri/src/commands/workshop.rs b/src-tauri/src/commands/workshop.rs new file mode 100644 index 0000000..45243bc --- /dev/null +++ b/src-tauri/src/commands/workshop.rs @@ -0,0 +1,208 @@ +use std::fs; +use std::path::PathBuf; +use tauri::AppHandle; +use crate::types::{WorkshopInstallRequest, InstalledWorkshopPackage, InstalledPackageEntry}; +use crate::config; +use crate::util; +#[tauri::command] +pub async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -> Result<(), String> { + let instance_dir = util::get_instance_working_dir(&app, &request.instance_id); + if !instance_dir.exists() { + return Err("Instance not installed".into()); + } + let root = util::get_app_dir(&app); + 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 wp_path = instance_dir.join("workshop_packages.json"); + let mut workshop_files: Vec = fs::read_to_string(&wf_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + let mut workshop_packages: Vec = fs::read_to_string(&wp_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + workshop_packages.retain(|p| p.id != request.package_id); + let raw_base = format!("https://raw.githubusercontent.com/LCE-Hub/LCE-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())?; + let mut pkg_dirs: Vec = Vec::new(); + 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 = instance_dir.clone().join(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 = std::process::Command::new("bsdtar") + .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)); + } + } + #[cfg(not(target_os = "linux"))] + { + let status = std::process::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.clone()); + } + if !pkg_dirs.contains(&dest_str) { + pkg_dirs.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); + } + + workshop_packages.push(InstalledWorkshopPackage { + id: request.package_id.clone(), + version: request.version.clone(), + dirs: pkg_dirs, + }); + if let Ok(json) = serde_json::to_string(&workshop_packages) { + let _ = fs::write(&wp_path, json); + } + + Ok(()) +} + +#[tauri::command] +pub async fn workshop_uninstall(app: AppHandle, instance_id: String, package_id: String) -> Result<(), String> { + let instance_dir = util::get_instance_working_dir(&app, &instance_id); + let wp_path = instance_dir.join("workshop_packages.json"); + let wf_path = instance_dir.join("workshop_files.json"); + let mut packages: Vec = fs::read_to_string(&wp_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + if let Some(pkg) = packages.iter().find(|p| p.id == package_id) { + for dir in &pkg.dirs { + let path = PathBuf::from(dir); + if path.is_dir() { + let _ = fs::remove_dir_all(&path); + } else if path.is_file() { + let _ = fs::remove_file(&path); + } + } + } + + let removed_dirs: std::collections::HashSet = packages + .iter() + .find(|p| p.id == package_id) + .map(|p| p.dirs.iter().cloned().collect()) + .unwrap_or_default(); + + packages.retain(|p| p.id != package_id); + if let Ok(json) = serde_json::to_string(&packages) { + let _ = fs::write(&wp_path, json); + } + + let mut workshop_files: Vec = fs::read_to_string(&wf_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + workshop_files.retain(|f| !removed_dirs.contains(f)); + if let Ok(json) = serde_json::to_string(&workshop_files) { + let _ = fs::write(&wf_path, json); + } + + Ok(()) +} + +#[tauri::command] +pub fn workshop_list_installed(app: AppHandle) -> Vec { + let root = util::get_app_dir(&app); + let mut result = Vec::new(); + let mut instance_dirs = vec![root.join("instances")]; + let config_val = config::load_config_raw(app.clone()); + if let Some(editions) = config_val.custom_editions { + for ed in editions { + if let Some(path) = ed.path { + instance_dirs.push(PathBuf::from(path)); + } + } + } + + for base_dir in instance_dirs { + if base_dir.ends_with("instances") { + if let Ok(entries) = fs::read_dir(&base_dir) { + for entry in entries.flatten() { + if !entry.path().is_dir() { continue; } + let instance_id = entry.file_name().to_string_lossy().to_string(); + let wp_path = entry.path().join("workshop_packages.json"); + let packages: Vec = fs::read_to_string(&wp_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + for pkg in packages { + result.push(InstalledPackageEntry { + instance_id: instance_id.clone(), + package_id: pkg.id, + version: pkg.version, + }); + } + } + } + } else { + let instance_id = base_dir.file_name().and_then(|n| n.to_str()).unwrap_or("custom").to_string(); + let config_val = config::load_config_raw(app.clone()); + let final_id = config_val.custom_editions.as_ref() + .and_then(|eds| eds.iter().find(|e| e.path.as_deref() == base_dir.to_str()).map(|e| e.id.clone())) + .unwrap_or(instance_id); + + let wp_path = base_dir.join("workshop_packages.json"); + let packages: Vec = fs::read_to_string(&wp_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + for pkg in packages { + result.push(InstalledPackageEntry { + instance_id: final_id.clone(), + package_id: pkg.id, + version: pkg.version, + }); + } + } + } + result +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs new file mode 100644 index 0000000..b876c8b --- /dev/null +++ b/src-tauri/src/config.rs @@ -0,0 +1,42 @@ +use std::fs; +use tauri::AppHandle; +use crate::types::AppConfig; +use crate::util; +pub fn load_config_raw(app: AppHandle) -> AppConfig { + let path = util::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 = util::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), + mangohud_enabled: None, + saved_servers: None, + } +} + +pub fn save_config_raw(app: &AppHandle, config: &AppConfig) { + let path = util::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); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8f44c28..f9dbaf9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,2496 +1,91 @@ +mod types; +mod state; +mod config; +mod util; +mod platform; +mod networking; +mod commands; use std::collections::HashMap; -use std::fs; -use std::io::Write; -use std::path::PathBuf; -use std::process::Command; -use steam_shortcuts_util::{Shortcut, parse_shortcuts, shortcuts_to_bytes}; - -#[cfg(target_os = "macos")] -use std::process::Stdio; - -use tauri::{AppHandle, Emitter, State, Manager}; -use futures_util::{StreamExt, SinkExt}; use std::sync::Arc; use tokio::sync::Mutex; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio_util::sync::CancellationToken; -use tauri_plugin_opener::OpenerExt; -use serde::{Deserialize, Serialize}; -use base64::{Engine as _, engine::general_purpose}; - -#[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, - pub path: Option, - pub category: Option>, - pub logo: Option, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -#[serde(rename_all = "camelCase")] -pub struct AppConfig { - pub username: String, - pub linux_runner: Option, - pub skin_base64: Option, - pub skin_library: Option>, - pub theme_style_id: Option, - pub theme_palette_id: Option, - pub apple_silicon_performance_boost: Option, - pub custom_editions: Option>, - pub profile: Option, - pub animations_enabled: Option, - pub vfx_enabled: Option, - pub rpc_enabled: Option, - pub music_vol: Option, - pub sfx_vol: Option, - pub legacy_mode: Option, - pub mangohud_enabled: Option, - pub saved_servers: Option>, -} - -#[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, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -#[serde(rename_all = "camelCase")] -pub struct ScreenshotInfo { - pub path: String, - pub instance_id: String, - pub name: String, - pub date: u64, -} - -#[derive(Serialize)] -pub struct HttpResponse { - status: u16, - body: String, -} - -pub struct DownloadState { pub token: Arc>> } -pub struct GameState { pub child: Arc>> } - -pub struct ProxyGuard { - pub cancel_tokens: Arc>>, - pub local_port: Arc>>, -} - -#[derive(Serialize, Clone, Debug)] -#[serde(rename_all = "camelCase")] -#[cfg(target_os = "macos")] -struct MacosSetupProgressPayload { - stage: String, - message: String, - percent: Option, -} - -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") -} - -fn get_instance_working_dir(app: &AppHandle, instance_id: &str) -> PathBuf { - let root = get_app_dir(app); - let config = load_config(app.clone()); - if let Some(ref editions) = config.custom_editions { - if let Some(edition) = editions.iter().find(|e| e.id == instance_id) { - if let Some(ref path) = edition.path { - return PathBuf::from(path); - } - } - } - root.join("instances").join(instance_id) -} - -#[cfg(target_os = "macos")] -fn emit_macos_setup_progress(window: &tauri::Window, stage: &str, message: String, percent: Option) { - 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 { - let entries = fs::read_dir(root).ok()?; - for entry in entries.flatten() { - let path = entry.path(); - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - if name == file_name { - return Some(path); - } - } - - if path.is_dir() { - if let Some(found) = find_executable_recursive(&path, file_name) { - return Some(found); - } - } - } - None -} - -fn is_macos_runtime_installed(_app: &AppHandle) -> bool { - #[cfg(target_os = "macos")] - { - let runtime_dir = get_macos_runtime_dir(_app); - let toolkit_dir = runtime_dir.join("toolkit"); - if !toolkit_dir.exists() { - return false; - } - - let candidates = [ - "Game Porting Toolkit.app", - ]; - - return candidates.iter().any(|name| find_executable_recursive(&toolkit_dir, name).is_some()); - } - - #[cfg(not(target_os = "macos"))] - { - false - } -} - -#[tauri::command] -fn check_macos_runtime_installed(app: AppHandle) -> bool { - is_macos_runtime_installed(&app) -} - -#[tauri::command] -fn check_macos_runtime_installed_fast(app: AppHandle) -> bool { - is_macos_runtime_installed(&app) -} - -#[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), - mangohud_enabled: None, - saved_servers: None, - } -} - -#[tauri::command] -fn get_external_palettes(app: AppHandle) -> Vec { - 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::(&content) { - palettes.push(palette); - } - } - } - } - } - palettes -} - -#[tauri::command] -fn import_theme(app: AppHandle) -> Result { - 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 pick_folder() -> Result { - let folder = rfd::FileDialog::new() - .set_title("Select Export Folder") - .pick_folder(); - - if let Some(path) = folder { - Ok(path.to_string_lossy().to_string()) - } else { - Err("CANCELED".into()) - } -} - -#[tauri::command] -fn pick_file(title: String, filters: Vec) -> Result { - let mut dialog = rfd::FileDialog::new().set_title(&title); - if !filters.is_empty() { - let filters_ref: Vec<&str> = filters.iter().map(|s| s.as_str()).collect(); - dialog = dialog.add_filter("Files", &filters_ref); - } - if let Some(path) = dialog.pick_file() { - Ok(path.to_string_lossy().to_string()) - } else { - Err("CANCELED".into()) - } -} - -#[tauri::command] -fn save_file_dialog(title: String, filename: String, filters: Vec) -> Result { - let mut dialog = rfd::FileDialog::new().set_title(&title).set_file_name(&filename); - if !filters.is_empty() { - let filters_ref: Vec<&str> = filters.iter().map(|s| s.as_str()).collect(); - dialog = dialog.add_filter("Files", &filters_ref); - } - if let Some(path) = dialog.save_file() { - Ok(path.to_string_lossy().to_string()) - } else { - Err("CANCELED".into()) - } -} - -#[tauri::command] -fn write_binary_file(path: String, data: Vec) -> Result<(), String> { - fs::write(path, data).map_err(|e| e.to_string()) -} - -#[tauri::command] -fn read_binary_file(path: String) -> Result, String> { - fs::read(path).map_err(|e| e.to_string()) -} - -#[tauri::command] -fn read_screenshot_as_data_url(path: String) -> Result { - let data = fs::read(&path).map_err(|e| e.to_string())?; - let mime = if path.to_lowercase().ends_with(".png") { - "image/png" - } else if path.to_lowercase().ends_with(".jpg") || path.to_lowercase().ends_with(".jpeg") { - "image/jpeg" - } else if path.to_lowercase().ends_with(".gif") { - "image/gif" - } else if path.to_lowercase().ends_with(".webp") { - "image/webp" - } else if data.len() > 4 && data[..4] == [0x89, 0x50, 0x4E, 0x47] { - "image/png" - } else if data.len() > 2 && data[..2] == [0xFF, 0xD8] { - "image/jpeg" - } else { - "image/png" - }; - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(&data); - Ok(format!("data:{};base64,{}", mime, b64)) -} - -#[tauri::command] -fn get_available_runners(app: AppHandle) -> Vec { - let mut runners = Vec::new(); - let mut seen_paths: std::collections::HashSet = 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), - "/usr/share/Steam/compatibilitytools.d".to_string() - ]; - - 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.to_lowercase().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 { - 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_instance_working_dir(&app, &instance_id).join("Minecraft.Client.exe").exists() -} - -#[tauri::command] -#[allow(non_snake_case)] -fn open_instance_folder(app: AppHandle, instance_id: String) { - let folder = get_instance_working_dir(&app, &instance_id); - if folder.exists() { - let _ = app.opener().open_path(folder.to_str().unwrap(), None::<&str>); - } -} - -#[tauri::command] -#[allow(non_snake_case)] -fn delete_instance(app: AppHandle, instance_id: String) -> Result<(), String> { - let config = load_config(app.clone()); - if let Some(ref editions) = config.custom_editions { - if let Some(edition) = editions.iter().find(|e| e.id == instance_id) { - if edition.path.is_some() { - return Ok(()); - } - } - } - 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, - } - - 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, dst: impl AsRef) -> 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 { - 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); - } - - let keep_list: std::collections::HashSet<&str> = [ - "Windows64", "Windows64Media", "uid.dat", "username.txt", "settings.dat", - "servers.dat", "servers.txt", "server.properties", "options.txt", "servers.db", - "workshop_files.json", "screenshots", "update_timestamp.txt", - "profile0.dat", "profile1.dat", "profile2.dat", "profile3.dat", - "profile4.dat", "profile5.dat", "profile6.dat", "profile7.dat", - "profile8.dat", "profile9.dat", "profile10.dat" - ].iter().copied().collect(); - if !instance_dir.exists() { - fs::create_dir_all(&instance_dir).map_err(|e| e.to_string())?; - } else { - let workshop_files: std::collections::HashSet = { - let wf_path = instance_dir.join("workshop_files.json"); - fs::read_to_string(&wf_path) - .ok() - .and_then(|s| serde_json::from_str::>(&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 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); - } - } - } - } - } - - 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 last_modified = response.headers().get(reqwest::header::LAST_MODIFIED) - .and_then(|h| h.to_str().ok()) - .unwrap_or("") - .to_string(); - if !last_modified.is_empty() { - let _ = fs::write(instance_dir.join("update_timestamp.txt"), last_modified); - } - - 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 status = Command::new("bsdtar") - .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()); - } - } - - #[cfg(not(target_os = "linux"))] - { - let mut cmd = Command::new("tar"); - cmd.args(["-xf", zip_path.to_str().unwrap(), "-C", instance_dir.to_str().unwrap()]); - #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt; - cmd.creation_flags(0x08000000); //neo: this weird flag (named CREATE_NO_WINDOW btw) is for hiding the cmd.exe window - } - let status = cmd.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 name_str = file_name.to_string_lossy(); - let dest_path = instance_dir.join(&file_name); - if keep_list.contains(name_str.as_ref()) && dest_path.exists() { - continue; - } - 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) { - 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(()) - } - } -} - -async fn perform_instance_sync(app: &AppHandle, instance_id: &str) -> Result<(), String> { - let target_dir = get_instance_working_dir(app, instance_id); - if !target_dir.exists() { - return Err("Instance directory not found".into()); - } - - let config = load_config(app.clone()); - let _ = fs::write(target_dir.join("username.txt"), &config.username); - - let skin_pck_path = get_app_dir(app).join("Skin.pck"); - if skin_pck_path.exists() { - let skin_dlc_dir = target_dir.join("Windows64Media").join("DLC").join("Custom Skins"); - let _ = fs::create_dir_all(&skin_dlc_dir); - let _ = fs::copy(&skin_pck_path, skin_dlc_dir.join("Skin.pck")); - } - - perform_dlc_sync(app, &target_dir)?; - Ok(()) -} - -#[tauri::command] -async fn sync_dlc(app: AppHandle, instance_id: String) -> Result<(), String> { - perform_instance_sync(&app, &instance_id).await -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct WorkshopInstallRequest { - instance_id: String, - zips: std::collections::HashMap, - package_id: String, - version: String, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -#[serde(rename_all = "camelCase")] -struct InstalledWorkshopPackage { - id: String, - version: String, - dirs: Vec, -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -#[serde(rename_all = "camelCase")] -struct InstalledPackageEntry { - instance_id: String, - package_id: String, - version: String, -} - -#[tauri::command] -async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -> Result<(), String> { - let instance_dir = get_instance_working_dir(&app, &request.instance_id); - if !instance_dir.exists() { - return Err("Instance not installed".into()); - } - let root = get_app_dir(&app); - - 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 wp_path = instance_dir.join("workshop_packages.json"); - - let mut workshop_files: Vec = fs::read_to_string(&wf_path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default(); - - let mut workshop_packages: Vec = fs::read_to_string(&wp_path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default(); - - workshop_packages.retain(|p| p.id != request.package_id); - let raw_base = format!("https://raw.githubusercontent.com/LCE-Hub/LCE-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())?; - let mut pkg_dirs: Vec = Vec::new(); - 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 = instance_dir.clone().join(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("bsdtar") - .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)); - } - } - #[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.clone()); - } - if !pkg_dirs.contains(&dest_str) { - pkg_dirs.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); - } - - workshop_packages.push(InstalledWorkshopPackage { - id: request.package_id.clone(), - version: request.version.clone(), - dirs: pkg_dirs, - }); - if let Ok(json) = serde_json::to_string(&workshop_packages) { - let _ = fs::write(&wp_path, json); - } - - Ok(()) -} - -#[tauri::command] -async fn workshop_uninstall(app: AppHandle, instance_id: String, package_id: String) -> Result<(), String> { - let instance_dir = get_instance_working_dir(&app, &instance_id); - let wp_path = instance_dir.join("workshop_packages.json"); - let wf_path = instance_dir.join("workshop_files.json"); - - let mut packages: Vec = fs::read_to_string(&wp_path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default(); - - if let Some(pkg) = packages.iter().find(|p| p.id == package_id) { - for dir in &pkg.dirs { - let path = PathBuf::from(dir); - if path.is_dir() { - let _ = fs::remove_dir_all(&path); - } else if path.is_file() { - let _ = fs::remove_file(&path); - } - } - } - - let removed_dirs: std::collections::HashSet = packages - .iter() - .find(|p| p.id == package_id) - .map(|p| p.dirs.iter().cloned().collect()) - .unwrap_or_default(); - - packages.retain(|p| p.id != package_id); - - if let Ok(json) = serde_json::to_string(&packages) { - let _ = fs::write(&wp_path, json); - } - - let mut workshop_files: Vec = fs::read_to_string(&wf_path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default(); - workshop_files.retain(|f| !removed_dirs.contains(f)); - if let Ok(json) = serde_json::to_string(&workshop_files) { - let _ = fs::write(&wf_path, json); - } - - Ok(()) -} - -#[tauri::command] -fn workshop_list_installed(app: AppHandle) -> Vec { - let root = get_app_dir(&app); - let mut result = Vec::new(); - let mut instance_dirs = vec![root.join("instances")]; - - let config = load_config(app.clone()); - if let Some(editions) = config.custom_editions { - for ed in editions { - if let Some(path) = ed.path { - instance_dirs.push(PathBuf::from(path)); - } - } - } - - for base_dir in instance_dirs { - if base_dir.ends_with("instances") { - if let Ok(entries) = fs::read_dir(&base_dir) { - for entry in entries.flatten() { - if !entry.path().is_dir() { continue; } - let instance_id = entry.file_name().to_string_lossy().to_string(); - let wp_path = entry.path().join("workshop_packages.json"); - let packages: Vec = fs::read_to_string(&wp_path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default(); - for pkg in packages { - result.push(InstalledPackageEntry { - instance_id: instance_id.clone(), - package_id: pkg.id, - version: pkg.version, - }); - } - } - } - } else { - let instance_id = base_dir.file_name().and_then(|n| n.to_str()).unwrap_or("custom").to_string(); //neo: this is just fallback - let config = load_config(app.clone()); - let final_id = config.custom_editions.as_ref() - .and_then(|eds| eds.iter().find(|e| e.path.as_deref() == base_dir.to_str()).map(|e| e.id.clone())) - .unwrap_or(instance_id); - - let wp_path = base_dir.join("workshop_packages.json"); - let packages: Vec = fs::read_to_string(&wp_path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default(); - for pkg in packages { - result.push(InstalledPackageEntry { - instance_id: final_id.clone(), - package_id: pkg.id, - version: pkg.version, - }); - } - } - } - result -} - -#[tauri::command] -async fn check_game_update(app: AppHandle, instance_id: String, url: String) -> Result { - let instance_dir = get_instance_working_dir(&app, &instance_id); - let timestamp_file = instance_dir.join("update_timestamp.txt"); - - let local_timestamp = fs::read_to_string(×tamp_file).unwrap_or_default(); - if local_timestamp.is_empty() { - return Ok(true); - } - - let response = reqwest::Client::new().head(&url).send().await.map_err(|e| e.to_string())?; - if let Some(remote_header) = response.headers().get(reqwest::header::LAST_MODIFIED) { - if let Ok(remote_timestamp) = remote_header.to_str() { - if remote_timestamp != local_timestamp { - return Ok(true); - } - } - } - - Ok(false) -} - -#[tauri::command] -#[allow(non_snake_case)] -async fn launch_game(app: AppHandle, state: State<'_, GameState>, instance_id: String, mut servers: Vec, extra_args: Vec) -> Result<(), String> { - perform_instance_sync(&app, &instance_id).await?; - let working_dir = get_instance_working_dir(&app, &instance_id); - let config = load_config(app.clone()); - let lce_live = McServer { name: "LCELive Game".into(), ip: "127.0.0.1".into(), port: 61000 }; - if !servers.iter().any(|s| s.ip == lce_live.ip && s.port == lce_live.port) { - servers.push(lce_live); - } - if let Some(ref saved) = config.saved_servers { - for s in saved { - if !servers.iter().any(|existing| existing.ip == s.ip && existing.port == s.port) { - servers.push(s.clone()); - } - } - } - ensure_server_list(&working_dir, servers); - let game_exe = working_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 is_proton = runner.r#type == "proton"; - let program = if is_proton { - PathBuf::from(&runner.path).join("proton").to_string_lossy().to_string() - } else { - runner.path.clone() - }; - let mut args: Vec = Vec::new(); - if is_proton { - args.push("run".to_string()); - } - let compat_data = if is_proton { - let cd = working_dir.join("proton_prefix"); - fs::create_dir_all(&cd).map_err(|e| e.to_string())?; - Some(cd) - } else { - None - }; - - let mangohud = config.mangohud_enabled.unwrap_or(false); - let (prog, runner_args): (&str, &[&str]) = if mangohud { - ("mangohud", &[&program]) - } else { - (&program, &[]) - }; - - let mut cmd = tokio::process::Command::new(prog); - for a in runner_args { - cmd.arg(a); - } - for a in &args { - cmd.arg(a); - } - - if is_proton { - let cd = compat_data.as_ref().unwrap(); - if std::env::var("STEAM_COMPAT_CLIENT_INSTALL_PATH").is_err() { - cmd.env("STEAM_COMPAT_CLIENT_INSTALL_PATH", ""); - } - cmd.env("STEAM_COMPAT_DATA_PATH", cd.to_str().unwrap()); - if std::env::var("SteamAppId").is_err() { - cmd.env("SteamAppId", "480"); - } - } - - #[cfg(unix)] - { - cmd.process_group(0); - cmd.env_remove("LD_PRELOAD"); - cmd.env_remove("PYTHONPATH"); - cmd.env_remove("PYTHONHOME"); - cmd.env_remove("LD_LIBRARY_PATH"); - cmd.env_remove("QT_PLUGIN_PATH"); - } - - cmd.arg(&game_exe); - for a in &extra_args { - cmd.arg(a); - } - cmd.current_dir(&working_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 - }; - for a in &game_args { - cmd.arg(a); - } - - #[cfg(unix)] - cmd.process_group(0); - - cmd.current_dir(&working_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); - for a in &game_args { - cmd.arg(a); - } - #[cfg(unix)] - cmd.process_group(0); - cmd.current_dir(&working_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::() 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)) -} - -#[tauri::command] -async fn download_logo(app: AppHandle, id: String, url: String) -> Result { - let logos_dir = get_app_dir(&app).join("logos"); - fs::create_dir_all(&logos_dir).map_err(|e| e.to_string())?; - let file_ext = if url.to_lowercase().ends_with(".png") { - "png" - } else if url.to_lowercase().ends_with(".jpg") || url.to_lowercase().ends_with(".jpeg") { - "jpg" - } else { - "png" - }; - - let filename = format!("{}.{}", id, file_ext); - let dest_path = logos_dir.join(&filename); - let response = reqwest::get(&url).await.map_err(|e| e.to_string())?; - if !response.status().is_success() { - return Err(format!("Failed to download logo: {}", response.status())); - } - - let bytes = response.bytes().await.map_err(|e| e.to_string())?; - fs::write(&dest_path, bytes).map_err(|e| e.to_string())?; - - Ok(dest_path.to_string_lossy().to_string()) -} - -#[tauri::command] -fn get_screenshots(app: AppHandle) -> Vec { - let mut screenshots = Vec::new(); - let root = get_app_dir(&app); - let mut instance_dirs = vec![root.join("instances")]; - - let config = load_config(app.clone()); - if let Some(ref editions) = config.custom_editions { - for ed in editions { - if let Some(path) = &ed.path { - instance_dirs.push(PathBuf::from(path)); - } - } - } - - for base_dir in instance_dirs { - if base_dir.ends_with("instances") { - if let Ok(entries) = fs::read_dir(&base_dir) { - for entry in entries.flatten() { - if entry.path().is_dir() { - let instance_id = entry.file_name().to_string_lossy().to_string(); - let dirs_to_check = [ - entry.path().join("screenshots"), - entry.path().join("Windows64").join("GameHDD"), - ]; - for screenshots_dir in dirs_to_check { - if let Ok(files) = fs::read_dir(screenshots_dir) { - for file in files.flatten() { - let path = file.path(); - if path.extension().and_then(|s| s.to_str()) == Some("png") { - let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); - let date = path.metadata().and_then(|m| m.modified()).ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) - .unwrap_or(0); - screenshots.push(ScreenshotInfo { - path: path.to_string_lossy().to_string(), - instance_id: instance_id.clone(), - name, - date, - }); - } - } - } - } - } - } - } - } else { - let instance_id = base_dir.file_name().and_then(|n| n.to_str()).unwrap_or("custom").to_string(); - let final_id = config.custom_editions.as_ref() - .and_then(|eds| eds.iter().find(|e| e.path.as_deref() == base_dir.to_str()).map(|e| e.id.clone())) - .unwrap_or(instance_id); - - let dirs_to_check = [ - base_dir.join("screenshots"), - base_dir.join("Windows64").join("GameHDD"), - ]; - for screenshots_dir in dirs_to_check { - if let Ok(files) = fs::read_dir(screenshots_dir) { - for file in files.flatten() { - let path = file.path(); - if path.extension().and_then(|s| s.to_str()) == Some("png") { - let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); - let date = path.metadata().and_then(|m| m.modified()).ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) - .unwrap_or(0); - screenshots.push(ScreenshotInfo { - path: path.to_string_lossy().to_string(), - instance_id: final_id.clone(), - name, - date, - }); - } - } - } - } - } - } - screenshots.sort_by(|a, b| b.date.cmp(&a.date)); - screenshots -} - -#[tauri::command] -fn delete_screenshot(path: String) -> Result<(), String> { - fs::remove_file(path).map_err(|e| e.to_string()) -} - -#[tauri::command] -async fn add_to_steam( - _app: AppHandle, - instance_id: String, - name: String, - title_base64: String, - panorama_base64: String, -) -> Result<(), String> { - let in_flatpak = std::path::Path::new("/.flatpak-info").exists(); - let (exe_str, launch_options, start_dir) = if in_flatpak { - ( - "/usr/bin/flatpak".to_string(), - format!("run io.github.Emerald_Legacy_Launcher.Emerald_Legacy_Launcher \"{}\"", instance_id), - std::env::var("HOME").unwrap_or_default(), - ) - } else { - let exe_path = std::env::current_exe().map_err(|e| e.to_string())?; - let s = exe_path.to_string_lossy().to_string(); - let dir = exe_path.parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_default(); - (s, format!("\"{}\"", instance_id), dir) - }; - let app_id_32 = steam_shortcuts_util::app_id_generator::calculate_app_id(&exe_str, &name); - //let app_id_64 = ((app_id_32 as u64) << 32) | 0x02000000; //neo: just in case we'll need later. - let mut userdata_dirs: Vec = Vec::new(); - #[cfg(target_os = "linux")] - { - if let Ok(home) = std::env::var("HOME") { - let h = PathBuf::from(home); - userdata_dirs.push(h.join(".steam/steam/userdata")); - userdata_dirs.push(h.join(".local/share/Steam/userdata")); - userdata_dirs.push(h.join(".var/app/com.valvesoftware.Steam/.local/share/Steam/userdata")); - } - } - #[cfg(target_os = "windows")] - { - userdata_dirs.push(PathBuf::from("C:\\Program Files\\Steam\\userdata")); - userdata_dirs.push(PathBuf::from("C:\\Program Files (x86)\\Steam\\userdata")); - } - #[cfg(target_os = "macos")] - { - if let Ok(home) = std::env::var("HOME") { - let h = PathBuf::from(home); - userdata_dirs.push(h.join("Library/Application Support/Steam/userdata")); - } - } - - let valid_userdata_dirs: Vec = userdata_dirs.into_iter().filter(|d| d.exists()).collect(); - if valid_userdata_dirs.is_empty() { - return Err("Steam userdata directory not found.".into()); - } - - for userdata_root in valid_userdata_dirs { - let entries = fs::read_dir(&userdata_root).map_err(|e| e.to_string())?; - for entry in entries.flatten() { - if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { - let user_config_dir = entry.path().join("config"); - let shortcuts_path = user_config_dir.join("shortcuts.vdf"); - if !user_config_dir.exists() { continue; } - let content = if shortcuts_path.exists() { - fs::read(&shortcuts_path).unwrap_or_default() - } else { - Vec::new() - }; - - let shortcuts = if !content.is_empty() { - parse_shortcuts(&content).map_err(|e| e.to_string())? - } else { - Vec::new() - }; - - let mut owned_shortcuts: Vec = shortcuts.iter().map(|s| s.to_owned()).collect(); - if owned_shortcuts.iter().any(|s| s.app_name == name && s.exe == exe_str) { - continue; - } - - owned_shortcuts.push(steam_shortcuts_util::shortcut::ShortcutOwned { - order: owned_shortcuts.len().to_string(), - app_id: app_id_32, - app_name: name.clone(), - exe: exe_str.clone(), - start_dir: start_dir.clone(), - icon: "".to_string(), - shortcut_path: "".to_string(), - launch_options: launch_options.clone(), - is_hidden: false, - allow_desktop_config: true, - allow_overlay: true, - open_vr: 0, - dev_kit: 0, - dev_kit_game_id: "".to_string(), - dev_kit_overrite_app_id: 0, - last_play_time: 0, - tags: Vec::new(), - }); - - let final_shortcuts: Vec = owned_shortcuts.iter().map(|s| s.borrow()).collect(); - let new_content = shortcuts_to_bytes(&final_shortcuts); - fs::write(&shortcuts_path, new_content).map_err(|e| e.to_string())?; - let grid_dir = user_config_dir.join("grid"); - if !grid_dir.exists() { - let _ = fs::create_dir_all(&grid_dir); - } - - if grid_dir.exists() { - let panorama_data = general_purpose::STANDARD.decode(panorama_base64.clone()).map_err(|e| e.to_string())?; - let title_data = general_purpose::STANDARD.decode(title_base64.clone()).map_err(|e| e.to_string())?; - let _ = fs::write(grid_dir.join(format!("{}p.png", app_id_32)), &panorama_data); - let _ = fs::write(grid_dir.join(format!("{}_hero.png", app_id_32)), &panorama_data); - let _ = fs::write(grid_dir.join(format!("{}.png", app_id_32)), &panorama_data); - let _ = fs::write(grid_dir.join(format!("{}_logo.png", app_id_32)), &title_data); - let _ = fs::write(grid_dir.join(format!("{}.json", app_id_32)), "{\"nVersion\":1,\"logoPosition\":{\"pinnedPosition\":\"CenterCenter\",\"nWidthPct\":50,\"nHeightPct\":50}}".as_bytes()); //neo: if you're confused, this is for logo position - } - } - } - } - - Ok(()) -} - -#[tauri::command] -fn open_screenshot_folder(app: AppHandle, path: String) { - let p = std::path::Path::new(&path); - if let Some(parent) = p.parent() { - if parent.exists() { - let _ = app.opener().open_path(parent.to_str().unwrap(), None::<&str>); - } - } -} - -#[tauri::command] -async fn save_global_skin_pck(app: AppHandle, pck_data: Vec) -> Result<(), String> { - let app_dir = get_app_dir(&app); - let _ = fs::write(app_dir.join("Skin.pck"), pck_data); - Ok(()) -} - -#[tauri::command] -async fn http_proxy_request(method: String, url: String, body: Option, headers: std::collections::HashMap) -> Result { - let client = reqwest::Client::new(); - let mut req = match method.to_uppercase().as_str() { - "GET" => client.get(&url), - "POST" => client.post(&url), - "PUT" => client.put(&url), - "DELETE" => client.delete(&url), - _ => return Err(format!("Unsupported method: {}", method)), - }; - for (k, v) in headers { - req = req.header(k, v); - } - if let Some(b) = body { - req = req.body(b); - } - let res = req.send().await.map_err(|e| e.to_string())?; - let status = res.status().as_u16(); - let text = res.text().await.map_err(|e| e.to_string())?; - - Ok(HttpResponse { - status, - body: text, - }) -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct P2pEndpoint { - pub ip: String, - pub port: u16, -} - -fn ws_base_url(api_base_url: &str) -> String { - if api_base_url.starts_with("https") { - api_base_url.replace("https", "wss") - } else { - api_base_url.replace("http", "ws") - } -} - -async fn stun_discover_impl() -> Result { - let socket = tokio::net::UdpSocket::bind("0.0.0.0:0").await.map_err(|e| e.to_string())?; - let stun_addr = tokio::net::lookup_host("stun.l.google.com:19302").await - .map_err(|e| format!("STUN DNS lookup failed: {}", e))? - .next() - .ok_or_else(|| "STUN DNS returned no addresses".to_string())?; - - let magic_cookie: u32 = 0x2112A442; - let mut trans_id = [0u8; 12]; - rand::Rng::fill(&mut rand::thread_rng(), &mut trans_id); - - let mut req = Vec::with_capacity(20); - req.extend_from_slice(&0x0001u16.to_be_bytes()); - req.extend_from_slice(&0x0000u16.to_be_bytes()); - req.extend_from_slice(&magic_cookie.to_be_bytes()); - req.extend_from_slice(&trans_id); - - socket.send_to(&req, stun_addr).await.map_err(|e| format!("STUN send: {}", e))?; - - let mut buf = [0u8; 512]; - tokio::time::timeout( - std::time::Duration::from_secs(5), - socket.recv_from(&mut buf) - ).await.map_err(|_| "STUN request timed out (5s)".to_string())? - .map_err(|e| format!("STUN recv: {}", e))?; - - let msg_type = u16::from_be_bytes([buf[0], buf[1]]); - let rcvd_cookie = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]); - - if msg_type != 0x0101 || rcvd_cookie != magic_cookie { - return Err(format!("Invalid STUN response (type=0x{:04X}, cookie=0x{:08X})", msg_type, rcvd_cookie)); - } - - let mut pos = 20; - while pos + 4 <= buf.len() { - let attr_type = u16::from_be_bytes([buf[pos], buf[pos+1]]); - let attr_len = u16::from_be_bytes([buf[pos+2], buf[pos+3]]) as usize; - pos += 4; - if attr_type == 0x0020 && attr_len >= 8 && pos + 8 <= buf.len() { - let _family = buf[pos+1]; - let xport = u16::from_be_bytes([buf[pos+2], buf[pos+3]]); - let port = xport ^ (magic_cookie >> 16) as u16; - let ip_bytes = [ - buf[pos+4] ^ (magic_cookie >> 24) as u8, - buf[pos+5] ^ (magic_cookie >> 16) as u8, - buf[pos+6] ^ (magic_cookie >> 8) as u8, - buf[pos+7] ^ magic_cookie as u8, - ]; - return Ok(P2pEndpoint { - ip: format!("{}.{}.{}.{}", ip_bytes[0], ip_bytes[1], ip_bytes[2], ip_bytes[3]), - port, - }); - } - pos += attr_len; - } - - Err("No XOR-MAPPED-ADDRESS in STUN response".into()) -} - -#[tauri::command] -async fn stun_discover() -> Result { - stun_discover_impl().await -} - -async fn run_relay_proxy( - proxy_state: &ProxyGuard, - ws_url: &str, - auth_token: &str, - cancel: CancellationToken, -) -> Result { - use tokio_tungstenite::tungstenite::client::IntoClientRequest; - - eprintln!("[Emerald] Joiner relay: connecting WS..."); - let mut request = ws_url - .into_client_request() - .map_err(|e| format!("Failed to build WS request: {}", e))?; - request.headers_mut().insert( - http::header::AUTHORIZATION, - format!("Bearer {}", auth_token) - .parse() - .map_err(|_| "Invalid auth header value".to_string())?, - ); - request.headers_mut().insert( - http::header::USER_AGENT, - "MCLCE-LceLive/1.0" - .parse() - .map_err(|_| "Invalid UA header value".to_string())?, - ); - - let (ws_stream, _) = tokio_tungstenite::connect_async(request) - .await - .map_err(|e| format!("Relay WS connect failed: {}", e))?; - eprintln!("[Emerald] Joiner relay: WS connected"); - - let listener = tokio::net::TcpListener::bind("0.0.0.0:61000") - .await - .map_err(|e| format!("Bind failed: {}", e))?; - let local_port = listener.local_addr().map_err(|e| e.to_string())?.port(); - eprintln!("[Emerald] Joiner relay: bound on 0.0.0.0:{}", local_port); - - { - let mut port = proxy_state.local_port.lock().await; - *port = Some(local_port); - } - - tokio::spawn(async move { - eprintln!("[Emerald] Joiner relay: waiting for TCP accept on port {}...", local_port); - let (tcp_stream, _) = tokio::select! { - result = listener.accept() => { - eprintln!("[Emerald] Joiner relay: TCP accepted"); - result.map_err(|e| format!("Accept failed: {}", e)).unwrap() - }, - _ = cancel.cancelled() => { - eprintln!("[Emerald] Joiner relay: cancelled before accept"); - return; - }, - }; - - eprintln!("[Emerald] Joiner relay: starting forwarders"); - let (tcp_read, tcp_write) = tcp_stream.into_split(); - let (ws_write, ws_read) = ws_stream.split(); - - let cancel_ws = cancel.clone(); - let forward_tcp = tokio::spawn(async move { - let mut ws_write = ws_write; - let mut tcp_read = tcp_read; - let mut buf = [0u8; 65536]; - loop { - tokio::select! { - result = tcp_read.read(&mut buf) => { - match result { - Ok(0) => { - eprintln!("[Emerald] Joiner relay: TCP→WS EOF"); - break; - }, - Err(e) => { - eprintln!("[Emerald] Joiner relay: TCP→WS read error: {e}"); - break; - }, - Ok(n) => { - eprintln!("[Emerald] Joiner relay: TCP→WS forwarding {} bytes", n); - if ws_write.send(tokio_tungstenite::tungstenite::Message::Binary(buf[..n].to_vec())).await.is_err() { - eprintln!("[Emerald] Joiner relay: TCP→WS send error"); - break; - } - } - } - } - _ = cancel_ws.cancelled() => { - eprintln!("[Emerald] Joiner relay: TCP→WS cancelled"); - break; - }, - } - } - }); - - let cancel_tcp = cancel.clone(); - let forward_ws = tokio::spawn(async move { - let ws_read = ws_read; - let mut tcp_write = tcp_write; - tokio::pin!(ws_read); - loop { - tokio::select! { - result = ws_read.next() => { - match result { - Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(data))) => { - eprintln!("[Emerald] Joiner relay: WS→TCP forwarding {} bytes", data.len()); - if tcp_write.write_all(&data).await.is_err() { - eprintln!("[Emerald] Joiner relay: WS→TCP write error"); - break; - } - } - Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => { - eprintln!("[Emerald] Joiner relay: WS→TCP close frame"); - break; - } - None => { - eprintln!("[Emerald] Joiner relay: WS→TCP stream ended"); - break; - } - Some(Err(e)) => { - eprintln!("[Emerald] Joiner relay: WS→TCP error: {e}"); - break; - } - _ => {} - } - } - _ = cancel_tcp.cancelled() => { - eprintln!("[Emerald] Joiner relay: WS→TCP cancelled"); - break; - }, - } - } - }); - - tokio::select! { - _ = forward_tcp => eprintln!("[Emerald] Joiner relay: forward_tcp done"), - _ = forward_ws => eprintln!("[Emerald] Joiner relay: forward_ws done"), - _ = cancel.cancelled() => eprintln!("[Emerald] Joiner relay: cancelled"), - } - eprintln!("[Emerald] Joiner relay: relay task ended"); - }); - - Ok(local_port) -} - -async fn run_host_relay( - _proxy_state: &ProxyGuard, - ws_url: &str, - auth_token: &str, - game_port: u16, - cancel: CancellationToken, -) -> Result<(), String> { - use tokio_tungstenite::tungstenite::client::IntoClientRequest; - - eprintln!("[Emerald] Host relay: connecting WS..."); - let mut request = ws_url - .into_client_request() - .map_err(|e| format!("Failed to build WS request: {}", e))?; - request.headers_mut().insert( - http::header::AUTHORIZATION, - format!("Bearer {}", auth_token) - .parse() - .map_err(|_| "Invalid auth header value".to_string())?, - ); - request.headers_mut().insert( - http::header::USER_AGENT, - "MCLCE-LceLive/1.0" - .parse() - .map_err(|_| "Invalid UA header value".to_string())?, - ); - - let (ws_stream, _) = tokio_tungstenite::connect_async(request) - .await - .map_err(|e| format!("Host relay WS connect failed: {}", e))?; - eprintln!("[Emerald] Host relay: WS connected"); - - eprintln!("[Emerald] Host relay: connecting to game 127.0.0.1:{}...", game_port); - let game_stream = loop { - match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", game_port)).await { - Ok(stream) => { - eprintln!("[Emerald] Host relay: connected to game"); - break stream; - }, - Err(e) => { - eprintln!("[Emerald] Host relay: game connect failed (retrying): {e}"); - tokio::select! { - _ = cancel.cancelled() => return Err("Host relay cancelled".into()), - _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {} - } - } - } - }; - - eprintln!("[Emerald] Host relay: starting forwarders"); - let (game_read, game_write) = game_stream.into_split(); - let (ws_write, ws_read) = ws_stream.split(); - - let cancel_ws = cancel.clone(); - let forward_game = tokio::spawn(async move { - let mut ws_write = ws_write; - let mut game_read = game_read; - let mut buf = [0u8; 65536]; - loop { - tokio::select! { - result = game_read.read(&mut buf) => { - match result { - Ok(0) => { - eprintln!("[Emerald] Host relay: game→WS EOF"); - break; - }, - Err(e) => { - eprintln!("[Emerald] Host relay: game→WS read error: {e}"); - break; - }, - Ok(n) => { - eprintln!("[Emerald] Host relay: game→WS forwarding {} bytes", n); - if ws_write.send(tokio_tungstenite::tungstenite::Message::Binary(buf[..n].to_vec())).await.is_err() { - eprintln!("[Emerald] Host relay: game→WS send error"); - break; - } - } - } - } - _ = cancel_ws.cancelled() => { - eprintln!("[Emerald] Host relay: game→WS cancelled"); - break; - }, - } - } - }); - - let cancel_ws2 = cancel.clone(); - let forward_ws = tokio::spawn(async move { - let ws_read = ws_read; - let mut game_write = game_write; - tokio::pin!(ws_read); - loop { - tokio::select! { - result = ws_read.next() => { - match result { - Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(data))) => { - eprintln!("[Emerald] Host relay: WS→game forwarding {} bytes", data.len()); - if game_write.write_all(&data).await.is_err() { - eprintln!("[Emerald] Host relay: WS→game write error"); - break; - } - } - Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => { - eprintln!("[Emerald] Host relay: WS→game close frame"); - break; - } - None => { - eprintln!("[Emerald] Host relay: WS→game stream ended"); - break; - } - Some(Err(e)) => { - eprintln!("[Emerald] Host relay: WS→game error: {e}"); - break; - } - _ => {} - } - } - _ = cancel_ws2.cancelled() => { - eprintln!("[Emerald] Host relay: WS→game cancelled"); - break; - }, - } - } - }); - - tokio::select! { - _ = forward_game => eprintln!("[Emerald] Host relay: forward_game done"), - _ = forward_ws => eprintln!("[Emerald] Host relay: forward_ws done"), - _ = cancel.cancelled() => eprintln!("[Emerald] Host relay: cancelled"), - } - - Ok(()) -} - -async fn run_direct_proxy( - proxy_state: &ProxyGuard, - target_ip: &str, - target_port: u16, - cancel: CancellationToken, -) -> Result { - let remote = tokio::net::TcpStream::connect(format!("{}:{}", target_ip, target_port)) - .await - .map_err(|e| format!("Direct TCP connect failed: {}", e))?; - - let listener = tokio::net::TcpListener::bind("0.0.0.0:0") - .await - .map_err(|e| format!("Bind failed: {}", e))?; - let local_port = listener.local_addr().map_err(|e| e.to_string())?.port(); - - { - let mut port = proxy_state.local_port.lock().await; - *port = Some(local_port); - } - - let (local_stream, _) = tokio::select! { - result = listener.accept() => result.map_err(|e| format!("Accept failed: {}", e))?, - _ = cancel.cancelled() => return Err("Proxy cancelled".into()), - }; - - let (mut a_read, mut a_write) = remote.into_split(); - let (mut b_read, mut b_write) = local_stream.into_split(); - let cancel_a = cancel.clone(); - - let task_a = tokio::spawn(async move { - let mut buf = [0u8; 65536]; - loop { - tokio::select! { - result = a_read.read(&mut buf) => { - match result { - Ok(0) | Err(_) => break, - Ok(n) => { - if b_write.write_all(&buf[..n]).await.is_err() { break; } - } - } - } - _ = cancel_a.cancelled() => break, - } - } - }); - - let cancel_b = cancel.clone(); - let task_b = tokio::spawn(async move { - let mut buf = [0u8; 65536]; - loop { - tokio::select! { - result = b_read.read(&mut buf) => { - match result { - Ok(0) | Err(_) => break, - Ok(n) => { - if a_write.write_all(&buf[..n]).await.is_err() { break; } - } - } - } - _ = cancel_b.cancelled() => break, - } - } - }); - - tokio::select! { - _ = task_a => {}, - _ = task_b => {}, - _ = cancel.cancelled() => {}, - } - - Ok(local_port) -} - -#[tauri::command] -async fn start_direct_proxy( - proxy_state: State<'_, ProxyGuard>, - target_ip: String, - target_port: u16, -) -> Result { - let cancel = CancellationToken::new(); - let session_id = "__direct__".to_string(); - { - let mut tokens = proxy_state.cancel_tokens.lock().await; - tokens.insert(session_id.clone(), cancel.clone()); - } - - let local_port = run_direct_proxy(&proxy_state, &target_ip, target_port, cancel).await?; - - { - let mut tokens = proxy_state.cancel_tokens.lock().await; - tokens.remove(&session_id); - } - - Ok(local_port) -} - -#[tauri::command] -async fn start_relay_proxy( - proxy_state: State<'_, ProxyGuard>, - api_base_url: String, - access_token: String, - session_id: String, -) -> Result { - let ws_base = ws_base_url(&api_base_url); - let ws_url = format!("{}/api/relay/ws?sessionId={}&role=joiner", ws_base, session_id); - - let cancel = CancellationToken::new(); - { - let mut tokens = proxy_state.cancel_tokens.lock().await; - tokens.insert(session_id.clone(), cancel.clone()); - } - - let local_port = run_relay_proxy(&proxy_state, &ws_url, &access_token, cancel).await?; - - { - let mut tokens = proxy_state.cancel_tokens.lock().await; - tokens.remove(&session_id); - } - - Ok(local_port) -} - -#[tauri::command] -async fn start_host_relay( - proxy_state: State<'_, ProxyGuard>, - api_base_url: String, - access_token: String, - session_id: String, - game_port: u16, -) -> Result<(), String> { - let ws_base = ws_base_url(&api_base_url); - let ws_url = format!("{}/api/relay/ws?sessionId={}&role=host", ws_base, session_id); - - let cancel = CancellationToken::new(); - { - let mut tokens = proxy_state.cancel_tokens.lock().await; - tokens.insert(session_id.clone(), cancel.clone()); - } - - let result = run_host_relay(&proxy_state, &ws_url, &access_token, game_port, cancel).await; - - { - let mut tokens = proxy_state.cancel_tokens.lock().await; - tokens.remove(&session_id); - } - - result -} - -#[tauri::command] -async fn stop_proxy(proxy_state: State<'_, ProxyGuard>, session_id: String) -> Result<(), String> { - let mut tokens = proxy_state.cancel_tokens.lock().await; - if let Some(token) = tokens.remove(&session_id) { - token.cancel(); - } - let mut port = proxy_state.local_port.lock().await; - *port = None; - Ok(()) -} - -#[tauri::command] -async fn stop_all_proxies(proxy_state: State<'_, ProxyGuard>) -> Result<(), String> { - let mut tokens = proxy_state.cancel_tokens.lock().await; - for (_, token) in tokens.drain() { - token.cancel(); - } - let mut port = proxy_state.local_port.lock().await; - *port = None; - Ok(()) -} - -#[tauri::command] -async fn join_game( - app: AppHandle, - game_state: State<'_, GameState>, - _proxy_state: State<'_, ProxyGuard>, - _api_base_url: String, - _access_token: String, - host_ip: String, - host_port: u16, - _session_id: String, - instance_id: String, -) -> Result<(), String> { - let server = McServer { - name: host_ip.clone(), - ip: host_ip, - port: host_port, - }; - - launch_game(app, game_state, instance_id, vec![server], vec![]).await -} - -#[tauri::command] -fn get_instance_path(app: tauri::AppHandle, instance_id: String) -> String { - get_instance_working_dir(&app, &instance_id).to_string_lossy().to_string() -} - +use tauri::Manager; +use commands::config as config_cmds; +use commands::download; +use commands::file_dialogs; +use commands::game; +use commands::macos_setup; +use commands::proxy_cmd; +use commands::runners; +use commands::skin; +use commands::steam; +use commands::workshop; +use networking::direct; +use networking::relay; +use networking::stun; +use state::{DownloadState, GameState, ProxyGuard}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) - .manage(DownloadState { token: Arc::new(Mutex::new(None)) }) - .manage(GameState { child: Arc::new(Mutex::new(None)) }) - .manage(ProxyGuard { cancel_tokens: Arc::new(Mutex::new(HashMap::new())), local_port: Arc::new(Mutex::new(None)) }) + .manage(DownloadState { + token: Arc::new(Mutex::new(None)), + }) + .manage(GameState { + child: Arc::new(Mutex::new(None)), + }) + .manage(ProxyGuard { + cancel_tokens: Arc::new(Mutex::new(HashMap::new())), + local_port: 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, pick_folder, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install, workshop_uninstall, workshop_list_installed, get_screenshots, delete_screenshot, open_screenshot_folder, save_global_skin_pck, check_game_update, check_macos_runtime_installed, check_macos_runtime_installed_fast, download_logo, pick_file, save_file_dialog, write_binary_file, read_binary_file, read_screenshot_as_data_url, add_to_steam, http_proxy_request, get_instance_path, stun_discover, start_direct_proxy, start_relay_proxy, start_host_relay, stop_proxy, stop_all_proxies, join_game]) + .invoke_handler(tauri::generate_handler![ + macos_setup::setup_macos_runtime, + game::launch_game, + game::stop_game, + game::check_game_installed, + config_cmds::save_config, + config_cmds::load_config, + download::download_and_install, + game::open_instance_folder, + download::cancel_download, + runners::get_available_runners, + config_cmds::get_external_palettes, + config_cmds::import_theme, + file_dialogs::pick_folder, + download::download_runner, + game::delete_instance, + game::sync_dlc, + skin::fetch_skin, + workshop::workshop_install, + workshop::workshop_uninstall, + workshop::workshop_list_installed, + skin::get_screenshots, + skin::delete_screenshot, + skin::open_screenshot_folder, + skin::save_global_skin_pck, + download::check_game_update, + macos_setup::check_macos_runtime_installed, + macos_setup::check_macos_runtime_installed_fast, + skin::download_logo, + file_dialogs::pick_file, + file_dialogs::save_file_dialog, + file_dialogs::write_binary_file, + file_dialogs::read_binary_file, + skin::read_screenshot_as_data_url, + steam::add_to_steam, + proxy_cmd::http_proxy_request, + game::get_instance_path, + stun::stun_discover, + direct::start_direct_proxy, + relay::start_relay_proxy, + relay::start_host_relay, + relay::stop_proxy, + relay::stop_all_proxies, + relay::join_game, + ]) .setup(|app| { let args: Vec = std::env::args().collect(); if args.len() > 1 && !args[1].starts_with('-') { @@ -2501,8 +96,8 @@ pub fn run() { let _ = window.hide(); } let state = app_handle.state::(); - match launch_game(app_handle.clone(), state, instance_id, Vec::new(), vec![]).await { - Ok(_) => { app_handle.exit(0); } + match game::launch_game(app_handle.clone(), state, instance_id, Vec::new(), vec![]).await { + Ok(_) => app_handle.exit(0), Err(e) => { eprintln!("Auto-launch error: {}", e); app_handle.exit(1); diff --git a/src-tauri/src/networking/direct.rs b/src-tauri/src/networking/direct.rs new file mode 100644 index 0000000..29eaf1b --- /dev/null +++ b/src-tauri/src/networking/direct.rs @@ -0,0 +1,93 @@ +use tauri::State; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_util::sync::CancellationToken; +use crate::state::ProxyGuard; +async fn run_direct_proxy( + proxy_state: &ProxyGuard, + target_ip: &str, + target_port: u16, + cancel: CancellationToken, +) -> Result { + let remote = tokio::net::TcpStream::connect(format!("{}:{}", target_ip, target_port)) + .await + .map_err(|e| format!("Direct TCP connect failed: {}", e))?; + + let listener = tokio::net::TcpListener::bind("0.0.0.0:0") + .await + .map_err(|e| format!("Bind failed: {}", e))?; + let local_port = listener.local_addr().map_err(|e| e.to_string())?.port(); + { + let mut port = proxy_state.local_port.lock().await; + *port = Some(local_port); + } + + let (local_stream, _) = tokio::select! { + result = listener.accept() => result.map_err(|e| format!("Accept failed: {}", e))?, + _ = cancel.cancelled() => return Err("Proxy cancelled".into()), + }; + + let (mut a_read, mut a_write) = remote.into_split(); + let (mut b_read, mut b_write) = local_stream.into_split(); + let cancel_a = cancel.clone(); + let task_a = tokio::spawn(async move { + let mut buf = [0u8; 65536]; + loop { + tokio::select! { + result = a_read.read(&mut buf) => { + match result { + Ok(0) | Err(_) => break, + Ok(n) => { if b_write.write_all(&buf[..n]).await.is_err() { break; } } + } + } + _ = cancel_a.cancelled() => break, + } + } + }); + + let cancel_b = cancel.clone(); + let task_b = tokio::spawn(async move { + let mut buf = [0u8; 65536]; + loop { + tokio::select! { + result = b_read.read(&mut buf) => { + match result { + Ok(0) | Err(_) => break, + Ok(n) => { if a_write.write_all(&buf[..n]).await.is_err() { break; } } + } + } + _ = cancel_b.cancelled() => break, + } + } + }); + + tokio::select! { + _ = task_a => {}, + _ = task_b => {}, + _ = cancel.cancelled() => {}, + } + + Ok(local_port) +} + +#[tauri::command] +pub async fn start_direct_proxy( + proxy_state: State<'_, ProxyGuard>, + target_ip: String, + target_port: u16, +) -> Result { + let cancel = CancellationToken::new(); + let session_id = "__direct__".to_string(); + { + let mut tokens = proxy_state.cancel_tokens.lock().await; + tokens.insert(session_id.clone(), cancel.clone()); + } + + let local_port = run_direct_proxy(&proxy_state, &target_ip, target_port, cancel).await?; + + { + let mut tokens = proxy_state.cancel_tokens.lock().await; + tokens.remove(&session_id); + } + + Ok(local_port) +} diff --git a/src-tauri/src/networking/mod.rs b/src-tauri/src/networking/mod.rs new file mode 100644 index 0000000..2e65f2d --- /dev/null +++ b/src-tauri/src/networking/mod.rs @@ -0,0 +1,3 @@ +pub mod stun; +pub mod relay; +pub mod direct; diff --git a/src-tauri/src/networking/relay.rs b/src-tauri/src/networking/relay.rs new file mode 100644 index 0000000..ef1ed60 --- /dev/null +++ b/src-tauri/src/networking/relay.rs @@ -0,0 +1,314 @@ +use futures_util::{SinkExt, StreamExt}; +use tauri::State; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_util::sync::CancellationToken; +use crate::state::ProxyGuard; +use crate::util; +async fn run_relay_proxy( + proxy_state: &ProxyGuard, + ws_url: &str, + auth_token: &str, + cancel: CancellationToken, +) -> Result { + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + eprintln!("[Emerald] Joiner relay: connecting WS..."); + let mut request = ws_url + .into_client_request() + .map_err(|e| format!("Failed to build WS request: {}", e))?; + request.headers_mut().insert( + http::header::AUTHORIZATION, + format!("Bearer {}", auth_token) + .parse() + .map_err(|_| "Invalid auth header value".to_string())?, + ); + request.headers_mut().insert( + http::header::USER_AGENT, + "MCLCE-LceLive/1.0" + .parse() + .map_err(|_| "Invalid UA header value".to_string())?, + ); + + let (ws_stream, _) = tokio_tungstenite::connect_async(request) + .await + .map_err(|e| format!("Relay WS connect failed: {}", e))?; + eprintln!("[Emerald] Joiner relay: WS connected"); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:61000") + .await + .map_err(|e| format!("Bind failed: {}", e))?; + let local_port = listener.local_addr().map_err(|e| e.to_string())?.port(); + eprintln!("[Emerald] Joiner relay: bound on 0.0.0.0:{}", local_port); + + { + let mut port = proxy_state.local_port.lock().await; + *port = Some(local_port); + } + + tokio::spawn(async move { + eprintln!("[Emerald] Joiner relay: waiting for TCP accept on port {}...", local_port); + let (tcp_stream, _) = tokio::select! { + result = listener.accept() => { + eprintln!("[Emerald] Joiner relay: TCP accepted"); + result.map_err(|e| format!("Accept failed: {}", e)).unwrap() + }, + _ = cancel.cancelled() => { + eprintln!("[Emerald] Joiner relay: cancelled before accept"); + return; + }, + }; + + eprintln!("[Emerald] Joiner relay: starting forwarders"); + let (tcp_read, tcp_write) = tcp_stream.into_split(); + let (ws_write, ws_read) = ws_stream.split(); + let cancel_ws = cancel.clone(); + let forward_tcp = tokio::spawn(async move { + let mut ws_write = ws_write; + let mut tcp_read = tcp_read; + let mut buf = [0u8; 65536]; + loop { + tokio::select! { + result = tcp_read.read(&mut buf) => { + match result { + Ok(0) => { eprintln!("[Emerald] Joiner relay: TCP→WS EOF"); break; }, + Err(e) => { eprintln!("[Emerald] Joiner relay: TCP→WS read error: {e}"); break; }, + Ok(n) => { + eprintln!("[Emerald] Joiner relay: TCP→WS forwarding {} bytes", n); + if ws_write.send(tokio_tungstenite::tungstenite::Message::Binary(buf[..n].to_vec())).await.is_err() { + eprintln!("[Emerald] Joiner relay: TCP→WS send error"); break; + } + } + } + } + _ = cancel_ws.cancelled() => { eprintln!("[Emerald] Joiner relay: TCP→WS cancelled"); break; }, + } + } + }); + + let cancel_tcp = cancel.clone(); + let forward_ws = tokio::spawn(async move { + let ws_read = ws_read; + let mut tcp_write = tcp_write; + tokio::pin!(ws_read); + loop { + tokio::select! { + result = ws_read.next() => { + match result { + Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(data))) => { + eprintln!("[Emerald] Joiner relay: WS→TCP forwarding {} bytes", data.len()); + if tcp_write.write_all(&data).await.is_err() { break; } + } + Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => { break; } + None => { break; } + Some(Err(e)) => { eprintln!("[Emerald] Joiner relay: WS→TCP error: {e}"); break; } + _ => {} + } + } + _ = cancel_tcp.cancelled() => { break; }, + } + } + }); + + tokio::select! { + _ = forward_tcp => eprintln!("[Emerald] Joiner relay: forward_tcp done"), + _ = forward_ws => eprintln!("[Emerald] Joiner relay: forward_ws done"), + _ = cancel.cancelled() => eprintln!("[Emerald] Joiner relay: cancelled"), + } + eprintln!("[Emerald] Joiner relay: relay task ended"); + }); + + Ok(local_port) +} + +async fn run_host_relay( + _proxy_state: &ProxyGuard, + ws_url: &str, + auth_token: &str, + game_port: u16, + cancel: CancellationToken, +) -> Result<(), String> { + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + eprintln!("[Emerald] Host relay: connecting WS..."); + let mut request = ws_url + .into_client_request() + .map_err(|e| format!("Failed to build WS request: {}", e))?; + request.headers_mut().insert( + http::header::AUTHORIZATION, + format!("Bearer {}", auth_token) + .parse() + .map_err(|_| "Invalid auth header value".to_string())?, + ); + request.headers_mut().insert( + http::header::USER_AGENT, + "MCLCE-LceLive/1.0" + .parse() + .map_err(|_| "Invalid UA header value".to_string())?, + ); + + let (ws_stream, _) = tokio_tungstenite::connect_async(request) + .await + .map_err(|e| format!("Host relay WS connect failed: {}", e))?; + eprintln!("[Emerald] Host relay: WS connected"); + eprintln!("[Emerald] Host relay: connecting to game 127.0.0.1:{}...", game_port); + let game_stream = loop { + match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", game_port)).await { + Ok(stream) => { eprintln!("[Emerald] Host relay: connected to game"); break stream; }, + Err(e) => { + eprintln!("[Emerald] Host relay: game connect failed (retrying): {e}"); + tokio::select! { + _ = cancel.cancelled() => return Err("Host relay cancelled".into()), + _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {} + } + } + } + }; + + eprintln!("[Emerald] Host relay: starting forwarders"); + let (game_read, game_write) = game_stream.into_split(); + let (ws_write, ws_read) = ws_stream.split(); + let cancel_ws = cancel.clone(); + let forward_game = tokio::spawn(async move { + let mut ws_write = ws_write; + let mut game_read = game_read; + let mut buf = [0u8; 65536]; + loop { + tokio::select! { + result = game_read.read(&mut buf) => { + match result { + Ok(0) => { eprintln!("[Emerald] Host relay: game→WS EOF"); break; }, + Err(e) => { eprintln!("[Emerald] Host relay: game→WS read error: {e}"); break; }, + Ok(n) => { + eprintln!("[Emerald] Host relay: game→WS forwarding {} bytes", n); + if ws_write.send(tokio_tungstenite::tungstenite::Message::Binary(buf[..n].to_vec())).await.is_err() { break; } + } + } + } + _ = cancel_ws.cancelled() => { break; }, + } + } + }); + + let cancel_ws2 = cancel.clone(); + let forward_ws = tokio::spawn(async move { + let ws_read = ws_read; + let mut game_write = game_write; + tokio::pin!(ws_read); + loop { + tokio::select! { + result = ws_read.next() => { + match result { + Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(data))) => { + eprintln!("[Emerald] Host relay: WS→game forwarding {} bytes", data.len()); + if game_write.write_all(&data).await.is_err() { break; } + } + Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => { break; } + None => { break; } + Some(Err(e)) => { eprintln!("[Emerald] Host relay: WS→game error: {e}"); break; } + _ => {} + } + } + _ = cancel_ws2.cancelled() => { break; }, + } + } + }); + + tokio::select! { + _ = forward_game => eprintln!("[Emerald] Host relay: forward_game done"), + _ = forward_ws => eprintln!("[Emerald] Host relay: forward_ws done"), + _ = cancel.cancelled() => eprintln!("[Emerald] Host relay: cancelled"), + } + + Ok(()) +} + +#[tauri::command] +pub async fn start_relay_proxy( + proxy_state: State<'_, ProxyGuard>, + api_base_url: String, + access_token: String, + session_id: String, +) -> Result { + let ws_base = util::ws_base_url(&api_base_url); + let ws_url = format!("{}/api/relay/ws?sessionId={}&role=joiner", ws_base, session_id); + let cancel = CancellationToken::new(); + { + let mut tokens = proxy_state.cancel_tokens.lock().await; + tokens.insert(session_id.clone(), cancel.clone()); + } + + let local_port = run_relay_proxy(&proxy_state, &ws_url, &access_token, cancel).await?; + + { + let mut tokens = proxy_state.cancel_tokens.lock().await; + tokens.remove(&session_id); + } + + Ok(local_port) +} + +#[tauri::command] +pub async fn start_host_relay( + proxy_state: State<'_, ProxyGuard>, + api_base_url: String, + access_token: String, + session_id: String, + game_port: u16, +) -> Result<(), String> { + let ws_base = util::ws_base_url(&api_base_url); + let ws_url = format!("{}/api/relay/ws?sessionId={}&role=host", ws_base, session_id); + let cancel = CancellationToken::new(); + { + let mut tokens = proxy_state.cancel_tokens.lock().await; + tokens.insert(session_id.clone(), cancel.clone()); + } + + let result = run_host_relay(&proxy_state, &ws_url, &access_token, game_port, cancel).await; + + { + let mut tokens = proxy_state.cancel_tokens.lock().await; + tokens.remove(&session_id); + } + + result +} + +#[tauri::command] +pub async fn stop_proxy(proxy_state: State<'_, ProxyGuard>, session_id: String) -> Result<(), String> { + let mut tokens = proxy_state.cancel_tokens.lock().await; + if let Some(token) = tokens.remove(&session_id) { + token.cancel(); + } + let mut port = proxy_state.local_port.lock().await; + *port = None; + Ok(()) +} + +#[tauri::command] +pub async fn stop_all_proxies(proxy_state: State<'_, ProxyGuard>) -> Result<(), String> { + let mut tokens = proxy_state.cancel_tokens.lock().await; + for (_, token) in tokens.drain() { + token.cancel(); + } + let mut port = proxy_state.local_port.lock().await; + *port = None; + Ok(()) +} + +#[tauri::command] +pub async fn join_game( + app: tauri::AppHandle, + game_state: State<'_, crate::state::GameState>, + _proxy_state: State<'_, ProxyGuard>, + _api_base_url: String, + _access_token: String, + host_ip: String, + host_port: u16, + _session_id: String, + instance_id: String, +) -> Result<(), String> { + let server = crate::types::McServer { + name: host_ip.clone(), + ip: host_ip, + port: host_port, + }; + crate::commands::game::launch_game(app, game_state, instance_id, vec![server], vec![]).await +} diff --git a/src-tauri/src/networking/stun.rs b/src-tauri/src/networking/stun.rs new file mode 100644 index 0000000..c78d59a --- /dev/null +++ b/src-tauri/src/networking/stun.rs @@ -0,0 +1,59 @@ +use crate::types::P2pEndpoint; +async fn stun_discover_impl() -> Result { + let socket = tokio::net::UdpSocket::bind("0.0.0.0:0").await.map_err(|e| e.to_string())?; + let stun_addr = tokio::net::lookup_host("stun.l.google.com:19302").await + .map_err(|e| format!("STUN DNS lookup failed: {}", e))? + .next() + .ok_or_else(|| "STUN DNS returned no addresses".to_string())?; + + let magic_cookie: u32 = 0x2112A442; + let mut trans_id = [0u8; 12]; + rand::Rng::fill(&mut rand::thread_rng(), &mut trans_id); + let mut req = Vec::with_capacity(20); + req.extend_from_slice(&0x0001u16.to_be_bytes()); + req.extend_from_slice(&0x0000u16.to_be_bytes()); + req.extend_from_slice(&magic_cookie.to_be_bytes()); + req.extend_from_slice(&trans_id); + socket.send_to(&req, stun_addr).await.map_err(|e| format!("STUN send: {}", e))?; + let mut buf = [0u8; 512]; + tokio::time::timeout( + std::time::Duration::from_secs(5), + socket.recv_from(&mut buf) + ).await.map_err(|_| "STUN request timed out (5s)".to_string())? + .map_err(|e| format!("STUN recv: {}", e))?; + let msg_type = u16::from_be_bytes([buf[0], buf[1]]); + let rcvd_cookie = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]); + if msg_type != 0x0101 || rcvd_cookie != magic_cookie { + return Err(format!("Invalid STUN response (type=0x{:04X}, cookie=0x{:08X})", msg_type, rcvd_cookie)); + } + + let mut pos = 20; + while pos + 4 <= buf.len() { + let attr_type = u16::from_be_bytes([buf[pos], buf[pos+1]]); + let attr_len = u16::from_be_bytes([buf[pos+2], buf[pos+3]]) as usize; + pos += 4; + if attr_type == 0x0020 && attr_len >= 8 && pos + 8 <= buf.len() { + let _family = buf[pos+1]; + let xport = u16::from_be_bytes([buf[pos+2], buf[pos+3]]); + let port = xport ^ (magic_cookie >> 16) as u16; + let ip_bytes = [ + buf[pos+4] ^ (magic_cookie >> 24) as u8, + buf[pos+5] ^ (magic_cookie >> 16) as u8, + buf[pos+6] ^ (magic_cookie >> 8) as u8, + buf[pos+7] ^ magic_cookie as u8, + ]; + return Ok(P2pEndpoint { + ip: format!("{}.{}.{}.{}", ip_bytes[0], ip_bytes[1], ip_bytes[2], ip_bytes[3]), + port, + }); + } + pos += attr_len; + } + + Err("No XOR-MAPPED-ADDRESS in STUN response".into()) +} + +#[tauri::command] +pub async fn stun_discover() -> Result { + stun_discover_impl().await +} diff --git a/src-tauri/src/platform/linux.rs b/src-tauri/src/platform/linux.rs new file mode 100644 index 0000000..e803d41 --- /dev/null +++ b/src-tauri/src/platform/linux.rs @@ -0,0 +1,16 @@ +use std::fs; +use crate::util; +pub fn kill_process_tree(app: &tauri::AppHandle, instance_id: &str) { + let root = util::get_app_dir(app); + let instance_dir = root.join("instances").join(instance_id); + let target = util::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::() 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); } + } + } +} diff --git a/src-tauri/src/platform/macos.rs b/src-tauri/src/platform/macos.rs new file mode 100644 index 0000000..8a3ff64 --- /dev/null +++ b/src-tauri/src/platform/macos.rs @@ -0,0 +1,55 @@ +use std::fs; +use std::path::PathBuf; +use tauri::{AppHandle, Emitter, Manager}; +use crate::types::MacosSetupProgressPayload; +pub 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") +} + +pub fn find_executable_recursive(root: &PathBuf, file_name: &str) -> Option { + let entries = fs::read_dir(root).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name == file_name { + return Some(path); + } + } + if path.is_dir() { + if let Some(found) = find_executable_recursive(&path, file_name) { + return Some(found); + } + } + } + None +} + +pub fn emit_macos_setup_progress(window: &tauri::Window, stage: &str, message: String, percent: Option) { + let _ = window.emit( + "macos-setup-progress", + MacosSetupProgressPayload { + stage: stage.to_string(), + message, + percent, + }, + ); +} + +pub fn is_macos_runtime_installed(app: &AppHandle) -> bool { + let runtime_dir = get_macos_runtime_dir(app); + let toolkit_dir = runtime_dir.join("toolkit"); + if !toolkit_dir.exists() { + return false; + } + let candidates = ["Game Porting Toolkit.app"]; + candidates.iter().any(|name| find_executable_recursive(&toolkit_dir, name).is_some()) +} diff --git a/src-tauri/src/platform/mod.rs b/src-tauri/src/platform/mod.rs new file mode 100644 index 0000000..6bbf20a --- /dev/null +++ b/src-tauri/src/platform/mod.rs @@ -0,0 +1,6 @@ +#[cfg(unix)] +pub mod linux; +#[cfg(target_os = "macos")] +pub mod macos; +#[cfg(target_os = "windows")] +pub mod windows; diff --git a/src-tauri/src/platform/windows.rs b/src-tauri/src/platform/windows.rs new file mode 100644 index 0000000..f86849c --- /dev/null +++ b/src-tauri/src/platform/windows.rs @@ -0,0 +1 @@ +//neo: windows sucks ass btw diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs new file mode 100644 index 0000000..2e70003 --- /dev/null +++ b/src-tauri/src/state.rs @@ -0,0 +1,16 @@ +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; +pub struct DownloadState { + pub token: Arc>>, +} + +pub struct GameState { + pub child: Arc>>, +} + +pub struct ProxyGuard { + pub cancel_tokens: Arc>>, + pub local_port: Arc>>, +} diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs new file mode 100644 index 0000000..17a1ecc --- /dev/null +++ b/src-tauri/src/types.rs @@ -0,0 +1,119 @@ +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, + pub path: Option, + pub category: Option>, + pub logo: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct AppConfig { + pub username: String, + pub linux_runner: Option, + pub skin_base64: Option, + pub skin_library: Option>, + pub theme_style_id: Option, + pub theme_palette_id: Option, + pub apple_silicon_performance_boost: Option, + pub custom_editions: Option>, + pub profile: Option, + pub animations_enabled: Option, + pub vfx_enabled: Option, + pub rpc_enabled: Option, + pub music_vol: Option, + pub sfx_vol: Option, + pub legacy_mode: Option, + pub mangohud_enabled: Option, + pub saved_servers: Option>, +} + +#[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, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ScreenshotInfo { + pub path: String, + pub instance_id: String, + pub name: String, + pub date: u64, +} + +#[derive(Serialize)] +pub struct HttpResponse { + pub status: u16, + pub body: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct P2pEndpoint { + pub ip: String, + pub port: u16, +} + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +#[cfg(target_os = "macos")] +pub struct MacosSetupProgressPayload { + pub stage: String, + pub message: String, + pub percent: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkshopInstallRequest { + pub instance_id: String, + pub zips: std::collections::HashMap, + pub package_id: String, + pub version: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct InstalledWorkshopPackage { + pub id: String, + pub version: String, + pub dirs: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct InstalledPackageEntry { + pub instance_id: String, + pub package_id: String, + pub version: String, +} diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs new file mode 100644 index 0000000..14daa25 --- /dev/null +++ b/src-tauri/src/util.rs @@ -0,0 +1,63 @@ +use std::fs; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; +use crate::config; +pub fn get_app_dir(app: &AppHandle) -> PathBuf { + app.path().app_local_data_dir().unwrap_or_else(|_| { + std::env::current_dir().unwrap_or_default() + }) +} + +pub fn get_config_path(app: &AppHandle) -> PathBuf { + get_app_dir(app).join("emerald_legacy_config.json") +} + +pub fn get_instance_working_dir(app: &AppHandle, instance_id: &str) -> PathBuf { + let root = get_app_dir(app); + let config = config::load_config_raw(app.clone()); + if let Some(ref editions) = config.custom_editions { + if let Some(edition) = editions.iter().find(|e| e.id == instance_id) { + if let Some(ref path) = edition.path { + return PathBuf::from(path); + } + } + } + root.join("instances").join(instance_id) +} + +pub fn copy_dir_all(src: impl AsRef, dst: impl AsRef) -> 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(()) +} + +pub fn ws_base_url(api_base_url: &str) -> String { + if api_base_url.starts_with("https") { + api_base_url.replace("https", "wss") + } else { + api_base_url.replace("http", "ws") + } +} + +#[cfg(unix)] +pub 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 +}