diff --git a/Cargo.lock b/Cargo.lock index b8ac7b8..0eb40e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2261,6 +2261,7 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-store", "tokio", + "walkdir", "windows 0.62.2", ] diff --git a/crates/lanspread-peer/README.md b/crates/lanspread-peer/README.md index 78daf4e..7b6abce 100644 --- a/crates/lanspread-peer/README.md +++ b/crates/lanspread-peer/README.md @@ -7,10 +7,11 @@ It is designed to run headless – other crates (most notably ## Runtime Overview -- `start_peer(game_dir, tx_events)` boots the asynchronous runtime in the +- `start_peer(game_dir, tx_events, peer_game_db)` boots the asynchronous runtime in the background and returns an `UnboundedSender` that the caller uses for control. The function immediately forwards the supplied game directory via - `PeerCommand::SetGameDir`. + `PeerCommand::SetGameDir` and keeps using the provided `PeerGameDB` so the UI + layer can observe live peer metadata. - `PeerCommand` represents the small control surface exposed to the UI layer: `ListGames`, `GetGame`, `DownloadGameFiles`, and `SetGameDir`. - `PeerEvent` enumerates everything the peer runtime reports back to the UI: diff --git a/crates/lanspread-peer/src/lib.rs b/crates/lanspread-peer/src/lib.rs index ab660b8..cbeb77c 100644 --- a/crates/lanspread-peer/src/lib.rs +++ b/crates/lanspread-peer/src/lib.rs @@ -89,6 +89,7 @@ impl From for PeerError { pub fn start_peer( game_dir: String, tx_notify_ui: UnboundedSender, + peer_game_db: Arc>, ) -> eyre::Result> { log::info!("Starting peer system with game directory: {game_dir}"); @@ -97,7 +98,7 @@ pub fn start_peer( // Start the peer in a background task let tx_control_clone = tx_control.clone(); tokio::spawn(async move { - if let Err(e) = run_peer(rx_control, tx_notify_ui).await { + if let Err(e) = run_peer(rx_control, tx_notify_ui, peer_game_db).await { log::error!("Peer system failed: {e}"); } }); @@ -342,6 +343,27 @@ impl PeerGameDB { seen.into_values().collect() } + #[must_use] + pub fn majority_game_size(&self, game_id: &str) -> Option { + let mut size_counts: HashMap = HashMap::new(); + + for peer in self.peers.values() { + if let Some(game) = peer.games.get(game_id) { + if game.size == 0 { + continue; + } + *size_counts.entry(game.size).or_insert(0) += 1; + } + } + + size_counts + .into_iter() + .max_by(|(size_a, count_a), (size_b, count_b)| { + count_a.cmp(count_b).then_with(|| size_a.cmp(size_b)) + }) + .map(|(size, _)| size) + } + /// Validates file sizes across all peers and returns only the files with majority consensus /// Returns a tuple of (`validated_files`, `peer_whitelist`, `file_peer_map`) where /// `peer_whitelist` contains peers that have at least one majority-approved file and @@ -1344,12 +1366,13 @@ impl std::fmt::Debug for PeerCtx { pub async fn run_peer( mut rx_control: UnboundedReceiver, tx_notify_ui: UnboundedSender, + peer_game_db: Arc>, ) -> eyre::Result<()> { // peer context let ctx = Ctx { game_dir: Arc::new(RwLock::new(None)), local_game_db: Arc::new(RwLock::new(None)), - peer_game_db: Arc::new(RwLock::new(PeerGameDB::new())), + peer_game_db: peer_game_db.clone(), local_peer_addr: Arc::new(RwLock::new(None)), downloading_games: Arc::new(RwLock::new(HashSet::new())), active_downloads: Arc::new(RwLock::new(HashMap::new())), diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml b/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml index 2fedac9..9844be4 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml +++ b/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml @@ -45,6 +45,7 @@ tauri-plugin-shell = { workspace = true } tauri-plugin-dialog = { workspace = true } tauri-plugin-store = { workspace = true } tokio = { workspace = true } +walkdir = { workspace = true } [target.'cfg(windows)'.dependencies] windows = { workspace = true } diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index d2ec1c6..bb6e38a 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -9,7 +9,7 @@ use std::{ use eyre::bail; use lanspread_compat::eti::get_games; use lanspread_db::db::{Game, GameDB}; -use lanspread_peer::{PeerCommand, PeerEvent, start_peer}; +use lanspread_peer::{PeerCommand, PeerEvent, PeerGameDB, start_peer}; use tauri::{AppHandle, Emitter as _, Manager}; use tauri_plugin_shell::{ShellExt, process::Command}; use tokio::sync::{RwLock, mpsc::UnboundedSender}; @@ -21,6 +21,8 @@ struct LanSpreadState { games: Arc>, games_in_download: Arc>>, games_folder: Arc>, + // Add access to peer game database for size calculations + peer_game_db: Arc>, } #[cfg(target_os = "windows")] @@ -425,6 +427,24 @@ fn update_game_installation_state(game: &mut Game, games_root: &Path) { let installed = downloaded && local_install_is_ready(&game_path); game.installed = installed; + // Calculate size from local files if available (highest priority) + if downloaded || installed { + match calculate_directory_size_sync(&game_path) { + Ok(local_size) => { + game.size = local_size; + log::debug!( + "Updated size for game {} from local files: {} bytes", + game.id, + local_size + ); + } + Err(e) => { + log::warn!("Failed to calculate local size for game {}: {e}", game.id); + // Keep the existing size (will fallback to peer/game.db later) + } + } + } + if installed { log::debug!("Set {game} to installed"); match lanspread_db::db::read_version_from_ini(&game_path) { @@ -450,6 +470,92 @@ fn update_game_installation_state(game: &mut Game, games_root: &Path) { } } +/// Synchronous version of `calculate_directory_size` for use in non-async contexts +fn calculate_directory_size_sync(dir: &Path) -> eyre::Result { + let mut total_size = 0u64; + + for entry in walkdir::WalkDir::new(dir) { + let entry = entry?; + let path = entry.path(); + + if path.is_file() { + let metadata = std::fs::metadata(path)?; + total_size += metadata.len(); + } + } + + Ok(total_size) +} + +/// Calculate total size from a list of file descriptions (used for peer majority calculation) +fn calculate_size_from_file_descriptions( + file_descriptions: &[lanspread_db::db::GameFileDescription], +) -> u64 { + file_descriptions + .iter() + .filter(|desc| !desc.is_dir) // Only count files, not directories + .map(|desc| desc.size) + .sum() +} + +/// Update game sizes from peer majority files when local files are not available +/// This implements the second priority: "we don't have the game but 1-n peers have. Use the majority files for that game, calculate a total size" +async fn update_game_sizes_from_peers( + games: &mut std::collections::HashMap, + peer_game_db: &Arc>, +) { + log::debug!("Updating game sizes from peer data where local files are not available"); + + let peer_db = peer_game_db.read().await; + + for game in games.values_mut() { + // Only update sizes for games that don't have local files + if !game.downloaded && !game.installed { + // Check if any peers have this game + let peer_files_for_game = peer_db.aggregated_game_files(&game.id); + + if peer_files_for_game.is_empty() { + if let Some(peer_size) = peer_db.majority_game_size(&game.id) { + if peer_size > 0 { + game.size = peer_size; + log::debug!( + "Updated size for game {} from peer-reported totals: {} bytes", + game.id, + peer_size + ); + } else { + log::debug!( + "Peer-reported size for game {} is 0; keeping previous value", + game.id + ); + } + } else { + log::debug!("No peer size data available for game {}", game.id); + // Keep existing size (will fallback to game.db sizes) + } + } else { + // Calculate size from peer majority files + let peer_size = calculate_size_from_file_descriptions(&peer_files_for_game); + + if peer_size > 0 { + game.size = peer_size; + log::debug!( + "Updated size for game {} from peer majority files: {} bytes (from {} files)", + game.id, + peer_size, + peer_files_for_game.len() + ); + } else { + log::debug!( + "Peer files for game {} exist but calculated size is 0", + game.id + ); + } + } + } + } +} + async fn refresh_games_list(app_handle: &AppHandle) { let state = app_handle.state::(); @@ -565,6 +671,10 @@ async fn update_game_db(games: Vec, app: AppHandle) { ); } } + + // Update game sizes from peer data (second priority) + // This will update sizes for games that don't have local files but are available from peers + update_game_sizes_from_peers(&mut game_db.games, &state.peer_game_db).await; } refresh_games_list(&app).await; @@ -686,11 +796,14 @@ pub fn run() { // channel to receive events from the peer let (tx_peer_event, mut rx_peer_event) = tokio::sync::mpsc::unbounded_channel::(); + let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new())); + let lanspread_state = LanSpreadState { peer_ctrl: Arc::new(RwLock::new(None)), games: Arc::new(RwLock::new(GameDB::empty())), games_in_download: Arc::new(RwLock::new(HashSet::new())), games_folder: Arc::new(RwLock::new(String::new())), + peer_game_db: peer_game_db.clone(), }; tauri::Builder::default() @@ -709,10 +822,13 @@ pub fn run() { .manage(lanspread_state) .setup({ let tx_peer_event_clone = tx_peer_event.clone(); + let peer_game_db_clone = peer_game_db.clone(); move |app| { // Initialize peer system ONLY when games directory is set (games directory is mandatory) // But the UI is responsive immediately - no blocking server discovery let app_handle_clone = app.handle().clone(); + let tx_peer_event_for_spawn = tx_peer_event_clone.clone(); + let peer_game_db_for_spawn = peer_game_db_clone.clone(); tauri::async_runtime::spawn(async move { // Wait for games directory to be set by user (this is mandatory) loop { @@ -752,7 +868,11 @@ pub fn run() { refresh_games_list(&app_handle_clone).await; // Only start peer system when we have a valid games directory - match start_peer(games_folder, tx_peer_event_clone) { + match start_peer( + games_folder, + tx_peer_event_for_spawn.clone(), + peer_game_db_for_spawn.clone(), + ) { Ok(peer_ctrl) => { let state = app_handle_clone.state::(); *state.peer_ctrl.write().await = Some(peer_ctrl);