mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-07-18 08:17:23 +00:00
2518 lines
93 KiB
Rust
2518 lines
93 KiB
Rust
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<String>,
|
|
pub category: Option<Vec<String>>,
|
|
pub logo: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AppConfig {
|
|
pub username: String,
|
|
pub linux_runner: Option<String>,
|
|
pub skin_base64: Option<String>,
|
|
pub skin_library: Option<Vec<SkinLibraryItem>>,
|
|
pub theme_style_id: Option<String>,
|
|
pub theme_palette_id: Option<String>,
|
|
pub apple_silicon_performance_boost: Option<bool>,
|
|
pub custom_editions: Option<Vec<CustomEdition>>,
|
|
pub profile: Option<String>,
|
|
pub animations_enabled: Option<bool>,
|
|
pub vfx_enabled: Option<bool>,
|
|
pub rpc_enabled: Option<bool>,
|
|
pub music_vol: Option<u32>,
|
|
pub sfx_vol: Option<u32>,
|
|
pub legacy_mode: Option<bool>,
|
|
pub mangohud_enabled: Option<bool>,
|
|
pub saved_servers: Option<Vec<McServer>>,
|
|
}
|
|
|
|
#[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<Mutex<Option<CancellationToken>>> }
|
|
pub struct GameState { pub child: Arc<Mutex<Option<tokio::process::Child>>> }
|
|
|
|
pub struct ProxyGuard {
|
|
pub cancel_tokens: Arc<Mutex<HashMap<String, CancellationToken>>>,
|
|
pub local_port: Arc<Mutex<Option<u16>>>,
|
|
}
|
|
|
|
#[derive(Serialize, Clone, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[cfg(target_os = "macos")]
|
|
struct MacosSetupProgressPayload {
|
|
stage: String,
|
|
message: String,
|
|
percent: Option<f64>,
|
|
}
|
|
|
|
fn get_app_dir(app: &AppHandle) -> PathBuf {
|
|
app.path().app_local_data_dir().unwrap_or_else(|_| {
|
|
std::env::current_dir().unwrap_or_default()
|
|
})
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn get_macos_runtime_dir(app: &AppHandle) -> PathBuf {
|
|
let home = app
|
|
.path()
|
|
.home_dir()
|
|
.ok()
|
|
.or_else(|| std::env::var("HOME").ok().map(PathBuf::from))
|
|
.unwrap_or_else(|| PathBuf::from("/"));
|
|
home.join("Library")
|
|
.join("Application Support")
|
|
.join("com.emerald.legacy")
|
|
.join("runtime")
|
|
}
|
|
|
|
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<f64>) {
|
|
let _ = window.emit(
|
|
"macos-setup-progress",
|
|
MacosSetupProgressPayload {
|
|
stage: stage.to_string(),
|
|
message,
|
|
percent,
|
|
},
|
|
);
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn find_executable_recursive(root: &PathBuf, file_name: &str) -> Option<PathBuf> {
|
|
let entries = fs::read_dir(root).ok()?;
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if 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<ThemePalette> {
|
|
let themes_dir = get_app_dir(&app).join("themes");
|
|
let _ = fs::create_dir_all(&themes_dir);
|
|
let mut palettes = Vec::new();
|
|
|
|
if let Ok(entries) = fs::read_dir(themes_dir) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.extension().and_then(|s| s.to_str()) == Some("json") {
|
|
if let Ok(content) = fs::read_to_string(&path) {
|
|
if let Ok(palette) = serde_json::from_str::<ThemePalette>(&content) {
|
|
palettes.push(palette);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
palettes
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn import_theme(app: AppHandle) -> Result<String, String> {
|
|
let file = rfd::FileDialog::new()
|
|
.add_filter("JSON Theme", &["json"])
|
|
.set_title("Import Theme Palette")
|
|
.pick_file();
|
|
|
|
if let Some(src_path) = file {
|
|
let content = fs::read_to_string(&src_path).map_err(|e| e.to_string())?;
|
|
let palette: ThemePalette = serde_json::from_str(&content).map_err(|_| "Invalid theme JSON format".to_string())?;
|
|
|
|
let themes_dir = get_app_dir(&app).join("themes");
|
|
let _ = fs::create_dir_all(&themes_dir);
|
|
|
|
let dest_path = themes_dir.join(format!("{}.json", palette.id));
|
|
fs::write(dest_path, content).map_err(|e| e.to_string())?;
|
|
|
|
Ok(palette.name)
|
|
} else {
|
|
Err("CANCELED".into())
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn pick_folder() -> Result<String, String> {
|
|
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<String>) -> Result<String, String> {
|
|
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<String>) -> Result<String, String> {
|
|
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<u8>) -> Result<(), String> {
|
|
fs::write(path, data).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn read_binary_file(path: String) -> Result<Vec<u8>, String> {
|
|
fs::read(path).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn read_screenshot_as_data_url(path: String) -> Result<String, String> {
|
|
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<Runner> {
|
|
let mut runners = Vec::new();
|
|
let mut seen_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
if let Ok(output) = Command::new("which").arg("wine").output() {
|
|
if output.status.success() {
|
|
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if !seen_paths.contains(&path) {
|
|
seen_paths.insert(path.clone());
|
|
runners.push(Runner {
|
|
id: "wine".to_string(),
|
|
name: "System Wine".to_string(),
|
|
path,
|
|
r#type: "wine".to_string(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Ok(output) = Command::new("ls").arg("/usr/share/emerald-legacy-launcher/wine/bin/wine").output() {
|
|
if output.status.success() {
|
|
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if !seen_paths.contains(&path) {
|
|
seen_paths.insert(path.clone());
|
|
runners.push(Runner {
|
|
id: "flatpaksucks".to_string(),
|
|
name: "Default for Flatpak".to_string(),
|
|
path,
|
|
r#type: "wine".to_string(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let home = std::env::var("HOME").unwrap_or_default();
|
|
let steam_paths = [
|
|
format!("{}/.local/share/Steam/compatibilitytools.d", home),
|
|
format!("{}/.local/share/Steam/steamapps/common", home),
|
|
"/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<String, String> {
|
|
let runners_dir = get_app_dir(&app).join("runners");
|
|
fs::create_dir_all(&runners_dir).map_err(|e| e.to_string())?;
|
|
|
|
let runner_dir = runners_dir.join(&name);
|
|
if runner_dir.exists() {
|
|
let _ = fs::remove_dir_all(&runner_dir);
|
|
}
|
|
fs::create_dir_all(&runner_dir).map_err(|e| e.to_string())?;
|
|
|
|
let token = CancellationToken::new();
|
|
let child_token = token.clone();
|
|
{
|
|
let mut lock = state.token.lock().await;
|
|
if let Some(old_token) = lock.take() {
|
|
old_token.cancel();
|
|
}
|
|
*lock = Some(token);
|
|
}
|
|
|
|
let tarball_path = runners_dir.join(format!("{}.tar.gz", name));
|
|
let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(format!("Download failed: {}", response.status()));
|
|
}
|
|
|
|
let total_size = response.content_length().unwrap_or(0) as f64;
|
|
let mut file = fs::File::create(&tarball_path).map_err(|e| e.to_string())?;
|
|
let mut downloaded = 0.0;
|
|
let mut stream = response.bytes_stream();
|
|
|
|
while let Some(chunk) = stream.next().await {
|
|
if child_token.is_cancelled() {
|
|
drop(file);
|
|
let _ = fs::remove_file(&tarball_path);
|
|
let _ = fs::remove_dir_all(&runner_dir);
|
|
return Err("CANCELLED".into());
|
|
}
|
|
let chunk = chunk.map_err(|e| e.to_string())?;
|
|
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
|
downloaded += chunk.len() as f64;
|
|
if total_size > 0.0 {
|
|
let _ = app.emit("runner-download-progress", downloaded / total_size * 100.0);
|
|
}
|
|
}
|
|
|
|
drop(file);
|
|
{ *state.token.lock().await = None; }
|
|
|
|
let status = Command::new("tar")
|
|
.args(["-zxf", tarball_path.to_str().unwrap(), "-C", runner_dir.to_str().unwrap(), "--strip-components=1"])
|
|
.status()
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let _ = fs::remove_file(&tarball_path);
|
|
|
|
if !status.success() {
|
|
let _ = fs::remove_dir_all(&runner_dir);
|
|
return Err("Extraction failed".into());
|
|
}
|
|
|
|
Ok(name)
|
|
}
|
|
|
|
#[tauri::command]
|
|
#[allow(non_snake_case)]
|
|
fn check_game_installed(app: AppHandle, instance_id: String) -> bool {
|
|
get_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<GithubAsset>,
|
|
}
|
|
|
|
emit_macos_setup_progress(&window, "resolving", "Resolving macOS compatibility runtime…".into(), None);
|
|
|
|
let client = reqwest::Client::new();
|
|
let release_text = client
|
|
.get("https://api.github.com/repos/Gcenx/game-porting-toolkit/releases/latest")
|
|
.header("User-Agent", "Emerald-Legacy-Launcher")
|
|
.send()
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.error_for_status()
|
|
.map_err(|e| e.to_string())?
|
|
.text()
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let release: GithubRelease = serde_json::from_str(&release_text).map_err(|e| e.to_string())?;
|
|
let asset = release
|
|
.assets
|
|
.iter()
|
|
.find(|a| a.name.ends_with(".tar.xz") || a.name.ends_with(".tar.gz"))
|
|
.ok_or_else(|| "No compatible runtime asset found in latest release.".to_string())?;
|
|
|
|
let runtime_dir = get_macos_runtime_dir(&app);
|
|
let toolkit_dir = runtime_dir.join("toolkit");
|
|
let prefix_dir = runtime_dir.join("prefix");
|
|
fs::create_dir_all(&runtime_dir).map_err(|e| e.to_string())?;
|
|
|
|
if toolkit_dir.exists() {
|
|
let _ = fs::remove_dir_all(&toolkit_dir);
|
|
}
|
|
fs::create_dir_all(&toolkit_dir).map_err(|e| e.to_string())?;
|
|
|
|
emit_macos_setup_progress(
|
|
&window,
|
|
"downloading",
|
|
format!("Downloading runtime ({})…", release.tag_name),
|
|
Some(0.0),
|
|
);
|
|
|
|
let archive_path = runtime_dir.join(format!("gptk_{}", asset.name));
|
|
let response = client
|
|
.get(&asset.browser_download_url)
|
|
.header("User-Agent", "Emerald-Legacy-Launcher")
|
|
.send()
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.error_for_status()
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let total_size = response.content_length().unwrap_or(0) as f64;
|
|
let mut file = fs::File::create(&archive_path).map_err(|e| e.to_string())?;
|
|
let mut downloaded = 0.0;
|
|
let mut last_percent_sent: i64 = -1;
|
|
let mut stream = response.bytes_stream();
|
|
|
|
while let Some(chunk) = stream.next().await {
|
|
let chunk = chunk.map_err(|e| e.to_string())?;
|
|
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
|
downloaded += chunk.len() as f64;
|
|
|
|
if total_size > 0.0 {
|
|
let percent = (downloaded / total_size * 100.0).clamp(0.0, 100.0);
|
|
let rounded = percent.floor() as i64;
|
|
if rounded != last_percent_sent {
|
|
last_percent_sent = rounded;
|
|
emit_macos_setup_progress(
|
|
&window,
|
|
"downloading",
|
|
format!("Downloading runtime… {}%", rounded),
|
|
Some(percent),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
drop(file);
|
|
|
|
emit_macos_setup_progress(&window, "extracting", "Extracting runtime…".into(), None);
|
|
|
|
let archive_metadata = fs::metadata(&archive_path).map_err(|e| format!("Cannot read archive: {}", e))?;
|
|
println!("Archive size: {} bytes", archive_metadata.len());
|
|
|
|
if archive_metadata.len() < 100_000_000 {
|
|
return Err(format!("Archive too small: {} bytes", archive_metadata.len()));
|
|
}
|
|
|
|
let status = Command::new("tar")
|
|
.args([
|
|
"-xf",
|
|
archive_path.to_str().ok_or_else(|| "Invalid archive path".to_string())?,
|
|
"-C",
|
|
toolkit_dir.to_str().ok_or_else(|| "Invalid toolkit path".to_string())?,
|
|
])
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::piped())
|
|
.status()
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
println!("Tar exit status: {:?}", status);
|
|
|
|
let _ = fs::remove_file(&archive_path);
|
|
if !status.success() {
|
|
return Err(format!("Extraction failed with status: {:?}", status));
|
|
}
|
|
|
|
fs::create_dir_all(&prefix_dir).map_err(|e| e.to_string())?;
|
|
|
|
let wine_binary = find_executable_recursive(&toolkit_dir, "wine64")
|
|
.or_else(|| find_executable_recursive(&toolkit_dir, "wine"))
|
|
.ok_or_else(|| "Unable to locate wine binary inside runtime.".to_string())?;
|
|
|
|
let wine_bin_dir = wine_binary
|
|
.parent()
|
|
.map(|pp| pp.to_path_buf())
|
|
.ok_or_else(|| "Unable to locate wine bin directory inside runtime.".to_string())?;
|
|
|
|
emit_macos_setup_progress(&window, "initializing", "Initializing Wine prefix…".into(), None);
|
|
|
|
let mut cmd = Command::new(&wine_binary);
|
|
cmd.arg("wineboot");
|
|
cmd.arg("-u");
|
|
cmd.env("WINEPREFIX", &prefix_dir);
|
|
cmd.env("WINEARCH", "win64");
|
|
cmd.env("WINEDEBUG", "-all");
|
|
cmd.env("WINEESYNC", "1");
|
|
cmd.env("WINEDLLOVERRIDES", "winemenubuilder.exe=d;mscoree,mshtml=");
|
|
cmd.env("MTL_HUD_ENABLED", "0");
|
|
cmd.env(
|
|
"PATH",
|
|
format!(
|
|
"{}:{}",
|
|
wine_bin_dir.to_string_lossy(),
|
|
std::env::var("PATH").unwrap_or_default()
|
|
),
|
|
);
|
|
cmd.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
|
|
let status = cmd.status().map_err(|e| e.to_string())?;
|
|
if !status.success() {
|
|
return Err("Wine prefix initialization failed".into());
|
|
}
|
|
|
|
emit_macos_setup_progress(&window, "done", "Setup complete.".into(), Some(100.0));
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn copy_dir_all(src: impl AsRef<std::path::Path>, dst: impl AsRef<std::path::Path>) -> std::io::Result<()> {
|
|
fs::create_dir_all(&dst)?;
|
|
for entry in fs::read_dir(src)? {
|
|
let entry = entry?;
|
|
let ty = entry.file_type()?;
|
|
if ty.is_dir() {
|
|
copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
|
|
} else {
|
|
fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
#[tauri::command]
|
|
#[allow(non_snake_case)]
|
|
async fn download_and_install(app: AppHandle, state: State<'_, DownloadState>, url: String, instance_id: String) -> Result<String, String> {
|
|
let root = get_app_dir(&app);
|
|
let instance_dir = root.join("instances").join(&instance_id);
|
|
let token = CancellationToken::new();
|
|
let child_token = token.clone();
|
|
{
|
|
let mut lock = state.token.lock().await;
|
|
if let Some(old_token) = lock.take() {
|
|
old_token.cancel();
|
|
}
|
|
*lock = Some(token);
|
|
}
|
|
|
|
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<String> = {
|
|
let wf_path = instance_dir.join("workshop_files.json");
|
|
fs::read_to_string(&wf_path)
|
|
.ok()
|
|
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.collect()
|
|
};
|
|
|
|
if let Ok(entries) = fs::read_dir(&instance_dir) {
|
|
for entry in entries.flatten() {
|
|
let file_name = entry.file_name();
|
|
let name_str = file_name.to_string_lossy();
|
|
let 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<McServer>) {
|
|
let servers_db = instance_dir.join("servers.db");
|
|
let mut all_servers = Vec::new();
|
|
if let Ok(content) = fs::read(&servers_db) {
|
|
if content.len() >= 12 && &content[0..4] == b"MCSV" {
|
|
let count = u32::from_le_bytes(content[8..12].try_into().unwrap_or([0; 4]));
|
|
let mut pos = 12;
|
|
for _ in 0..count {
|
|
if pos + 2 > content.len() { break; }
|
|
let ip_len = u16::from_le_bytes(content[pos..pos+2].try_into().unwrap_or([0; 2])) as usize;
|
|
pos += 2;
|
|
if pos + ip_len > content.len() { break; }
|
|
let ip = String::from_utf8_lossy(&content[pos..pos+ip_len]).to_string();
|
|
pos += ip_len;
|
|
if pos + 2 > content.len() { break; }
|
|
let port = u16::from_le_bytes(content[pos..pos+2].try_into().unwrap_or([0; 2]));
|
|
pos += 2;
|
|
if pos + 2 > content.len() { break; }
|
|
let name_len = u16::from_le_bytes(content[pos..pos+2].try_into().unwrap_or([0; 2])) as usize;
|
|
pos += 2;
|
|
if pos + name_len > content.len() { break; }
|
|
let name = String::from_utf8_lossy(&content[pos..pos+name_len]).to_string();
|
|
pos += name_len;
|
|
|
|
all_servers.push(McServer { name, ip, port });
|
|
}
|
|
}
|
|
}
|
|
|
|
for s in servers {
|
|
all_servers.push(s);
|
|
}
|
|
|
|
let mut unique_servers = Vec::new();
|
|
let mut seen: std::collections::HashSet<(String, u16)> = std::collections::HashSet::new();
|
|
for s in all_servers {
|
|
let key = (s.ip.clone(), s.port);
|
|
if seen.insert(key) {
|
|
unique_servers.push(s);
|
|
}
|
|
}
|
|
|
|
let mut file_content = Vec::new();
|
|
file_content.extend_from_slice(b"MCSV");
|
|
file_content.extend_from_slice(&1u32.to_le_bytes());
|
|
file_content.extend_from_slice(&(unique_servers.len() as u32).to_le_bytes());
|
|
for server in unique_servers {
|
|
let ip_bytes = server.ip.as_bytes();
|
|
let name_bytes = server.name.as_bytes();
|
|
file_content.extend_from_slice(&(ip_bytes.len() as u16).to_le_bytes());
|
|
file_content.extend_from_slice(ip_bytes);
|
|
file_content.extend_from_slice(&server.port.to_le_bytes());
|
|
file_content.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
|
|
file_content.extend_from_slice(name_bytes);
|
|
}
|
|
let _ = fs::create_dir_all(instance_dir);
|
|
let _ = fs::write(&servers_db, file_content);
|
|
}
|
|
|
|
fn perform_dlc_sync(app: &AppHandle, instance_dir: &PathBuf) -> Result<(), String> {
|
|
let mut dlc_src = None;
|
|
let root = get_app_dir(app);
|
|
|
|
use tauri::path::BaseDirectory;
|
|
if let Ok(p) = app.path().resolve("resources/DLC", BaseDirectory::Resource) {
|
|
if p.exists() {
|
|
dlc_src = Some(p);
|
|
} else {
|
|
if let Ok(p2) = app.path().resolve("DLC", BaseDirectory::Resource) {
|
|
if p2.exists() { dlc_src = Some(p2); }
|
|
}
|
|
}
|
|
}
|
|
|
|
if dlc_src.is_none() {
|
|
let current = std::env::current_dir().unwrap_or_default();
|
|
let p3 = current.join("src-tauri").join("resources").join("DLC");
|
|
let p4 = current.join("resources").join("DLC");
|
|
if p3.exists() { dlc_src = Some(p3); }
|
|
else if p4.exists() { dlc_src = Some(p4); }
|
|
}
|
|
|
|
if dlc_src.is_none() {
|
|
let p5 = root.join("DLC");
|
|
if p5.exists() { dlc_src = Some(p5); }
|
|
}
|
|
|
|
match dlc_src {
|
|
Some(src) => {
|
|
let dlc_dest = instance_dir.join("Windows64Media").join("DLC");
|
|
let _ = fs::create_dir_all(&dlc_dest);
|
|
|
|
if let Ok(entries) = fs::read_dir(&src) {
|
|
for entry in entries.flatten() {
|
|
let name = entry.file_name();
|
|
let dest_path = dlc_dest.join(&name);
|
|
|
|
if !dest_path.exists() {
|
|
if let Err(e) = if entry.path().is_dir() {
|
|
copy_dir_all(entry.path(), &dest_path)
|
|
} else {
|
|
fs::copy(entry.path(), &dest_path).map(|_| ())
|
|
} {
|
|
eprintln!("[DLC Sync] Failed to copy {:?} to {:?}: {}", entry.path(), dest_path, e);
|
|
} else {
|
|
println!("[DLC Sync] Copied to {:?}", dest_path);
|
|
}
|
|
} else {
|
|
println!("[DLC Sync] Skipping {:?}: Already exists in instance", name);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
},
|
|
None => {
|
|
println!("[DLC Sync] Skipping sync: No DLC source found.");
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
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<String, String>,
|
|
package_id: String,
|
|
version: String,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct InstalledWorkshopPackage {
|
|
id: String,
|
|
version: String,
|
|
dirs: Vec<String>,
|
|
}
|
|
|
|
#[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<String> = fs::read_to_string(&wf_path)
|
|
.ok()
|
|
.and_then(|s| serde_json::from_str(&s).ok())
|
|
.unwrap_or_default();
|
|
|
|
let mut workshop_packages: Vec<InstalledWorkshopPackage> = 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<String> = 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<InstalledWorkshopPackage> = 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<String> = 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<String> = 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<InstalledPackageEntry> {
|
|
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<InstalledWorkshopPackage> = 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<InstalledWorkshopPackage> = 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<bool, String> {
|
|
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<McServer>, extra_args: Vec<String>) -> 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<String> = 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::<u32>() else { continue };
|
|
let cmdline = fs::read_to_string(format!("/proc/{}/cmdline", pid))
|
|
.unwrap_or_default();
|
|
if cmdline.contains(&*target) {
|
|
unsafe { libc::kill(pid as i32, libc::SIGKILL); }
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn stop_game(#[allow(unused_variables)] app: AppHandle, #[allow(unused_variables)] instance_id: String, state: State<'_, GameState>) -> Result<(), String> {
|
|
let mut lock = state.child.lock().await;
|
|
if let Some(mut child) = lock.take() {
|
|
#[cfg(unix)] kill_process_tree(&app, &instance_id);
|
|
let _ = child.kill().await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn fetch_skin(username: String) -> Result<(String, String), String> {
|
|
let client = reqwest::Client::new();
|
|
let mojang_url = format!("https://api.mojang.com/users/profiles/minecraft/{}", username);
|
|
let mojang_res = client.get(&mojang_url).send().await.map_err(|e| format!("Failed request to mojang: {}", e))?;
|
|
if !mojang_res.status().is_success() {
|
|
return Err("Player not found".to_string());
|
|
}
|
|
let mojang_text = mojang_res.text().await.map_err(|e| format!("Failed to read mojang text: {}", e))?;
|
|
let mojang_data: serde_json::Value = serde_json::from_str(&mojang_text).map_err(|e| format!("Invalid Mojang JSON: {}", e))?;
|
|
let id = mojang_data.get("id").and_then(|v| v.as_str()).ok_or_else(|| "Invalid Moajng response format".to_string())?;
|
|
let name_exact = mojang_data.get("name").and_then(|v| v.as_str()).unwrap_or(&username).to_string();
|
|
|
|
let mc_api_url = format!("https://api.minecraftapi.net/v3/profile/{}", id);
|
|
let mc_api_res = client.get(&mc_api_url).send().await.map_err(|e| format!("Failed request to mc api: {}", e))?;
|
|
if !mc_api_res.status().is_success() {
|
|
return Err("Error fetching skin data".to_string());
|
|
}
|
|
let mc_api_text = mc_api_res.text().await.map_err(|e| format!("Failed to read mc api text: {}", e))?;
|
|
let mc_api_data: serde_json::Value = serde_json::from_str(&mc_api_text).map_err(|e| format!("Invalid MC API JSON: {}", e))?;
|
|
let image_b64 = mc_api_data.get("skin")
|
|
.and_then(|s| s.get("image"))
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| "No skin found".to_string())?;
|
|
|
|
Ok((image_b64.to_string(), name_exact))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn download_logo(app: AppHandle, id: String, url: String) -> Result<String, String> {
|
|
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<ScreenshotInfo> {
|
|
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<PathBuf> = 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<PathBuf> = 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<steam_shortcuts_util::shortcut::ShortcutOwned> = 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<Shortcut> = 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<u8>) -> 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<String>, headers: std::collections::HashMap<String, String>) -> Result<HttpResponse, String> {
|
|
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<P2pEndpoint, String> {
|
|
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<P2pEndpoint, String> {
|
|
stun_discover_impl().await
|
|
}
|
|
|
|
async fn run_relay_proxy(
|
|
proxy_state: &ProxyGuard,
|
|
ws_url: &str,
|
|
auth_token: &str,
|
|
cancel: CancellationToken,
|
|
) -> Result<u16, String> {
|
|
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<u16, String> {
|
|
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<u16, String> {
|
|
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<u16, String> {
|
|
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()
|
|
}
|
|
|
|
#[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)) })
|
|
.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])
|
|
.setup(|app| {
|
|
let args: Vec<String> = std::env::args().collect();
|
|
if args.len() > 1 && !args[1].starts_with('-') {
|
|
let instance_id = args[1].clone();
|
|
let app_handle = app.handle().clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
if let Some(window) = app_handle.get_webview_window("main") {
|
|
let _ = window.hide();
|
|
}
|
|
let state = app_handle.state::<GameState>();
|
|
match 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);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
Ok(())
|
|
})
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running tauri application");
|
|
}
|