use std::{ collections::{HashMap, HashSet}, fs::{self, OpenOptions}, io::{self, Read as _, Seek as _, SeekFrom, Write as _}, net::SocketAddr, path::{Component, Path, PathBuf}, sync::{Arc, Mutex, OnceLock}, time::{Duration, SystemTime, UNIX_EPOCH}, }; use eyre::bail; use lanspread_compat::eti::get_games; use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescription}; use lanspread_peer::{ ActiveOperation, ActiveOperationKind, PeerCommand, PeerEvent, PeerGameDB, PeerRuntimeHandle, PeerStartOptions, UnpackFuture, Unpacker, migrate_legacy_state, start_peer_with_options, }; use tauri::{AppHandle, Emitter as _, Manager}; use tauri_plugin_shell::{ShellExt, process::Command}; use tokio::sync::{ RwLock, mpsc::{UnboundedReceiver, UnboundedSender}, }; use tracing::{Event, Level, Metadata, Subscriber, field::Visit}; use tracing_subscriber::{ layer::{Context, Layer}, prelude::*, registry::LookupSpan, }; // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ type OutboundTransfers = Arc>>>; const OUTBOUND_TRANSFER_EMIT_DEBOUNCE: Duration = Duration::from_millis(100); #[derive(Default)] struct OutboundTransferEmitState { scheduled: bool, generation: u64, } impl OutboundTransferEmitState { fn record_change(&mut self) -> bool { self.generation = self.generation.saturating_add(1); if self.scheduled { return false; } self.scheduled = true; true } fn observed_generation(&self) -> u64 { self.generation } fn finish_emit(&mut self, observed_generation: u64) -> bool { if self.generation != observed_generation { return true; } self.scheduled = false; false } } /// Tauri-managed runtime state shared by commands and setup tasks. #[derive(Default)] struct LanSpreadState { peer_ctrl: Arc>>>, peer_runtime: Arc>>, games: Arc>, active_operations: Arc>>, games_folder: Arc>, peer_game_db: Arc>, catalog: Arc>, unpack_logs: Arc>>, state_dir: OnceLock, main_log_sink: OnceLock, active_outbound_transfers: OutboundTransfers, outbound_transfer_emit: Arc>, } #[derive(Clone, Debug, PartialEq, Eq)] struct InstallSettings { account_name: String, language: String, } struct PeerEventTx(UnboundedSender); #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] enum UiOperationKind { Downloading, Installing, Updating, Uninstalling, RemovingDownload, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] struct UiActiveOperation { id: String, operation: UiOperationKind, } #[derive(Clone, Debug, serde::Serialize)] struct GamesListPayload { games: Vec, active_operations: Vec, } #[derive(Clone, Debug, serde::Serialize)] struct LauncherGame { #[serde(flatten)] game: Game, can_host_server: bool, active_outbound_transfers: usize, } #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] struct UnpackLogEntry { archive: String, destination: String, status_code: Option, stdout: String, stderr: String, started_at_ms: u64, finished_at_ms: u64, success: bool, } #[derive(Clone, Debug, serde::Serialize)] struct MainLogLinePayload { line: String, level: String, sequence: Option, } #[derive(Clone, Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] struct MainLogHistoryPayload { contents: String, last_sequence: u64, } struct SidecarUnpacker { app_handle: AppHandle, } const MAX_UNPACK_LOGS: usize = 20; const UNPACK_LOGS_FILE_NAME: &str = "unpack-logs.json"; const MAIN_LOG_FILE_NAME: &str = "lanspread.log"; const MAX_MAIN_LOG_BYTES: u64 = 2 * 1024 * 1024; const MAIN_LOG_TRIM_SLACK_BYTES: u64 = 64 * 1024; impl Unpacker for SidecarUnpacker { fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> { Box::pin(async move { let app_handle = self.app_handle.clone(); let sidecar = app_handle.shell().sidecar("unrar")?; do_unrar(&app_handle, sidecar, archive, dest).await }) } } #[tauri::command] async fn get_unpack_logs( state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result> { Ok(state.inner().unpack_logs.read().await.clone()) } #[tauri::command] async fn get_main_logs( app_handle: tauri::AppHandle, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { if let Some(sink) = state.inner().main_log_sink.get() { return Ok(sink.read_history()?); } let state_dir = app_handle.path().app_data_dir()?; fs::create_dir_all(&state_dir)?; let path = main_log_path(&state_dir); match read_main_log_file_to_limit(&path, MAX_MAIN_LOG_BYTES) { Ok(contents) => Ok(MainLogHistoryPayload { contents, last_sequence: 0, }), Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(MainLogHistoryPayload { contents: String::new(), last_sequence: 0, }), Err(err) => Err(err.into()), } } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] const GAME_SETUP_SCRIPT: &str = "game_setup.cmd"; #[cfg_attr(not(target_os = "windows"), allow(dead_code))] const GAME_START_SCRIPT: &str = "game_start.cmd"; const SERVER_START_SCRIPT: &str = "server_start.cmd"; #[cfg_attr(not(target_os = "windows"), allow(dead_code))] const DEFAULT_LANGUAGE: &str = "en"; #[cfg_attr(not(target_os = "windows"), allow(dead_code))] const DEFAULT_USERNAME: &str = "Commander"; #[cfg_attr(not(target_os = "windows"), allow(dead_code))] const MAX_USERNAME_CHARS: usize = 24; #[tauri::command] async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<()> { log::debug!("request_games"); let peer_ctrl_arc = state.inner().peer_ctrl.clone(); let peer_ctrl = peer_ctrl_arc.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"); } Ok(()) } #[tauri::command] async fn install_game( id: String, language: String, username: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { if state .inner() .active_operations .read() .await .contains_key(&id) { log::warn!("Game already has an active operation: {id}"); return Ok(false); } let peer_ctrl_arc = state.inner().peer_ctrl.clone(); let peer_ctrl = peer_ctrl_arc.read().await.clone(); let Some((downloaded, installed)) = state .inner() .games .read() .await .get_game_by_id(&id) .map(|game| (game.downloaded, game.installed)) else { log::warn!("Ignoring install request for unknown game: {id}"); return Ok(false); }; let _ = (language, username); let handled = if let Some(peer_ctrl) = peer_ctrl { let command = if !downloaded { PeerCommand::GetGame(id.clone()) } else if !installed { PeerCommand::InstallGame { id: id.clone() } } else { log::info!("Game is already installed: {id}"); return Ok(false); }; if let Err(e) = peer_ctrl.send(command) { log::error!("Failed to send message to peer: {e:?}"); return Ok(false); } true } else { log::warn!("Peer system not initialized yet"); false }; Ok(handled) } #[tauri::command] async fn update_game( id: String, language: String, username: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { if state .inner() .active_operations .read() .await .contains_key(&id) { log::warn!("Game already has an active operation: {id}"); return Ok(false); } log::info!("Starting update for game: {id}"); let peer_ctrl_arc = state.inner().peer_ctrl.clone(); let peer_ctrl = peer_ctrl_arc.read().await.clone(); let _ = (language, username); if let Some(peer_ctrl) = peer_ctrl { if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id: id.clone() }) { log::error!("Failed to send message to peer: {e:?}"); return Ok(false); } Ok(true) } else { log::warn!("Peer system not initialized yet"); Ok(false) } } #[tauri::command] async fn uninstall_game( id: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { if state .inner() .active_operations .read() .await .contains_key(&id) { log::warn!("Game already has an active operation: {id}"); return Ok(false); } let peer_ctrl_arc = state.inner().peer_ctrl.clone(); let peer_ctrl = peer_ctrl_arc.read().await.clone(); if let Some(peer_ctrl) = peer_ctrl { if let Err(e) = peer_ctrl.send(PeerCommand::UninstallGame { id }) { log::error!("Failed to send message to peer: {e:?}"); return Ok(false); } Ok(true) } else { log::warn!("Peer system not initialized yet"); Ok(false) } } #[tauri::command] async fn remove_downloaded_game( id: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { if state .inner() .active_operations .read() .await .contains_key(&id) { log::warn!("Game already has an active operation: {id}"); return Ok(false); } let Some((downloaded, installed)) = state .inner() .games .read() .await .get_game_by_id(&id) .map(|game| (game.downloaded, game.installed)) else { log::warn!("Ignoring downloaded-file removal for unknown game: {id}"); return Ok(false); }; if !downloaded || installed { log::warn!( "Ignoring downloaded-file removal for {id}: downloaded={downloaded}, installed={installed}" ); return Ok(false); } let peer_ctrl_arc = state.inner().peer_ctrl.clone(); let peer_ctrl = peer_ctrl_arc.read().await.clone(); if let Some(peer_ctrl) = peer_ctrl { if let Err(e) = peer_ctrl.send(PeerCommand::RemoveDownloadedGame { id }) { log::error!("Failed to send message to peer: {e:?}"); return Ok(false); } Ok(true) } else { log::warn!("Peer system not initialized yet"); Ok(false) } } #[tauri::command] async fn cancel_download( id: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { let is_active_download = { let active_operations = state.inner().active_operations.read().await; matches!( active_operations.get(&id), Some(UiOperationKind::Downloading) ) }; if !is_active_download { log::warn!("Ignoring cancel request for inactive download: {id}"); return Ok(false); } let peer_ctrl_arc = state.inner().peer_ctrl.clone(); let peer_ctrl = peer_ctrl_arc.read().await.clone(); if let Some(peer_ctrl) = peer_ctrl { if let Err(e) = peer_ctrl.send(PeerCommand::CancelDownload { id }) { log::error!("Failed to send message to peer: {e:?}"); return Ok(false); } Ok(true) } else { log::warn!("Peer system not initialized yet"); Ok(false) } } #[tauri::command] async fn open_game_files( id: String, app_handle: AppHandle, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { let Some(target) = resolve_game_root_for_open(&id, &state).await else { return Ok(false); }; #[allow(deprecated)] if let Err(e) = app_handle.shell().open(target.display().to_string(), None) { log::error!("Failed to open file viewer for {}: {e}", target.display()); return Ok(false); } Ok(true) } async fn resolve_game_root_for_open( id: &str, state: &tauri::State<'_, LanSpreadState>, ) -> Option { if !is_single_component_game_id(id) { log::warn!("Ignoring file viewer request for invalid game id: {id}"); return None; } if state .inner() .games .read() .await .get_game_by_id(id) .is_none() { log::warn!("Ignoring file viewer request for unknown game: {id}"); return None; } let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone()); let Ok(root) = games_folder.canonicalize() else { log::warn!( "Cannot open files because game directory is unavailable: {}", games_folder.display() ); return None; }; let target = root.join(id); let Ok(target) = target.canonicalize() else { log::warn!( "Cannot open files because game root is unavailable: {}", target.display() ); return None; }; if !target.is_dir() || !target.starts_with(&root) { log::warn!( "Refusing to open file viewer outside game directory: {}", target.display() ); return None; } Some(target) } fn is_single_component_game_id(id: &str) -> bool { let mut components = Path::new(id).components(); matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() } #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] struct LaunchSettings { language: String, username: String, } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] fn launch_settings(language: &str, username: &str) -> LaunchSettings { LaunchSettings { language: sanitize_language(language), username: sanitize_username(username), } } fn install_settings(language: &str, username: &str) -> InstallSettings { InstallSettings { account_name: sanitize_username(username), language: install_language(language), } } fn install_language(language: &str) -> String { match sanitize_language(language).as_str() { "de" => "german".to_string(), _ => "english".to_string(), } } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] fn sanitize_language(language: &str) -> String { match language.trim().to_ascii_lowercase().as_str() { "de" => "de".to_string(), "en" => "en".to_string(), _ => DEFAULT_LANGUAGE.to_string(), } } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] fn sanitize_username(username: &str) -> String { let cleaned = username .trim() .chars() .filter(|c| !c.is_control() && *c != '"' && *c != '%') .take(MAX_USERNAME_CHARS) .collect::(); if cleaned.is_empty() { DEFAULT_USERNAME.to_string() } else { cleaned } } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] fn script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String { script_params_with_mode("/c", script_path, id, settings) } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] fn server_script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String { script_params_with_mode("/k", script_path, id, settings) } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] fn script_params_with_mode( cmd_mode: &str, script_path: &Path, id: &str, settings: &LaunchSettings, ) -> String { format!( r#"/d /s {cmd_mode} ""{}" "local" "{}" "{}" "{}"""#, script_path.display(), id, settings.language, settings.username, ) } #[tauri::command] async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result { let peer_ctrl_arc = state.inner().peer_ctrl.clone(); let peer_ctrl = peer_ctrl_arc.read().await.clone(); if let Some(peer_ctrl) = peer_ctrl { // Send a request to get peer count if let Err(e) = peer_ctrl.send(PeerCommand::GetPeerCount) { log::error!("Failed to send GetPeerCount message to peer: {e:?}"); } } // For now, we'll return 0 and rely on the event-based updates // The UI will get the actual count through peer-count-updated events Ok(0) } #[tauri::command] async fn get_game_thumbnail( game_id: String, app_handle: tauri::AppHandle, ) -> tauri::Result { use base64::Engine; let resource_path = app_handle.path().resolve( format!("assets/{game_id}.jpg"), tauri::path::BaseDirectory::Resource, )?; dbg!(&resource_path); let image_data = std::fs::read(&resource_path)?; let base64_data = base64::engine::general_purpose::STANDARD.encode(&image_data); Ok(format!("data:image/jpeg;base64,{base64_data}")) } #[cfg(target_os = "windows")] fn run_as_admin( file: &str, params: &str, dir: &str, show_cmd: windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD, ) -> 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()), show_cmd, ) }; (result.0 as usize) > 32 // Success if greater than 32 } #[cfg(target_os = "windows")] async fn run_game_windows( id: String, language: String, username: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result<()> { if !is_single_component_game_id(&id) { log::warn!("Ignoring run request for invalid game id: {id}"); return Ok(()); } let settings = launch_settings(&language, &username); let games_folder_lock = state.inner().games_folder.clone(); let games_folder = { let guard = games_folder_lock.read().await; guard.clone() }; let games_folder = PathBuf::from(games_folder); if !games_folder.exists() { log::error!("games_folder {} does not exist", games_folder.display()); return Ok(()); } let game_path = games_folder.join(id.clone()); let game_setup_bin = game_path.join(GAME_SETUP_SCRIPT); let game_start_bin = game_path.join(GAME_START_SCRIPT); let Some(state_dir) = state.inner().state_dir.get().cloned() else { log::error!("app state directory is not initialized; cannot run game"); return Ok(()); }; let setup_done_file = lanspread_peer::setup_done_path(&state_dir, &id); if !setup_done_file.exists() && game_setup_bin.exists() { if !local_install_is_present(&game_path) { log::warn!( "local install is missing for {}; skipping game_setup", game_path.display() ); return Ok(()); } let result = run_as_admin( "cmd.exe", &script_params(&game_setup_bin, &id, &settings), &game_path.display().to_string(), windows::Win32::UI::WindowsAndMessaging::SW_HIDE, ); if !result { log::error!("failed to run {GAME_SETUP_SCRIPT}"); return Ok(()); } if let Some(parent) = setup_done_file.parent() && let Err(e) = std::fs::create_dir_all(parent) { log::error!( "failed to create setup marker directory {}: {e}", parent.display() ); } if let Err(e) = std::fs::File::create(&setup_done_file) { log::error!( "failed to create setup marker {}: {e}", setup_done_file.display() ); } } apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await; if game_start_bin.exists() { let result = run_as_admin( "cmd.exe", &script_params(&game_start_bin, &id, &settings), &game_path.display().to_string(), windows::Win32::UI::WindowsAndMessaging::SW_HIDE, ); if !result { log::error!("failed to run {GAME_START_SCRIPT}"); } } Ok(()) } /// Stamp the launcher's username and language into the installed game's setting /// files the first time it is played. Uses the same processed values the install /// transaction used to write before this step moved to play time. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] async fn apply_launch_settings( state_dir: &Path, game_path: &Path, id: &str, language: &str, username: &str, ) { let settings = install_settings(language, username); match lanspread_peer::apply_launch_settings_once( state_dir, game_path, id, Some(&settings.account_name), Some(&settings.language), ) .await { Ok(outcome) => log::info!("launch settings for {id}: {outcome:?}"), Err(e) => log::error!("failed to apply launch settings for {id}: {e}"), } } #[tauri::command] async fn run_game( id: String, language: String, username: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result<()> { #[cfg(target_os = "windows")] { run_game_windows(id, language, username, state).await?; } #[cfg(not(target_os = "windows"))] { let _ = (state, language, username); log::error!("run_game not implemented for this platform: id={id}"); } Ok(()) } #[cfg(target_os = "windows")] async fn start_server_windows( id: String, language: String, username: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { if !is_single_component_game_id(&id) { log::warn!("Ignoring server start request for invalid game id: {id}"); return Ok(false); } let settings = launch_settings(&language, &username); let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone()); if !games_folder.exists() { log::error!("games_folder {} does not exist", games_folder.display()); return Ok(false); } let game_path = games_folder.join(id.clone()); if !local_install_is_present(&game_path) { log::warn!( "local install is missing for {}; skipping {SERVER_START_SCRIPT}", game_path.display() ); return Ok(false); } let server_start_bin = game_path.join(SERVER_START_SCRIPT); if !server_start_bin.is_file() { log::warn!( "server start script is missing for {}: {}", id, server_start_bin.display() ); return Ok(false); } let Some(state_dir) = state.inner().state_dir.get().cloned() else { log::error!("app state directory is not initialized; cannot start server"); return Ok(false); }; apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await; let result = run_as_admin( "cmd.exe", &server_script_params(&server_start_bin, &id, &settings), &game_path.display().to_string(), windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL, ); if !result { log::error!("failed to run {SERVER_START_SCRIPT}"); } Ok(result) } #[tauri::command] async fn start_server( id: String, language: String, username: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { #[cfg(target_os = "windows")] { start_server_windows(id, language, username, state).await } #[cfg(not(target_os = "windows"))] { let _ = (state, language, username); log::error!("start_server not implemented for this platform: id={id}"); Ok(false) } } #[cfg(target_os = "windows")] fn local_install_is_present(game_path: &Path) -> bool { game_path.join("local").is_dir() } fn clear_local_game_state(game: &mut Game) { game.set_downloaded(false); game.installed = false; game.local_version = None; } fn has_local_game_state(game: &Game) -> bool { game.downloaded || game.installed || game.local_version.is_some() || game.availability != Availability::LocalOnly } fn apply_peer_local_state(existing: &mut Game, local_game: &Game) { existing.set_downloaded(local_game.downloaded); existing.installed = local_game.installed; existing.local_version.clone_from(&local_game.local_version); existing.availability = local_game.normalized_availability(); } fn apply_peer_local_games(game_db: &mut GameDB, local_games: &[Game]) { let local_game_ids = local_games .iter() .map(|game| game.id.clone()) .collect::>(); for local_game in local_games { if let Some(existing_game) = game_db.get_mut_game_by_id(&local_game.id) { apply_peer_local_state(existing_game, local_game); log::debug!("Updated local game status for: {}", local_game.id); } } for game in game_db.games.values_mut() { if !local_game_ids.contains(&game.id) && has_local_game_state(game) { log::info!( "Game {} missing from peer local snapshot; marking as unavailable locally", game.id ); clear_local_game_state(game); } } } fn apply_peer_remote_games(game_db: &mut GameDB, peer_games: Vec) { // Peer events update availability, but catalog metadata stays anchored to game.db. for game in game_db.games.values_mut() { game.peer_count = 0; } for peer_game in peer_games { if let Some(existing) = game_db.get_mut_game_by_id(&peer_game.id) { existing.peer_count = peer_game.peer_count; } else { log::debug!( "Peer advertised unknown game {id}; ignoring because game.db is ground truth", id = peer_game.id ); } } } fn clear_all_local_game_states(game_db: &mut GameDB) { for game in game_db.games.values_mut() { clear_local_game_state(game); } } async fn emit_games_list(app_handle: &AppHandle) { let state = app_handle.state::(); let games_db_lock = state.games.clone(); let game_db = games_db_lock.read().await; let games_folder = state.games_folder.read().await.clone(); if game_db.games.is_empty() { log::debug!("Game database empty; skipping emit"); return; } let active_transfers = state.active_outbound_transfers.read().await; let games_to_emit = game_db .all_games() .into_iter() .cloned() .map(|game| { let active_outbound_transfers = active_transfers.get(&game.id).map_or(0, Vec::len); LauncherGame { can_host_server: game_can_host_server(&games_folder, &game), active_outbound_transfers, game, } }) .collect::>(); drop(game_db); drop(active_transfers); let active_operations = { let active_operations = state.active_operations.read().await; ui_active_operations_from_map(&active_operations) }; let payload = GamesListPayload { games: games_to_emit, active_operations, }; if let Err(e) = app_handle.emit("games-list-updated", Some(payload)) { log::error!("Failed to emit games-list-updated event: {e}"); } else { log::info!("Emitted games-list-updated event"); } } fn game_can_host_server(games_folder: &str, game: &Game) -> bool { if !game.installed || games_folder.is_empty() || !is_single_component_game_id(&game.id) { return false; } PathBuf::from(games_folder) .join(&game.id) .join(SERVER_START_SCRIPT) .is_file() } fn ui_active_operations_from_map( active_operations: &HashMap, ) -> Vec { let mut snapshot = active_operations .iter() .map(|(id, operation)| UiActiveOperation { id: id.clone(), operation: *operation, }) .collect::>(); snapshot.sort_by(|left, right| left.id.cmp(&right.id)); snapshot } fn reconcile_active_operations( active_operations: &mut HashMap, snapshot: &[ActiveOperation], ) { active_operations.clear(); active_operations.extend(snapshot.iter().map(|operation| { ( operation.id.clone(), ui_operation_from_peer(operation.operation), ) })); } fn ui_operation_from_peer(operation: ActiveOperationKind) -> UiOperationKind { match operation { ActiveOperationKind::Downloading => UiOperationKind::Downloading, ActiveOperationKind::Installing => UiOperationKind::Installing, ActiveOperationKind::Updating => UiOperationKind::Updating, ActiveOperationKind::Uninstalling => UiOperationKind::Uninstalling, ActiveOperationKind::RemovingDownload => UiOperationKind::RemovingDownload, } } #[tauri::command] fn game_directory_exists(path: String) -> bool { PathBuf::from(path).is_dir() } #[tauri::command] async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> tauri::Result<()> { log::info!("update_game_directory: {path}"); let games_folder = PathBuf::from(&path); if !games_folder.is_dir() { log::error!("game dir {} does not exist", games_folder.display()); return Ok(()); } let state = app_handle.state::(); let current_path = state.games_folder.read().await.clone(); let active_ids = state .active_operations .read() .await .keys() .cloned() .collect::>(); if current_path != path && !active_ids.is_empty() { log::warn!( "Rejecting game directory change to {} while UI operations are active for: {}", games_folder.display(), active_ids.join(", ") ); return Ok(()); } let path_changed = current_path != path; let Some(state_dir) = state.state_dir.get().cloned() else { log::error!("app state directory is not initialized; cannot update game directory"); return Ok(()); }; if path_changed || state.peer_ctrl.read().await.is_none() { let migration = migrate_legacy_state(&games_folder, &state_dir).await; if migration.failures > 0 { log::warn!( "Legacy state migration completed with {} failure(s)", migration.failures ); } } *state.games_folder.write().await = path; ensure_bundled_game_db_loaded(&app_handle).await; if path_changed { let mut game_db = state.games.write().await; clear_all_local_game_states(&mut game_db); } emit_games_list(&app_handle).await; ensure_peer_started(&app_handle, &games_folder).await; Ok(()) } async fn update_game_db(games: Vec, app: AppHandle) { for game in &games { log::trace!("peer event ListGames iter: {game:?}"); } let state = app.state::(); { let mut game_db = state.games.write().await; apply_peer_remote_games(&mut game_db, games); } emit_games_list(&app).await; } async fn update_local_games_in_db(local_games: Vec, app: AppHandle) { let state = app.state::(); { let mut game_db = state.games.write().await; apply_peer_local_games(&mut game_db, &local_games); } emit_games_list(&app).await; } 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( app_handle: &AppHandle, sidecar: Command, rar_file: &Path, dest_dir: &Path, ) -> eyre::Result<()> { let started_at_ms = now_millis(); let paths = prepare_unrar_paths(app_handle, rar_file, dest_dir, started_at_ms).await?; log::info!( "unrar game: {} to {}", paths.archive.display(), paths.destination.display() ); run_unrar_sidecar(app_handle, sidecar, &paths, started_at_ms).await } struct UnrarPaths { archive: PathBuf, destination: PathBuf, destination_arg: String, } async fn prepare_unrar_paths( app_handle: &AppHandle, rar_file: &Path, dest_dir: &Path, started_at_ms: u64, ) -> eyre::Result { let original_archive = rar_file.display().to_string(); let original_destination = dest_dir.display().to_string(); if let Err(err) = std::fs::create_dir_all(dest_dir) { let stderr = format!("failed to create directory {}: {err}", dest_dir.display()); record_unpack_failure( app_handle, original_archive, original_destination, started_at_ms, stderr.clone(), ) .await; bail!("{stderr}"); } let rar_file = match rar_file.canonicalize() { Ok(path) => path, Err(err) => { let stderr = format!( "rar_file canonicalize failed for {}: {err}", rar_file.display() ); record_unpack_failure( app_handle, original_archive, original_destination, started_at_ms, stderr.clone(), ) .await; bail!("{stderr}"); } }; let dest_dir = match dest_dir.canonicalize() { Ok(path) => path, Err(err) => { let stderr = format!( "dest_dir canonicalize failed for {}: {err}", dest_dir.display() ); record_unpack_failure( app_handle, rar_file.display().to_string(), original_destination, started_at_ms, stderr.clone(), ) .await; bail!("{stderr}"); } }; let Some(dest_dir_arg) = dest_dir.to_str().map(add_final_slash) else { let stderr = format!("failed to get str of dest_dir {}", dest_dir.display()); record_unpack_failure( app_handle, rar_file.display().to_string(), dest_dir.display().to_string(), started_at_ms, stderr.clone(), ) .await; bail!("{stderr}"); }; Ok(UnrarPaths { archive: rar_file, destination: dest_dir, destination_arg: dest_dir_arg, }) } async fn run_unrar_sidecar( app_handle: &AppHandle, sidecar: Command, paths: &UnrarPaths, started_at_ms: u64, ) -> eyre::Result<()> { let out = match sidecar .arg("x") // extract files .arg(&paths.archive) .arg("-y") // Assume Yes on all queries .arg("-o") // Set overwrite mode .arg(&paths.destination_arg) .output() .await { Ok(out) => out, Err(err) => { let stderr = format!("failed to run unrar sidecar: {err}"); record_unpack_failure( app_handle, paths.archive.display().to_string(), paths.destination.display().to_string(), started_at_ms, stderr.clone(), ) .await; bail!("{stderr}"); } }; let stdout = clean_terminal_log(&String::from_utf8_lossy(&out.stdout)); let stderr = clean_terminal_log(&String::from_utf8_lossy(&out.stderr)); let status_code = out.status.code(); let success = out.status.success(); record_unpack_log( app_handle, UnpackLogEntry { archive: paths.archive.display().to_string(), destination: paths.destination.display().to_string(), status_code, stdout: stdout.clone(), stderr: stderr.clone(), started_at_ms, finished_at_ms: now_millis(), success, }, ) .await; if !success { if !stdout.trim().is_empty() { log::error!("unrar stdout: {stdout}"); } if !stderr.trim().is_empty() { log::error!("unrar stderr: {stderr}"); } bail!("unrar failed with status {status_code:?}: {stderr}"); } Ok(()) } async fn record_unpack_failure( app_handle: &AppHandle, archive: String, destination: String, started_at_ms: u64, stderr: String, ) { record_unpack_log( app_handle, UnpackLogEntry { archive, destination, status_code: None, stdout: String::new(), stderr, started_at_ms, finished_at_ms: now_millis(), success: false, }, ) .await; } async fn record_unpack_log(app_handle: &AppHandle, entry: UnpackLogEntry) { let state = app_handle.state::(); let mut entry = entry; clean_unpack_log_entry(&mut entry); let logs = { let mut logs = state.inner().unpack_logs.write().await; logs.push(entry); trim_unpack_logs(&mut logs); logs.clone() }; persist_unpack_logs(app_handle, &logs).await; if let Err(err) = app_handle.emit("unpack-logs-updated", ()) { log::warn!("Failed to emit unpack-logs-updated event: {err}"); } } fn trim_unpack_logs(logs: &mut Vec) { if logs.len() > MAX_UNPACK_LOGS { let overflow = logs.len() - MAX_UNPACK_LOGS; logs.drain(..overflow); } } fn clean_unpack_log_entry(entry: &mut UnpackLogEntry) { let stdout = clean_terminal_log(&entry.stdout); let stderr = clean_terminal_log(&entry.stderr); entry.stdout = stdout; entry.stderr = stderr; } fn clean_terminal_log(input: &str) -> String { let mut output = String::new(); let mut line = String::new(); let mut chars = input.chars().peekable(); while let Some(ch) = chars.next() { match ch { '\r' if chars.peek() == Some(&'\n') => { let _ = chars.next(); output.push_str(&line); output.push('\n'); line.clear(); } '\r' => { line.clear(); } '\n' => { output.push_str(&line); output.push('\n'); line.clear(); } '\u{8}' => { let _ = line.pop(); } '\t' => line.push(ch), ch if ch.is_control() => {} ch => line.push(ch), } } output.push_str(&line); output } fn unpack_logs_path(state_dir: &Path) -> PathBuf { state_dir.join(UNPACK_LOGS_FILE_NAME) } fn main_log_path(state_dir: &Path) -> PathBuf { state_dir.join(MAIN_LOG_FILE_NAME) } #[cfg(test)] fn trim_main_log_file_to_limit(path: &Path, max_bytes: u64) -> io::Result<()> { let mut file = match OpenOptions::new().read(true).write(true).open(path) { Ok(file) => file, Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), Err(err) => return Err(err), }; trim_main_log_file_to_limit_with_file(&mut file, max_bytes) } fn trim_main_log_file_to_limit_with_file(file: &mut fs::File, max_bytes: u64) -> io::Result<()> { let metadata = file.metadata()?; if metadata.len() <= max_bytes { file.seek(SeekFrom::End(0))?; return Ok(()); } let tail = if max_bytes == 0 { String::new() } else { file.seek(SeekFrom::Start(metadata.len() - max_bytes))?; let mut bytes = Vec::with_capacity(usize::try_from(max_bytes).unwrap_or(usize::MAX)); file.read_to_end(&mut bytes)?; valid_utf8_tail(bytes) }; file.set_len(0)?; file.seek(SeekFrom::Start(0))?; file.write_all(tail.as_bytes())?; file.seek(SeekFrom::End(0))?; Ok(()) } fn read_main_log_file_to_limit(path: &Path, max_bytes: u64) -> io::Result { let mut file = fs::File::open(path)?; read_main_log_file_to_limit_with_file(&mut file, max_bytes) } fn read_main_log_file_to_limit_with_file( file: &mut fs::File, max_bytes: u64, ) -> io::Result { let metadata = file.metadata()?; if metadata.len() == 0 || max_bytes == 0 { file.seek(SeekFrom::End(0))?; return Ok(String::new()); } let start = metadata.len().saturating_sub(max_bytes); file.seek(SeekFrom::Start(start))?; let capacity = usize::try_from(metadata.len() - start).unwrap_or(usize::MAX); let mut bytes = Vec::with_capacity(capacity); file.read_to_end(&mut bytes)?; file.seek(SeekFrom::End(0))?; if start == 0 { Ok(String::from_utf8_lossy(&bytes).into_owned()) } else { Ok(valid_utf8_tail(bytes)) } } fn valid_utf8_tail(bytes: Vec) -> String { for offset in 0..bytes.len().min(4) { if let Ok(tail) = std::str::from_utf8(&bytes[offset..]) { return tail.to_string(); } } String::from_utf8_lossy(&bytes).into_owned() } #[derive(Clone)] struct MainLogSink { app_handle: AppHandle, path: PathBuf, file_state: Arc>, } #[derive(Default)] struct MainLogFileState { file: Option, last_sequence: u64, } impl MainLogSink { fn new(app_handle: AppHandle, path: PathBuf) -> Self { Self { app_handle, path, file_state: Arc::new(Mutex::new(MainLogFileState::default())), } } fn write_line(&self, line: String, level: Level) { write_main_log_stdout(&line); let sequence = self.append_file_line(&line); let _ = self.app_handle.emit( "main-log-line", MainLogLinePayload { line, level: level.as_str().to_string(), sequence, }, ); } fn read_history(&self) -> io::Result { let mut file_state = self .file_state .lock() .map_err(|_| io::Error::other("main log file lock poisoned"))?; if file_state.file.is_none() && !self.path.exists() { return Ok(MainLogHistoryPayload { contents: String::new(), last_sequence: file_state.last_sequence, }); } let contents = { let file = self.cached_file(&mut file_state.file)?; trim_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES)?; read_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES)? }; Ok(MainLogHistoryPayload { contents, last_sequence: file_state.last_sequence, }) } fn append_file_line(&self, line: &str) -> Option { let Ok(mut file_state) = self.file_state.lock() else { return None; }; let write_result = self.cached_file(&mut file_state.file).and_then(|file| { file.seek(SeekFrom::End(0)) .and_then(|_| writeln!(file, "{line}")) }); if write_result.is_err() { file_state.file = None; return None; } file_state.last_sequence = file_state.last_sequence.saturating_add(1); let sequence = file_state.last_sequence; let should_trim = file_state.file.as_ref().is_some_and(|file| { file.metadata().is_ok_and(|metadata| { metadata.len() > MAX_MAIN_LOG_BYTES.saturating_add(MAIN_LOG_TRIM_SLACK_BYTES) }) }); if should_trim && let Some(file) = file_state.file.as_mut() { let _ = trim_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES); } Some(sequence) } fn cached_file<'a>(&self, file: &'a mut Option) -> io::Result<&'a mut fs::File> { if file.is_none() { *file = Some(open_main_log_file(&self.path)?); } file.as_mut() .ok_or_else(|| io::Error::other("main log file was not opened")) } } fn open_main_log_file(path: &Path) -> io::Result { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } OpenOptions::new() .create(true) .truncate(false) .read(true) .write(true) .open(path) } struct MainLogLayer { sink: MainLogSink, } impl MainLogLayer { fn new(sink: MainLogSink) -> Self { Self { sink } } } impl Layer for MainLogLayer where S: Subscriber + for<'span> LookupSpan<'span>, { fn enabled(&self, metadata: &Metadata<'_>, _ctx: Context<'_, S>) -> bool { should_capture_main_log_metadata(metadata) } fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { let metadata = event.metadata(); if !should_capture_main_log_metadata(metadata) { return; } let mut visitor = MainLogFieldVisitor::default(); event.record(&mut visitor); let target = visitor .log_target .clone() .unwrap_or_else(|| metadata.target().to_string()); let message = visitor.into_message(); let (date, time) = current_main_log_timestamp(); let line = format_main_log_line_parts(&date, &time, &target, metadata.level().as_str(), &message); self.sink.write_line(line, *metadata.level()); } } #[derive(Default)] struct MainLogFieldVisitor { message: Option, log_target: Option, fields: Vec, } impl MainLogFieldVisitor { fn record_value(&mut self, field_name: &str, value: String) { match field_name { "message" => self.message = Some(value), "log.target" => self.log_target = Some(value), "log.module_path" | "log.file" | "log.line" => {} _ => self.fields.push(format!("{field_name}={value}")), } } fn into_message(self) -> String { let mut parts = Vec::new(); if let Some(message) = self.message && !message.is_empty() { parts.push(message); } parts.extend(self.fields); if parts.is_empty() { String::from("(no message)") } else { normalize_main_log_message(&parts.join(" ")) } } } impl Visit for MainLogFieldVisitor { fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { self.record_value(field.name(), value.to_string()); } fn record_i64(&mut self, field: &tracing::field::Field, value: i64) { self.record_value(field.name(), value.to_string()); } fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { self.record_value(field.name(), value.to_string()); } fn record_str(&mut self, field: &tracing::field::Field, value: &str) { self.record_value(field.name(), value.to_string()); } fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { self.record_value(field.name(), format!("{value:?}")); } } fn should_capture_main_log_metadata(metadata: &Metadata<'_>) -> bool { if metadata.target().starts_with("mdns_sd::service_daemon") { return false; } matches!(*metadata.level(), Level::ERROR | Level::WARN | Level::INFO) } fn current_main_log_timestamp() -> (String, String) { let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc()); let date = now.date(); let clock = now.time(); ( format!( "{:04}-{:02}-{:02}", date.year(), u8::from(date.month()), date.day() ), format!( "{:02}:{:02}:{:02}", clock.hour(), clock.minute(), clock.second() ), ) } fn format_main_log_line_parts( date: &str, time: &str, target: &str, level: &str, message: &str, ) -> String { format!( "[{date}][{time}][{}][{level}] {}", normalize_main_log_target(target), normalize_main_log_message(message) ) } fn normalize_main_log_target(target: &str) -> String { target.replace(['\r', '\n'], " ") } fn normalize_main_log_message(message: &str) -> String { message.replace('\r', "\\r").replace('\n', "\\n") } fn write_main_log_stdout(line: &str) { let stdout = std::io::stdout(); let mut stdout = stdout.lock(); let _ = writeln!(stdout, "{line}"); } fn init_main_logging(sink: MainLogSink) -> Result<(), Box> { let subscriber = tracing_subscriber::registry().with(MainLogLayer::new(sink)); tracing::subscriber::set_global_default(subscriber)?; tracing_log::LogTracer::builder() .with_max_level(log::LevelFilter::Info) .init()?; Ok(()) } fn load_unpack_logs(state_dir: &Path) -> Vec { let path = unpack_logs_path(state_dir); let contents = match std::fs::read_to_string(&path) { Ok(contents) => contents, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(), Err(err) => { log::warn!("Failed to read unpack logs from {}: {err}", path.display()); return Vec::new(); } }; let mut logs = match serde_json::from_str::>(&contents) { Ok(logs) => logs, Err(err) => { log::warn!("Failed to parse unpack logs from {}: {err}", path.display()); return Vec::new(); } }; logs.iter_mut().for_each(clean_unpack_log_entry); trim_unpack_logs(&mut logs); logs } async fn persist_unpack_logs(app_handle: &AppHandle, logs: &[UnpackLogEntry]) { let state = app_handle.state::(); let Some(state_dir) = state.state_dir.get().cloned() else { log::warn!("Cannot persist unpack logs before app state directory is initialized"); return; }; let path = unpack_logs_path(&state_dir); let contents = match serde_json::to_vec_pretty(logs) { Ok(contents) => contents, Err(err) => { log::warn!( "Failed to serialize unpack logs for {}: {err}", path.display() ); return; } }; if let Err(err) = tokio::fs::write(&path, contents).await { log::warn!("Failed to persist unpack logs to {}: {err}", path.display()); } } fn now_millis() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |duration| { u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) }) } /// Resolve the bundled catalog database packaged with the Tauri application. fn resolve_bundled_game_db_path(app_handle: &AppHandle) -> PathBuf { app_handle .path() .resolve("game.db", tauri::path::BaseDirectory::Resource) .unwrap_or_else(|e| { log::error!("Failed to resolve game.db resource: {e}"); panic!("game.db resource is required - cannot continue"); }) } /// Load the bundled catalog into the in-memory game database used by the UI. async fn load_bundled_game_db(app_handle: &AppHandle) -> GameDB { let game_db_path = resolve_bundled_game_db_path(app_handle); let eti_games = get_games(&game_db_path).await.unwrap_or_else(|e| { log::error!("Failed to load ETI games: {e}"); panic!("game.db resource is required - cannot continue"); }); log::info!("Loaded {} ETI games from game.db", eti_games.len()); let games: Vec = eti_games.into_iter().map(Into::into).collect(); GameDB::from(games) } async fn ensure_bundled_game_db_loaded(app_handle: &AppHandle) { let state = app_handle.state::(); let needs_load = { state.games.read().await.games.is_empty() }; if needs_load { let game_db = load_bundled_game_db(app_handle).await; let catalog = GameCatalog::from_game_db(&game_db); *state.games.write().await = game_db; *state.catalog.write().await = catalog; } } async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) { let state = app_handle.state::(); let mut peer_ctrl = state.peer_ctrl.write().await; if let Some(peer_ctrl) = peer_ctrl.as_ref() { if let Err(e) = peer_ctrl.send(PeerCommand::SetGameDir(games_folder.to_path_buf())) { log::error!("Failed to send PeerCommand::SetGameDir: {e}"); } return; } let Some(state_dir) = state.state_dir.get().cloned() else { log::error!("app state directory is not initialized; cannot start peer"); return; }; let tx_peer_event = app_handle.state::().inner().0.clone(); let unpacker = Arc::new(SidecarUnpacker { app_handle: app_handle.clone(), }); match start_peer_with_options( games_folder.to_path_buf(), tx_peer_event, state.peer_game_db.clone(), unpacker, state.catalog.clone(), PeerStartOptions { state_dir: Some(state_dir), active_outbound_transfers: Some(state.active_outbound_transfers.clone()), stream_install_provider: None, }, ) { Ok(handle) => { let sender = handle.sender(); *peer_ctrl = Some(sender.clone()); *state.peer_runtime.write().await = Some(handle); if let Err(e) = sender.send(PeerCommand::ListGames) { log::error!("Failed to send initial PeerCommand::ListGames: {e}"); } log::info!("Peer system initialized successfully with games directory"); } Err(e) => { log::error!("Failed to initialize peer system: {e}"); } } } fn emit_game_id_event(app_handle: &AppHandle, event: &str, id: &str, label: &str) { if let Err(e) = app_handle.emit(event, Some(id.to_owned())) { log::error!("{label}: Failed to emit {event} event: {e}"); } } fn emit_peer_addr_event(app_handle: &AppHandle, event: &str, addr: SocketAddr) { if let Err(e) = app_handle.emit(event, Some(addr.to_string())) { log::error!("Failed to emit {event} event: {e}"); } } fn spawn_peer_event_loop(app_handle: AppHandle, mut rx_peer_event: UnboundedReceiver) { tauri::async_runtime::spawn(async move { while let Some(event) = rx_peer_event.recv().await { handle_peer_event(&app_handle, event).await; } }); } async fn schedule_outbound_transfer_emit(app_handle: &AppHandle) { let state = app_handle.state::(); let should_spawn = { let mut emit_state = state.outbound_transfer_emit.write().await; emit_state.record_change() }; if !should_spawn { return; } let app_handle = app_handle.clone(); tauri::async_runtime::spawn(async move { loop { tokio::time::sleep(OUTBOUND_TRANSFER_EMIT_DEBOUNCE).await; let observed_generation = { let state = app_handle.state::(); state .outbound_transfer_emit .read() .await .observed_generation() }; emit_games_list(&app_handle).await; let needs_follow_up_emit = { let state = app_handle.state::(); let mut emit_state = state.outbound_transfer_emit.write().await; emit_state.finish_emit(observed_generation) }; if !needs_follow_up_emit { break; } } }); } #[allow(clippy::too_many_lines)] async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) { match event { PeerEvent::LocalPeerReady { peer_id, addr } => { log::info!("Local peer ready: {peer_id} at {addr}"); if let Err(e) = app_handle.emit("peer-local-ready", Some((peer_id, addr.to_string()))) { log::error!("Failed to emit peer-local-ready event: {e}"); } } PeerEvent::ListGames(games) => { log::info!("PeerEvent::ListGames received"); update_game_db(games, app_handle.clone()).await; } PeerEvent::LocalLibraryChanged { games: local_games } => { log::info!("PeerEvent::LocalLibraryChanged received"); update_local_games_in_db(local_games, app_handle.clone()).await; } PeerEvent::ActiveOperationsChanged { active_operations } => { log::info!("PeerEvent::ActiveOperationsChanged received"); let state = app_handle.state::(); { let mut ui_active_operations = state.active_operations.write().await; reconcile_active_operations(&mut ui_active_operations, &active_operations); } emit_games_list(app_handle).await; } PeerEvent::OutboundTransferCountChanged => { log::info!("PeerEvent::OutboundTransferCountChanged received"); schedule_outbound_transfer_emit(app_handle).await; } PeerEvent::GotGameFiles { id, file_descriptions, } => { handle_got_game_files(app_handle, id, file_descriptions).await; } PeerEvent::NoPeersHaveGame { id } => { log::warn!("PeerEvent::NoPeersHaveGame received for {id}"); emit_game_id_event( app_handle, "game-no-peers", &id, "PeerEvent::NoPeersHaveGame", ); } PeerEvent::DownloadGameFilesBegin { id } => { log::info!("PeerEvent::DownloadGameFilesBegin received"); emit_game_id_event( app_handle, "game-download-begin", &id, "PeerEvent::DownloadGameFilesBegin", ); } PeerEvent::DownloadGameFileChunkFinished { id, peer_addr, relative_path, offset, length, } => { log::debug!( "PeerEvent::DownloadGameFileChunkFinished received for {id}: \ {relative_path} offset {offset} length {length} from {peer_addr}" ); } PeerEvent::DownloadGameFilesProgress(progress) => { if let Err(e) = app_handle.emit("game-download-progress", Some(progress)) { log::error!("Failed to emit game-download-progress event: {e}"); } } PeerEvent::DownloadGameFilesFinished { id } => { handle_download_finished(app_handle, id); } PeerEvent::DownloadGameFilesFailed { id } => { log::warn!("PeerEvent::DownloadGameFilesFailed received"); emit_game_id_event( app_handle, "game-download-failed", &id, "PeerEvent::DownloadGameFilesFailed", ); } PeerEvent::DownloadGameFilesAllPeersGone { id } => { log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}"); emit_game_id_event( app_handle, "game-download-peers-gone", &id, "PeerEvent::DownloadGameFilesAllPeersGone", ); } PeerEvent::InstallGameBegin { id, operation } => { let operation_name: &'static str = (&operation).into(); log::info!("PeerEvent::InstallGameBegin received for {id}: {operation_name}"); emit_game_id_event( app_handle, "game-install-begin", &id, "PeerEvent::InstallGameBegin", ); } PeerEvent::InstallGameFinished { id } => { log::info!("PeerEvent::InstallGameFinished received for {id}"); emit_game_id_event( app_handle, "game-install-finished", &id, "PeerEvent::InstallGameFinished", ); } PeerEvent::InstallGameFailed { id } => { log::warn!("PeerEvent::InstallGameFailed received for {id}"); emit_game_id_event( app_handle, "game-install-failed", &id, "PeerEvent::InstallGameFailed", ); } PeerEvent::UninstallGameBegin { id } => { log::info!("PeerEvent::UninstallGameBegin received for {id}"); emit_game_id_event( app_handle, "game-uninstall-begin", &id, "PeerEvent::UninstallGameBegin", ); } PeerEvent::UninstallGameFinished { id } => { log::info!("PeerEvent::UninstallGameFinished received for {id}"); emit_game_id_event( app_handle, "game-uninstall-finished", &id, "PeerEvent::UninstallGameFinished", ); } PeerEvent::UninstallGameFailed { id } => { log::warn!("PeerEvent::UninstallGameFailed received for {id}"); emit_game_id_event( app_handle, "game-uninstall-failed", &id, "PeerEvent::UninstallGameFailed", ); } PeerEvent::RemoveDownloadedGameBegin { id } => { log::info!("PeerEvent::RemoveDownloadedGameBegin received for {id}"); emit_game_id_event( app_handle, "game-remove-download-begin", &id, "PeerEvent::RemoveDownloadedGameBegin", ); } PeerEvent::RemoveDownloadedGameFinished { id } => { log::info!("PeerEvent::RemoveDownloadedGameFinished received for {id}"); emit_game_id_event( app_handle, "game-remove-download-finished", &id, "PeerEvent::RemoveDownloadedGameFinished", ); } PeerEvent::RemoveDownloadedGameFailed { id } => { log::warn!("PeerEvent::RemoveDownloadedGameFailed received for {id}"); emit_game_id_event( app_handle, "game-remove-download-failed", &id, "PeerEvent::RemoveDownloadedGameFailed", ); } PeerEvent::PeerConnected(addr) => { log::info!("Peer connected: {addr}"); emit_peer_addr_event(app_handle, "peer-connected", addr); } PeerEvent::PeerDisconnected(addr) => { log::info!("Peer disconnected: {addr}"); emit_peer_addr_event(app_handle, "peer-disconnected", addr); } PeerEvent::PeerDiscovered(addr) => { log::info!("Peer discovered: {addr}"); emit_peer_addr_event(app_handle, "peer-discovered", addr); } PeerEvent::PeerLost(addr) => { log::info!("Peer lost: {addr}"); emit_peer_addr_event(app_handle, "peer-lost", addr); } PeerEvent::PeerCountUpdated(count) => { log::info!("Peer count updated: {count}"); if let Err(e) = app_handle.emit("peer-count-updated", Some(count)) { log::error!("Failed to emit peer-count-updated event: {e}"); } } PeerEvent::RuntimeFailed { component, error } => { let component_name: &'static str = (&component).into(); log::error!("Peer runtime component {component_name} failed: {error}"); if let Err(e) = app_handle.emit( "peer-runtime-failed", Some((component_name.to_string(), error)), ) { log::error!("Failed to emit peer-runtime-failed event: {e}"); } } } } async fn handle_got_game_files( app_handle: &AppHandle, id: String, file_descriptions: Vec, ) { log::info!("PeerEvent::GotGameFiles received"); emit_game_id_event( app_handle, "game-download-pre", &id, "PeerEvent::GotGameFiles", ); 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}"); } } fn handle_download_finished(app_handle: &AppHandle, id: String) { log::info!("PeerEvent::DownloadGameFilesFinished received"); emit_game_id_event( app_handle, "game-download-finished", &id, "PeerEvent::DownloadGameFilesFinished", ); } #[cfg(test)] mod tests { use super::*; fn unpack_log_fixture(index: usize) -> UnpackLogEntry { let timestamp = u64::try_from(index).unwrap_or(u64::MAX); UnpackLogEntry { archive: format!("archive-{index}.rar"), destination: format!("destination-{index}"), status_code: Some(0), stdout: format!("stdout {index}"), stderr: String::new(), started_at_ms: timestamp, finished_at_ms: timestamp, success: true, } } fn game_fixture(id: &str, name: &str) -> Game { Game { id: id.to_string(), name: name.to_string(), description: format!("{name} description"), release_year: "2000".to_string(), publisher: "publisher".to_string(), max_players: 4, version: "1.0".to_string(), genre: "genre".to_string(), size: 123, downloaded: false, installed: false, availability: Availability::LocalOnly, eti_game_version: None, local_version: None, peer_count: 0, } } fn eti_game_fixture(game_id: &str, game_version: &str) -> lanspread_compat::eti::EtiGame { lanspread_compat::eti::EtiGame { game_id: game_id.to_string(), game_title: "Catalog Game".to_string(), game_key: "catalog-game".to_string(), game_release: "2000".to_string(), game_publisher: "publisher".to_string(), game_size: 1.0, game_readme_de: "description".to_string(), game_readme_en: "description".to_string(), game_readme_fr: "description".to_string(), game_maxplayers: 4, game_master_req: 0, genre_de: "genre".to_string(), game_version: game_version.to_string(), } } #[test] fn eti_game_conversion_uses_catalog_version_as_authoritative_eti_version() { let game = Game::from(eti_game_fixture("alpha", "20200721")); assert_eq!(game.version, "20200721"); assert_eq!(game.eti_game_version.as_deref(), Some("20200721")); assert_eq!(game.local_version, None); } #[test] fn terminal_log_cleanup_preserves_crlf_and_collapses_redrawn_lines() { let input = "Extracting foo 10%\rExtracting foo 80%\rExtracting foo OK\r\nAll done\r\n"; assert_eq!(clean_terminal_log(input), "Extracting foo OK\nAll done\n"); } #[test] fn terminal_log_cleanup_applies_backspaces() { assert_eq!(clean_terminal_log("abc\u{8}\u{8}de\n"), "ade\n"); } #[test] fn terminal_log_cleanup_removes_other_controls() { assert_eq!(clean_terminal_log("a\u{7}b\tc"), "ab\tc"); } #[test] fn unpack_log_retention_keeps_last_twenty_entries() { let mut logs = (0..25).map(unpack_log_fixture).collect::>(); trim_unpack_logs(&mut logs); assert_eq!(logs.len(), MAX_UNPACK_LOGS); assert_eq!( logs.first().map(|entry| entry.archive.as_str()), Some("archive-5.rar") ); assert_eq!( logs.last().map(|entry| entry.archive.as_str()), Some("archive-24.rar") ); } #[test] fn unpack_logs_load_from_app_state_dir_and_apply_retention() { let root = std::env::temp_dir().join(format!( "lanspread-unpack-logs-test-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .expect("clock should be after epoch") .as_nanos() )); std::fs::create_dir_all(&root).expect("test state dir should be created"); let logs = (0..25).map(unpack_log_fixture).collect::>(); std::fs::write( unpack_logs_path(&root), serde_json::to_vec(&logs).expect("logs should serialize"), ) .expect("logs should be written"); let loaded = load_unpack_logs(&root); assert_eq!(loaded.len(), MAX_UNPACK_LOGS); assert_eq!( loaded.first().map(|entry| entry.archive.as_str()), Some("archive-5.rar") ); assert_eq!( loaded.last().map(|entry| entry.archive.as_str()), Some("archive-24.rar") ); let _ = std::fs::remove_dir_all(root); } #[test] fn main_log_line_format_is_stable_and_single_line() { let line = format_main_log_line_parts( "2026-06-07", "12:34:56", "lanspread\napp", "WARN", "first line\nsecond line", ); assert_eq!( line, "[2026-06-07][12:34:56][lanspread app][WARN] first line\\nsecond line" ); } #[test] fn main_log_trim_keeps_utf8_tail_at_char_boundary() { let root = std::env::temp_dir().join(format!( "lanspread-main-log-test-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .expect("clock should be after epoch") .as_nanos() )); std::fs::create_dir_all(&root).expect("test state dir should be created"); let path = main_log_path(&root); std::fs::write(&path, format!("{}{}", "a".repeat(11), "é".repeat(20))) .expect("main log should be written"); trim_main_log_file_to_limit(&path, 21).expect("main log should trim"); let trimmed = std::fs::read_to_string(&path).expect("trimmed log should remain utf-8"); assert!(trimmed.as_bytes().len() <= 21); assert!(trimmed.starts_with('é')); assert!(trimmed.ends_with('é')); let _ = std::fs::remove_dir_all(root); } #[test] fn active_operation_reconciliation_replaces_stale_ui_history() { let mut active_operations = HashMap::from([ ("stale".to_string(), UiOperationKind::Installing), ("keep".to_string(), UiOperationKind::Downloading), ]); let snapshot = vec![ ActiveOperation { id: "keep".to_string(), operation: ActiveOperationKind::Updating, }, ActiveOperation { id: "new".to_string(), operation: ActiveOperationKind::Uninstalling, }, ]; reconcile_active_operations(&mut active_operations, &snapshot); assert_eq!(active_operations.len(), 2); assert_eq!( active_operations.get("keep"), Some(&UiOperationKind::Updating) ); assert_eq!( active_operations.get("new"), Some(&UiOperationKind::Uninstalling) ); assert!( !active_operations.contains_key("stale"), "snapshot reconciliation should drop stale UI operations" ); } #[test] fn local_snapshot_without_finish_clears_stale_ui_operation() { let mut active_operations = HashMap::from([("game".to_string(), UiOperationKind::Downloading)]); reconcile_active_operations(&mut active_operations, &[]); assert!( active_operations.is_empty(), "an authoritative snapshot without the game should clear a missed finish event" ); } #[test] fn local_snapshot_without_begin_restores_active_ui_operation() { let mut active_operations = HashMap::new(); let snapshot = vec![ActiveOperation { id: "game".to_string(), operation: ActiveOperationKind::Installing, }]; reconcile_active_operations(&mut active_operations, &snapshot); assert_eq!( active_operations.get("game"), Some(&UiOperationKind::Installing), "an authoritative snapshot should recover a missed begin event" ); } #[test] fn active_operation_payload_is_sorted_for_stable_ui_updates() { let active_operations = HashMap::from([ ("zeta".to_string(), UiOperationKind::Downloading), ("alpha".to_string(), UiOperationKind::Installing), ]); let snapshot = ui_active_operations_from_map(&active_operations); assert_eq!( snapshot, vec![ UiActiveOperation { id: "alpha".to_string(), operation: UiOperationKind::Installing, }, UiActiveOperation { id: "zeta".to_string(), operation: UiOperationKind::Downloading, }, ] ); } #[test] fn outbound_transfer_emit_state_coalesces_bursts_without_losing_updates() { let mut state = OutboundTransferEmitState::default(); assert!( state.record_change(), "first change should schedule an emit" ); assert_eq!(state.observed_generation(), 1); assert!( !state.record_change(), "second change should reuse the scheduled emit" ); assert_eq!(state.observed_generation(), 2); assert!( state.finish_emit(1), "a generation observed before the latest change needs a follow-up emit" ); assert!( !state.finish_emit(2), "the latest observed generation clears the scheduled emit" ); assert!(state.record_change(), "a later burst should schedule again"); } #[test] fn game_file_viewer_ids_must_be_single_path_components() { assert!(is_single_component_game_id("game")); assert!(is_single_component_game_id("game.v1")); assert!(!is_single_component_game_id("")); assert!(!is_single_component_game_id("../game")); assert!(!is_single_component_game_id("nested/game")); assert!(!is_single_component_game_id("/game")); } #[test] fn launch_settings_sanitize_script_arguments() { assert_eq!( launch_settings("DE", " Alice \"Ace\"%PATH%\n "), LaunchSettings { language: "de".to_string(), username: "Alice AcePATH".to_string(), } ); assert_eq!( launch_settings("fr", ""), LaunchSettings { language: DEFAULT_LANGUAGE.to_string(), username: DEFAULT_USERNAME.to_string(), } ); } #[test] fn install_settings_use_language_file_values() { assert_eq!( install_settings("de", " Alice \"Ace\"%PATH%\n "), InstallSettings { account_name: "Alice AcePATH".to_string(), language: "german".to_string(), } ); assert_eq!( install_settings("fr", ""), InstallSettings { account_name: DEFAULT_USERNAME.to_string(), language: "english".to_string(), } ); } #[test] fn script_params_use_common_argument_shape() { let start_params = script_params( Path::new("C:/Games/My Game") .join(GAME_START_SCRIPT) .as_path(), "my-game", &LaunchSettings { language: "en".to_string(), username: "Alice".to_string(), }, ); assert_eq!( start_params, r#"/d /s /c ""C:/Games/My Game/game_start.cmd" "local" "my-game" "en" "Alice"""# ); let server_params = server_script_params( Path::new("C:/Games/My Game") .join(SERVER_START_SCRIPT) .as_path(), "my-game", &LaunchSettings { language: "en".to_string(), username: "Alice".to_string(), }, ); assert_eq!( server_params, r#"/d /s /k ""C:/Games/My Game/server_start.cmd" "local" "my-game" "en" "Alice"""# ); } #[test] fn server_host_capability_requires_installed_game_with_script() { let root = std::env::temp_dir().join(format!( "lanspread-server-test-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .expect("clock should be after epoch") .as_nanos() )); let game_root = root.join("game"); std::fs::create_dir_all(&game_root).expect("game root should be created"); std::fs::write(game_root.join(SERVER_START_SCRIPT), b"").expect("script should be written"); let mut game = game_fixture("game", "Game"); assert!(!game_can_host_server( root.to_string_lossy().as_ref(), &game )); game.installed = true; assert!(game_can_host_server(root.to_string_lossy().as_ref(), &game)); let _ = std::fs::remove_dir_all(root); } #[test] fn peer_local_snapshot_replaces_local_state_without_overwriting_catalog_metadata() { let mut alpha = game_fixture("alpha", "Catalog Alpha"); alpha.size = 999; alpha.peer_count = 3; let mut beta = game_fixture("beta", "Catalog Beta"); beta.set_downloaded(true); beta.installed = true; beta.local_version = Some("20240101".to_string()); let mut game_db = GameDB::from(vec![alpha, beta]); let mut local_alpha = game_fixture("alpha", "Peer Alpha"); local_alpha.size = 42; local_alpha.set_downloaded(true); local_alpha.local_version = Some("20240202".to_string()); let mut unknown = game_fixture("unknown", "Unknown"); unknown.set_downloaded(true); unknown.installed = true; apply_peer_local_games(&mut game_db, &[local_alpha, unknown]); let alpha = game_db.get_game_by_id("alpha").expect("alpha remains"); assert_eq!(alpha.name, "Catalog Alpha"); assert_eq!(alpha.size, 999); assert_eq!(alpha.peer_count, 3); assert!(alpha.downloaded); assert!(!alpha.installed); assert_eq!(alpha.availability, Availability::Ready); assert_eq!(alpha.local_version.as_deref(), Some("20240202")); let beta = game_db.get_game_by_id("beta").expect("beta remains"); assert!(!beta.downloaded); assert!(!beta.installed); assert_eq!(beta.availability, Availability::LocalOnly); assert_eq!(beta.local_version, None); assert!(game_db.get_game_by_id("unknown").is_none()); } #[test] fn peer_remote_snapshot_updates_counts_without_overwriting_catalog_version() { let mut alpha = game_fixture("alpha", "Catalog Alpha"); alpha.size = 999; alpha.eti_game_version = Some("20200721".to_string()); let mut beta = game_fixture("beta", "Catalog Beta"); beta.peer_count = 2; beta.eti_game_version = Some("20200101".to_string()); let mut game_db = GameDB::from(vec![alpha, beta]); let mut peer_alpha = game_fixture("alpha", "Peer Alpha"); peer_alpha.size = 42; peer_alpha.peer_count = 3; peer_alpha.eti_game_version = Some("20990101".to_string()); let mut unknown = game_fixture("unknown", "Unknown"); unknown.peer_count = 1; unknown.eti_game_version = Some("20990101".to_string()); apply_peer_remote_games(&mut game_db, vec![peer_alpha, unknown]); let alpha = game_db.get_game_by_id("alpha").expect("alpha remains"); assert_eq!(alpha.name, "Catalog Alpha"); assert_eq!(alpha.size, 999); assert_eq!(alpha.peer_count, 3); assert_eq!(alpha.eti_game_version.as_deref(), Some("20200721")); let beta = game_db.get_game_by_id("beta").expect("beta remains"); assert_eq!(beta.peer_count, 0); assert_eq!(beta.eti_game_version.as_deref(), Some("20200101")); assert!(game_db.get_game_by_id("unknown").is_none()); } } #[allow(clippy::missing_panics_doc)] #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // channel to receive events from the peer let (tx_peer_event, rx_peer_event) = tokio::sync::mpsc::unbounded_channel::(); tauri::Builder::default() .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .invoke_handler(tauri::generate_handler![ request_games, install_game, run_game, start_server, game_directory_exists, update_game_directory, update_game, uninstall_game, remove_downloaded_game, cancel_download, open_game_files, get_peer_count, get_game_thumbnail, get_unpack_logs, get_main_logs ]) .manage(LanSpreadState::default()) .manage(PeerEventTx(tx_peer_event)) .setup(move |app| { let state_dir = app.path().app_data_dir()?; std::fs::create_dir_all(&state_dir)?; let main_log_sink = MainLogSink::new(app.handle().clone(), main_log_path(&state_dir)); let state = app.state::(); if state.main_log_sink.set(main_log_sink.clone()).is_err() { log::warn!("main log sink was already initialized"); } init_main_logging(main_log_sink)?; let unpack_logs = load_unpack_logs(&state_dir); tauri::async_runtime::block_on(async { *state.unpack_logs.write().await = unpack_logs; }); if state.state_dir.set(state_dir).is_err() { log::warn!("app state directory was already initialized"); } spawn_peer_event_loop(app.handle().clone(), rx_peer_event); Ok(()) }) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app_handle, event| { if matches!(event, tauri::RunEvent::Exit) { shutdown_peer_runtime(app_handle); } }); } fn shutdown_peer_runtime(app_handle: &AppHandle) { let state = app_handle.state::(); let peer_runtime = state.peer_runtime.clone(); tauri::async_runtime::block_on(async move { let Some(mut handle) = peer_runtime.write().await.take() else { return; }; handle.shutdown(); if tokio::time::timeout(std::time::Duration::from_secs(2), handle.wait_stopped()) .await .is_err() { log::warn!("Peer runtime did not stop within 2s of shutdown request"); } }); }