use std::{ collections::HashSet, path::{Path, PathBuf}, sync::Arc, }; use eyre::bail; use lanspread_db::db::{Game, GameDB}; use lanspread_mdns::{LANSPREAD_SERVICE_TYPE, discover_service}; use lanspread_peer::{PeerCommand, PeerEvent, start_peer}; use tauri::{AppHandle, Emitter as _, Manager}; use tauri_plugin_shell::{ShellExt, process::Command}; use tokio::sync::{RwLock, mpsc::UnboundedSender}; // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ struct LanSpreadState { peer_ctrl: Arc>>>, games: Arc>, games_in_download: Arc>>, games_folder: Arc>, } #[tauri::command] fn request_games(state: tauri::State) { log::debug!("request_games"); let peer_ctrl = tauri::async_runtime::block_on(async { state.inner().peer_ctrl.read().await.clone() }); if let Some(peer_ctrl) = peer_ctrl { if let Err(e) = peer_ctrl.send(PeerCommand::ListGames) { log::error!("Failed to send message to peer: {e:?}"); } } else { log::warn!("Peer system not initialized yet"); } } #[tauri::command] fn install_game(id: String, state: tauri::State) -> bool { let already_in_download = tauri::async_runtime::block_on(async { if state.inner().games_in_download.read().await.contains(&id) { log::warn!("Game is already downloading: {id}"); return true; } false }); if already_in_download { return false; } let peer_ctrl = tauri::async_runtime::block_on(async { state.inner().peer_ctrl.read().await.clone() }); if let Some(peer_ctrl) = peer_ctrl { if let Err(e) = peer_ctrl.send(PeerCommand::GetGame(id)) { log::error!("Failed to send message to peer: {e:?}"); } true } else { log::warn!("Peer system not initialized yet"); false } } /// Backup the current game folder by renaming it to `___TO_BE_DELETE___GameNameHere` /// # Errors /// Returns error if backup fails fn backup_game_folder(game_path: &Path) -> eyre::Result { if !game_path.exists() { bail!("Game folder does not exist: {}", game_path.display()); } let game_name = game_path .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| eyre::eyre!("Invalid game folder name"))?; let parent = game_path .parent() .ok_or_else(|| eyre::eyre!("Cannot get parent directory"))?; let backup_name = format!("___TO_BE_DELETE___{game_name}"); let backup_path = parent.join(backup_name); // Remove existing backup if it exists if backup_path.exists() { std::fs::remove_dir_all(&backup_path)?; } // Rename current game folder to backup std::fs::rename(game_path, &backup_path)?; log::info!( "Backed up game folder: {} -> {}", game_path.display(), backup_path.display() ); Ok(backup_path) } /// Restore game folder from backup /// # Errors /// Returns error if restore fails fn restore_game_folder(game_path: &Path, backup_path: &Path) -> eyre::Result<()> { if !backup_path.exists() { bail!("Backup folder does not exist: {}", backup_path.display()); } // Remove the partially installed game folder if it exists if game_path.exists() { std::fs::remove_dir_all(game_path)?; } // Restore backup std::fs::rename(backup_path, game_path)?; log::info!( "Restored game folder from backup: {} -> {}", backup_path.display(), game_path.display() ); Ok(()) } /// Remove backup folder after successful update /// # Errors /// Returns error if cleanup fails fn cleanup_backup_folder(backup_path: &Path) -> eyre::Result<()> { if backup_path.exists() { std::fs::remove_dir_all(backup_path)?; log::info!("Cleaned up backup folder: {}", backup_path.display()); } Ok(()) } #[tauri::command] fn update_game(id: String, state: tauri::State) -> bool { let already_in_download = tauri::async_runtime::block_on(async { if state.inner().games_in_download.read().await.contains(&id) { log::warn!("Game is already downloading/updating: {id}"); return true; } false }); if already_in_download { return false; } // Get the games folder let games_folder = tauri::async_runtime::block_on(async { state.inner().games_folder.read().await.clone() }); let games_folder = PathBuf::from(games_folder); let game_path = games_folder.join(&id); // Backup current game folder let backup_path = match backup_game_folder(&game_path) { Ok(path) => path, Err(e) => { log::error!("Failed to backup game folder for {id}: {e}"); return false; } }; log::info!("Starting update for game: {id}"); // Start the download process let peer_ctrl = tauri::async_runtime::block_on(async { state.inner().peer_ctrl.read().await.clone() }); if let Some(peer_ctrl) = peer_ctrl { if let Err(e) = peer_ctrl.send(PeerCommand::GetGame(id.clone())) { log::error!("Failed to send message to peer: {e:?}"); // Try to restore backup if download fails to start if let Err(restore_err) = restore_game_folder(&game_path, &backup_path) { log::error!("Failed to restore backup after download failure: {restore_err}"); } return false; } true } else { log::warn!("Peer system not initialized yet"); false } } #[cfg(target_os = "windows")] fn run_as_admin(file: &str, params: &str, dir: &str) -> bool { use std::{ffi::OsStr, os::windows::ffi::OsStrExt}; use windows::{Win32::UI::Shell::ShellExecuteW, core::PCWSTR}; let file_wide: Vec = OsStr::new(file).encode_wide().chain(Some(0)).collect(); let params_wide: Vec = OsStr::new(params).encode_wide().chain(Some(0)).collect(); let dir_wide: Vec = OsStr::new(dir).encode_wide().chain(Some(0)).collect(); let runas_wide: Vec = OsStr::new("runas").encode_wide().chain(Some(0)).collect(); let result = unsafe { ShellExecuteW( None, PCWSTR::from_raw(runas_wide.as_ptr()), PCWSTR::from_raw(file_wide.as_ptr()), PCWSTR::from_raw(params_wide.as_ptr()), PCWSTR::from_raw(dir_wide.as_ptr()), windows::Win32::UI::WindowsAndMessaging::SW_HIDE, ) }; (result.0 as usize) > 32 // Success if greater than 32 } #[cfg(target_os = "windows")] fn run_game_windows(id: String, state: tauri::State) { use std::fs::File; const FIRST_START_DONE_FILE: &str = ".softlan_first_start_done"; let games_folder = tauri::async_runtime::block_on(async { state.inner().games_folder.read().await.clone() }); let games_folder = PathBuf::from(games_folder); if !games_folder.exists() { log::error!("games_folder {games_folder:?} does not exist"); return; } let game_path = games_folder.join(id.clone()); let game_setup_bin = game_path.join("game_setup.cmd"); let game_start_bin = game_path.join("game_start.cmd"); let first_start_done_file = game_path.join(FIRST_START_DONE_FILE); if !first_start_done_file.exists() && game_setup_bin.exists() { let result = run_as_admin( "cmd.exe", &format!( r#"/c "{} local {} de playername""#, game_setup_bin.display(), &id ), &game_path.display().to_string(), ); if !result { log::error!("failed to run game_setup.cmd"); return; } if let Err(e) = File::create(&first_start_done_file) { log::error!("failed to create {first_start_done_file:?}: {e}"); } } if game_start_bin.exists() { let result = run_as_admin( "cmd.exe", &format!( r#"/c "{} local {} de playername""#, game_start_bin.display(), &id ), &game_path.display().to_string(), ); if !result { log::error!("failed to run game_start.cmd"); } } } #[tauri::command] fn run_game(id: String, state: tauri::State) { #[cfg(target_os = "windows")] run_game_windows(id, state); #[cfg(not(target_os = "windows"))] { let _ = state; log::error!("run_game not implemented for this platform: id={id}"); } } fn set_game_install_state_from_path(game_db: &mut GameDB, path: &Path, installed: bool) { if let Some(file_name) = path.file_name() && let Some(file_name) = file_name.to_str() && let Some(game) = game_db.get_mut_game_by_id(file_name) { if installed { log::debug!("Set {game} to installed"); } else { log::debug!("Set {game} to uninstalled"); } game.installed = installed; // Read local version.ini if installed if installed { if let Ok(version) = lanspread_db::db::read_version_from_ini(path) { game.local_version = version; if let Some(ref version) = game.local_version { log::debug!("Read local version for game {}: {}", game.id, version); } } else { log::warn!("Failed to read local version.ini for game: {}", game.id); } } else { // Clear local version when uninstalled game.local_version = None; } } } #[tauri::command] fn update_game_directory(app_handle: tauri::AppHandle, path: String) { log::info!("update_game_directory: {path}"); let peer_ctrl = tauri::async_runtime::block_on(async { app_handle .state::() .inner() .peer_ctrl .read() .await .clone() }); if let Some(peer_ctrl) = peer_ctrl && let Err(e) = peer_ctrl.send(PeerCommand::SetGameDir(path.clone())) { log::error!("Failed to send PeerCommand::SetGameDir: {e}"); } { tauri::async_runtime::block_on(async { let mut games_folder = app_handle .state::() .inner() .games_folder .write() .await; games_folder.clone_from(&path); }); } let path = PathBuf::from(path); if !path.exists() { log::error!("game dir {} does not exist", path.display()); } let entries = match path.read_dir() { Ok(entries) => entries, Err(e) => { log::error!("Failed to read game dir: {e}"); return; } }; tauri::async_runtime::spawn(async move { let mut game_db = app_handle .state::() .inner() .games .write() .await; // Reset all games to uninstalled game_db.set_all_uninstalled(); // update game_db with installed games from real game directory entries.into_iter().for_each(|entry| { if let Ok(entry) = entry && let Ok(path_type) = entry.file_type() && path_type.is_dir() { let path = entry.path(); if path.join("version.ini").exists() { set_game_install_state_from_path(&mut game_db, &path, true); } } }); if let Err(e) = app_handle.emit("games-list-updated", Some(game_db.all_games())) { log::error!("Failed to emit games-list-updated event: {e}"); } }); } async fn find_peers(app: AppHandle) { log::info!("Looking for peers..."); loop { match discover_service(LANSPREAD_SERVICE_TYPE) { Ok(peer_addr) => { log::info!("Found peer at {peer_addr}"); let state: tauri::State = app.state(); if state.peer_ctrl.read().await.is_some() { // Peer discovery is handled by the peer system itself // No need to establish explicit connections request_games(state); } else { log::warn!( "Peer system not initialized yet, cannot request games from discovered peer" ); } break; } Err(e) => { log::warn!("Failed to find peers: {e} - retrying..."); tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; } } } } async fn update_game_db(games: Vec, app: AppHandle) { for game in &games { log::trace!("peer event ListGames iter: {game:?}"); } let state = app.state::(); // Store games list *state.games.write().await = GameDB::from(games.clone()); // Tell Frontend about new games list if let Err(e) = app.emit("games-list-updated", Some(games)) { log::error!("Failed to emit games-list-updated event: {e}"); } else { log::info!("Emitted games-list-updated event"); } } fn add_final_slash(path: &str) -> String { #[cfg(target_os = "windows")] const SLASH_CHAR: char = '\\'; #[cfg(not(target_os = "windows"))] const SLASH_CHAR: char = '/'; if path.ends_with(SLASH_CHAR) { path.to_string() } else { format!("{path}{SLASH_CHAR}") } } async fn do_unrar(sidecar: Command, rar_file: &Path, dest_dir: &Path) -> eyre::Result<()> { if let Ok(()) = std::fs::create_dir_all(dest_dir) { if let Ok(rar_file) = rar_file.canonicalize() { if let Ok(dest_dir) = dest_dir.canonicalize() { let dest_dir = dest_dir .to_str() .ok_or_else(|| eyre::eyre!("failed to get str of dest_dir"))?; log::info!( "unrar game: {} to {}", rar_file.canonicalize()?.display(), dest_dir ); let out = sidecar .arg("x") // extract files .arg(rar_file.canonicalize()?) .arg("-y") // Assume Yes on all queries .arg("-o") // Set overwrite mode .arg(add_final_slash(dest_dir)) .output() .await?; if !out.status.success() { log::error!("unrar stderr: {}", String::from_utf8_lossy(&out.stderr)); } return Ok(()); } log::error!("dest_dir canonicalize failed: {}", dest_dir.display()); } else { log::error!("rar_file canonicalize failed: {}", rar_file.display()); } } bail!("failed to create directory: {dest_dir:?}"); } async fn unpack_game(id: &str, sidecar: Command, games_folder: &str) { let game_path = PathBuf::from(games_folder).join(id); let eti_rar = game_path.join(format!("{id}.eti")); let local_path = game_path.join("local"); if let Err(e) = do_unrar(sidecar, &eti_rar, &local_path).await { log::error!("{} -> {}: {e}", eti_rar.display(), local_path.display()); } } #[allow(clippy::too_many_lines)] #[allow(clippy::missing_panics_doc)] #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let tauri_logger_builder = tauri_plugin_log::Builder::new() .clear_targets() .target(tauri_plugin_log::Target::new( tauri_plugin_log::TargetKind::Stdout, )) .level(log::LevelFilter::Info) .level_for("mdns_sd::service_daemon", log::LevelFilter::Off); // channel to receive events from the peer let (tx_peer_event, mut rx_peer_event) = tokio::sync::mpsc::unbounded_channel::(); let lanspread_state = LanSpreadState { peer_ctrl: Arc::new(RwLock::new(None)), games: Arc::new(RwLock::new(GameDB::empty())), games_in_download: Arc::new(RwLock::new(HashSet::new())), games_folder: Arc::new(RwLock::new(String::new())), }; tauri::Builder::default() .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_logger_builder.build()) .plugin(tauri_plugin_shell::init()) .invoke_handler(tauri::generate_handler![ request_games, install_game, run_game, update_game_directory, update_game ]) .manage(lanspread_state) .setup({ let tx_peer_event_clone = tx_peer_event.clone(); move |app| { let app_handle = app.handle().clone(); // discover peers tauri::async_runtime::spawn(async move { find_peers(app_handle).await }); // Initialize peer system when games directory is set let app_handle_clone = app.handle().clone(); tauri::async_runtime::spawn(async move { // Wait for games directory to be set loop { let games_folder = { let state = app_handle_clone.state::(); state.games_folder.read().await.clone() }; if !games_folder.is_empty() { match start_peer(games_folder, tx_peer_event_clone) { Ok(peer_ctrl) => { let state = app_handle_clone.state::(); *state.peer_ctrl.write().await = Some(peer_ctrl); log::info!("Peer system initialized successfully"); } Err(e) => { log::error!("Failed to initialize peer system: {e}"); } } break; } tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } }); let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { while let Some(event) = rx_peer_event.recv().await { match event { PeerEvent::ListGames(games) => { log::info!("PeerEvent::ListGames received"); update_game_db(games, app_handle.clone()).await; } PeerEvent::GotGameFiles { id, file_descriptions } => { log::info!("PeerEvent::GotGameFiles received"); if let Err(e) = app_handle.emit( "game-download-pre", Some(id.clone()), ) { log::error!("PeerEvent::GotGameFiles: Failed to emit game-download-pre event: {e}"); } let state = app_handle.state::(); let peer_ctrl = state.peer_ctrl.read().await.clone(); if let Some(peer_ctrl) = peer_ctrl && let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles{ id, file_descriptions, }) { log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}"); } } PeerEvent::DownloadGameFilesBegin { id } => { log::info!("PeerEvent::DownloadGameFilesBegin received"); app_handle .state::() .inner() .games_in_download .write() .await .insert(id.clone()); if let Err(e) = app_handle.emit("game-download-begin", Some(id)) { log::error!("PeerEvent::DownloadGameFilesBegin: Failed to emit game-download-begin event: {e}"); } } PeerEvent::DownloadGameFilesFinished { id } => { log::info!("PeerEvent::DownloadGameFilesFinished received"); if let Err(e) = app_handle.emit("game-download-finished", Some(id.clone())) { log::error!("PeerEvent::DownloadGameFilesFinished: Failed to emit game-download-finished event: {e}"); } app_handle .state::() .inner() .games_in_download .write() .await .remove(&id.clone()); let games_folder = app_handle .state::() .inner() .games_folder .read() .await .clone(); if let Ok(sidecar) = app_handle.shell().sidecar("unrar") { let app_handle = app_handle.clone(); // Spawn a separate task to handle unpacking and backup cleanup tauri::async_runtime::spawn(async move { unpack_game(&id, sidecar, &games_folder).await; if !games_folder.is_empty() { let backup_name = format!("___TO_BE_DELETE___{id}"); let backup_path = PathBuf::from(&games_folder).join(backup_name); if let Err(e) = cleanup_backup_folder(&backup_path) { log::error!("Failed to cleanup backup folder after successful update: {e}"); } } log::info!("PeerEvent::UnpackGameFinished received"); if let Err(e) = app_handle.emit("game-unpack-finished", Some(id.clone())) { log::error!("PeerEvent::UnpackGameFinished: Failed to emit game-unpack-finished event: {e}"); } }); } } PeerEvent::DownloadGameFilesFailed { id } => { log::warn!("PeerEvent::DownloadGameFilesFailed received"); if let Err(e) = app_handle.emit("game-download-failed", Some(id.clone())) { log::error!("Failed to emit game-download-failed event: {e}"); } app_handle .state::() .inner() .games_in_download .write() .await .remove(&id.clone()); // Check if a backup exists and restore from backup if failed // The backup folder name follows the pattern: ___TO_BE_DELETE___{game_name} let games_folder = app_handle .state::() .inner() .games_folder .read() .await .clone(); if !games_folder.is_empty() { let backup_name = format!("___TO_BE_DELETE___{id}"); let backup_path = PathBuf::from(&games_folder).join(backup_name); let game_path = PathBuf::from(&games_folder).join(&id); // Delete the half-downloaded game folder if it exists if game_path.exists() { if let Err(e) = std::fs::remove_dir_all(&game_path) { log::error!("Failed to delete half-downloaded game folder: {e}"); } else { log::info!("Deleted half-downloaded game folder: {}", game_path.display()); } } // Restore the backup if let Err(e) = restore_game_folder(&game_path, &backup_path) { log::error!("Failed to restore backup after download failure: {e}"); } } } PeerEvent::PeerConnected(addr) => { log::info!("Peer connected: {addr}"); if let Err(e) = app_handle.emit("peer-connected", Some(addr.to_string())) { log::error!("Failed to emit peer-connected event: {e}"); } } PeerEvent::PeerDisconnected(addr) => { log::info!("Peer disconnected: {addr}"); if let Err(e) = app_handle.emit("peer-disconnected", Some(addr.to_string())) { log::error!("Failed to emit peer-disconnected event: {e}"); } } PeerEvent::PeerDiscovered(addr) => { log::info!("Peer discovered: {addr}"); if let Err(e) = app_handle.emit("peer-discovered", Some(addr.to_string())) { log::error!("Failed to emit peer-discovered event: {e}"); } } PeerEvent::PeerLost(addr) => { log::info!("Peer lost: {addr}"); if let Err(e) = app_handle.emit("peer-lost", Some(addr.to_string())) { log::error!("Failed to emit peer-lost event: {e}"); } } } } }); Ok(()) } }) .run(tauri::generate_context!()) .expect("error while running tauri application"); }