#[cfg(target_os = "windows")] use std::fs::File; use std::{ collections::{HashMap, HashSet}, net::SocketAddr, path::{Path, PathBuf}, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; use eyre::bail; use lanspread_compat::eti::get_games; use lanspread_db::db::{Availability, Game, GameDB, GameFileDescription}; use lanspread_peer::{ ActiveOperation, ActiveOperationKind, PeerCommand, PeerEvent, PeerGameDB, PeerRuntimeHandle, UnpackFuture, Unpacker, start_peer, }; use tauri::{AppHandle, Emitter as _, Manager}; use tauri_plugin_shell::{ShellExt, process::Command}; use tokio::sync::{ RwLock, mpsc::{UnboundedReceiver, UnboundedSender}, }; // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ /// 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>>, } struct PeerEventTx(UnboundedSender); #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] enum UiOperationKind { Downloading, Installing, Updating, Uninstalling, } #[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 UnpackLogEntry { archive: String, destination: String, status_code: Option, stdout: String, stderr: String, started_at_ms: u64, finished_at_ms: u64, success: bool, } struct SidecarUnpacker { app_handle: AppHandle, } const MAX_UNPACK_LOGS: usize = 100; 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()) } #[cfg(target_os = "windows")] const FIRST_START_DONE_FILE: &str = ".softlan_first_start_done"; #[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, 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 handled = if let Some(peer_ctrl) = peer_ctrl { let command = if !downloaded { PeerCommand::GetGame(id) } else if !installed { PeerCommand::InstallGame { id } } 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:?}"); } true } else { log::warn!("Peer system not initialized yet"); false }; Ok(handled) } #[tauri::command] async fn update_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); } 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(); if let Some(peer_ctrl) = peer_ctrl { if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { 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 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 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) -> 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")] async fn run_game_windows( id: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result<()> { 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.cmd"); let game_start_bin = game_path.join("game_start.cmd"); let first_start_done_file = game_path.join("local").join(FIRST_START_DONE_FILE); if !first_start_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", &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 Ok(()); } if let Err(e) = File::create(&first_start_done_file) { log::error!( "failed to create first-start marker {}: {e}", first_start_done_file.display() ); } } 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"); } } Ok(()) } #[tauri::command] async fn run_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<()> { #[cfg(target_os = "windows")] { run_game_windows(id, state).await?; } #[cfg(not(target_os = "windows"))] { let _ = state; log::error!("run_game not implemented for this platform: id={id}"); } Ok(()) } #[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 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; if game_db.games.is_empty() { log::debug!("Game database empty; skipping emit"); return; } let games_to_emit = game_db .all_games() .into_iter() .cloned() .collect::>(); drop(game_db); 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 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, } } #[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; *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; // Reset peer counts up front. Presence/metadata stay anchored to the baked game.db. for game in game_db.games.values_mut() { game.peer_count = 0; } for peer_game in games { if let Some(existing) = game_db.get_mut_game_by_id(&peer_game.id) { existing.peer_count = peer_game.peer_count; if let Some(peer_version) = &peer_game.eti_game_version { match &existing.eti_game_version { Some(current_version) if current_version >= peer_version => {} _ => { existing.eti_game_version = Some(peer_version.clone()); log::debug!( "Updated eti_game_version for {} to {} based on peer data", peer_game.id, peer_version ); } } } } else { log::debug!( "Peer advertised unknown game {id}; ignoring because game.db is ground truth", id = peer_game.id ); } } } 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 = String::from_utf8_lossy(&out.stdout).into_owned(); let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); 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 logs = state.inner().unpack_logs.write().await; logs.push(entry); if logs.len() > MAX_UNPACK_LOGS { let overflow = logs.len() - MAX_UNPACK_LOGS; logs.drain(..overflow); } } if let Err(err) = app_handle.emit("unpack-logs-updated", ()) { log::warn!("Failed to emit unpack-logs-updated event: {err}"); } } 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 = game_db.games.keys().cloned().collect::>(); *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 tx_peer_event = app_handle.state::().inner().0.clone(); let unpacker = Arc::new(SidecarUnpacker { app_handle: app_handle.clone(), }); match start_peer( games_folder.to_path_buf(), tx_peer_event, state.peer_game_db.clone(), unpacker, state.catalog.clone(), ) { 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; } }); } #[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::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::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::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 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, } } #[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 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()); } } #[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, 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_logger_builder.build()) .plugin(tauri_plugin_shell::init()) .invoke_handler(tauri::generate_handler![ request_games, install_game, run_game, update_game_directory, update_game, uninstall_game, get_peer_count, get_game_thumbnail, get_unpack_logs ]) .manage(LanSpreadState::default()) .manage(PeerEventTx(tx_peer_event)) .setup(move |app| { 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"); } }); }