From 53c7fe10ba30c220ee7e126385a71f8eb4ec18bf Mon Sep 17 00:00:00 2001 From: ddidderr Date: Fri, 28 Nov 2025 21:10:42 +0100 Subject: [PATCH] refactor (Opus 4.5): modularize and split --- CLAUDE.md | 198 ++ crates/lanspread-peer/src/config.rs | 30 + crates/lanspread-peer/src/context.rs | 74 + crates/lanspread-peer/src/download.rs | 690 ++++++ crates/lanspread-peer/src/error.rs | 43 + crates/lanspread-peer/src/handlers.rs | 378 +++ crates/lanspread-peer/src/lib.rs | 2847 +--------------------- crates/lanspread-peer/src/local_games.rs | 281 +++ crates/lanspread-peer/src/network.rs | 256 ++ crates/lanspread-peer/src/peer_db.rs | 502 ++++ crates/lanspread-peer/src/services.rs | 731 ++++++ 11 files changed, 3301 insertions(+), 2729 deletions(-) create mode 100644 CLAUDE.md create mode 100644 crates/lanspread-peer/src/config.rs create mode 100644 crates/lanspread-peer/src/context.rs create mode 100644 crates/lanspread-peer/src/download.rs create mode 100644 crates/lanspread-peer/src/error.rs create mode 100644 crates/lanspread-peer/src/handlers.rs create mode 100644 crates/lanspread-peer/src/local_games.rs create mode 100644 crates/lanspread-peer/src/network.rs create mode 100644 crates/lanspread-peer/src/peer_db.rs create mode 100644 crates/lanspread-peer/src/services.rs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..be4feef --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,198 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**LanSpread2** is a peer-to-peer (P2P) game distribution system with a Tauri desktop application frontend. It enables users to share games over local networks using mDNS discovery and QUIC protocol for peer communication. + +## Common Development Commands + +### Build + +```bash +# Development (opens UI, needs user interaction) +cargo tauri dev + +# Debug build for very quick testing (without bundle) +cargo tauri build --debug --no-bundle + +# Relase build for testing (without bundling) +cargo tauri build --no-bundle + +# Production (with bundling) +cargo tauri build -- --profile release-lto +``` + +### Build Profiles + +- `release`: Default release profile with debug info, assertions enabled, overflow checks on, for testing +- `release-lto`: Optimized for distribution with LTO enabled, debug symbols stripped, smaller binary, only for production + +### Code Quality + +```bash +# Lint all code +cargo clippy + +# Format code (nightly Rust) +cargo +nightly fmt +``` + +### Testing + +```bash +# Run all tests +cargo test --all + +# Run tests for a specific crate +cargo test -p lanspread-peer + +# Run a specific test +cargo test -p lanspread-peer test_name + +# Run tests with output +cargo test -- --nocapture +``` + +### Dependencies + +```bash +# Update frontend dependencies (Deno) +deno outdated --update --latest +``` + +## Architecture + +LanSpread follows a layered, modular architecture with clear separation of concerns: + +### Crate Structure + +The project is organized as a Cargo workspace with 7 crates: + +**Foundation Layer:** +- **lanspread-utils**: Utility macros and helpers used across all crates +- **lanspread-db**: Core data structures (`Game`, `GameFileDescription`, game metadata) + +**Protocol & Communication:** +- **lanspread-proto**: P2P communication protocol definitions + - Message types: `Request` (Ping, ListGames, GetGame, etc.), `Response`, `Message` trait + - Serialization: JSON with `serde` + +**Network Discovery & Compatibility:** +- **lanspread-mdns**: mDNS service discovery and advertisement + - Advertises and discovers "_lanspread._udp.local." services on LAN + - Uses `mdns-sd` crate + +- **lanspread-compat**: Compatibility layer for legacy ETI game database format + - Reads legacy `game.db` SQLite databases + - Converts `EtiGame` structs to modern `Game` struct + +**Core P2P Engine:** +- **lanspread-peer**: (~97KB) Central orchestration for all P2P functionality + - QUIC-based networking with length-delimited frame codec (`tokio-util`) + - Manages peer-to-peer connections and game synchronization + - Handles file streaming with chunk transfers and path validation + - Communicates via unbounded channels (`PeerEvent`, `PeerCommand`) + - Implements retry logic for requesting games from peers + - Entry point: `start_peer()` function + +**Application Layer:** +- **lanspread-tauri-deno-ts**: Tauri desktop application + - Binary + Library crate for the UI layer + - IPC commands: `request_games`, `install_game`, etc. + - Manages `LanSpreadState` for peer controller, game DB, and downloads + - Uses Tauri plugins: logging, shell, dialogs, persistent storage + - Allocates MiMalloc for memory efficiency + +### Data Flow + +``` +User (Tauri UI) + ↓ IPC Commands (request_games, install_game) + ↓ +lanspread-tauri-deno-ts (app layer) + ↓ +lanspread-peer (P2P engine) + ├─ lanspread-mdns (discovers peers via mDNS) + ├─ lanspread-proto (constructs P2P messages) + ├─ lanspread-compat (reads legacy game databases) + └─ QUIC Network ↔ Other Peers + ↓ +lanspread-db (game data structures) + ↓ +Result → Back to UI +``` + +### Key Architectural Details + +1. **Async/Await**: Tokio runtime throughout for non-blocking I/O +2. **Message Passing**: Unbounded channels for inter-component communication (events and commands) +3. **Protocol**: Length-delimited QUIC frames with JSON serialization +4. **Service Discovery**: Automatic mDNS announcement and discovery of peers +5. **File Streaming**: Chunked transfer with path validation for security +6. **Legacy Support**: Backwards compatibility with ETI game database format +7. **Error Handling**: Custom error types via `eyre` crate + +## Development Practices + +### From AGENTS.md + +- Always check code with `cargo clippy` and fix any issues +- Always format with `cargo +nightly fmt` after completing changes +- Use appropriate Rust log levels when debugging: + - `RUST_LOG=lanspread=debug` for module-specific debugging + - `RUST_LOG=info,lanspread=debug` for general info + module debug + +### Performance Considerations + +- Use `release-lto` profile for final builds to enable Link-Time Optimization +- MiMalloc is used for the Tauri app to reduce memory overhead +- QUIC protocol chosen for efficient P2P communication +- Length-delimited framing reduces message parsing overhead + +## Important Implementation Notes + +### Logging + +The codebase uses `tracing` and `log` crates. Note: There's documented uncertainty about their exact relationship (see LESSONS_LEARNED.md). The client app uses `log` crate; be consistent with whichever is used in the component you're modifying. + +### Legacy Database Support + +Some users may have old ETI format game databases. The `lanspread-compat` crate handles reading these. When querying games, both modern and legacy formats are considered. + +### Path Validation + +File transfers validate paths to prevent directory traversal attacks. All file operations use validated paths from `lanspread-peer`. + +### Retry Logic + +`lanspread-peer` includes retry logic for requesting games from peers. This handles transient network failures in P2P discovery. + +## Known Design Decisions + +**Why not Tauri + Leptos?** Leptos adds unnecessary complexity. Tauri is designed to transfer backend Rust to frontend JavaScript world, but with Leptos the frontend becomes Rust, creating a double translation. The current Tauri + Deno/TypeScript approach is cleaner. + +**Why Tauri?** Simple setup, easy development with `cargo tauri dev`, easy testing and bundling with installers, small final binary (~13MB). + +## File Organization + +``` +crates/ +├── lanspread-db/ # Core data models +├── lanspread-utils/ # Macros & utilities +├── lanspread-proto/ # Protocol definitions +├── lanspread-compat/ # Legacy ETI compatibility +├── lanspread-mdns/ # mDNS discovery +├── lanspread-peer/ # P2P engine (largest, most complex) +└── lanspread-tauri-deno-ts/ + ├── src-tauri/ # Rust Tauri backend + └── (sibling dirs) # Deno/TypeScript frontend code +``` + +## Additional Resources + +- **README.md**: Build and development prerequisites +- **LESSONS_LEARNED.md**: Architectural decisions and trade-offs +- **AGENTS.md**: Code quality guidelines +- **TODO.md**: Tracked work items diff --git a/crates/lanspread-peer/src/config.rs b/crates/lanspread-peer/src/config.rs new file mode 100644 index 0000000..7ff9521 --- /dev/null +++ b/crates/lanspread-peer/src/config.rs @@ -0,0 +1,30 @@ +//! Configuration constants for the peer system. + +use std::time::Duration; + +/// Interval between peer ping checks (seconds). +pub const PEER_PING_INTERVAL_SECS: u64 = 5; + +/// Timeout after which a peer is considered stale (seconds). +pub const PEER_STALE_TIMEOUT_SECS: u64 = 12; + +/// Size of each download chunk (32 MB). +pub const CHUNK_SIZE: u64 = 32 * 1024 * 1024; + +/// Maximum number of retry attempts for failed chunk downloads. +pub const MAX_RETRY_COUNT: usize = 3; + +/// Interval for local game directory monitoring (seconds). +pub const LOCAL_GAME_MONITOR_INTERVAL_SECS: u64 = 5; + +/// TLS certificate for QUIC connections. +pub static CERT_PEM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../cert.pem")); + +/// TLS private key for QUIC connections. +pub static KEY_PEM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../key.pem")); + +/// Returns the peer stale timeout as a Duration. +#[must_use] +pub fn peer_stale_timeout() -> Duration { + Duration::from_secs(PEER_STALE_TIMEOUT_SECS) +} diff --git a/crates/lanspread-peer/src/context.rs b/crates/lanspread-peer/src/context.rs new file mode 100644 index 0000000..1abede0 --- /dev/null +++ b/crates/lanspread-peer/src/context.rs @@ -0,0 +1,74 @@ +//! Shared context types for the peer system. + +use std::{ + collections::{HashMap, HashSet}, + net::SocketAddr, + sync::Arc, +}; + +use lanspread_db::db::GameDB; +use tokio::{sync::RwLock, task::JoinHandle}; + +use crate::{PeerEvent, peer_db::PeerGameDB}; + +/// Main context for the peer system. +#[derive(Clone)] +pub struct Ctx { + pub game_dir: Arc>>, + pub local_game_db: Arc>>, + pub peer_game_db: Arc>, + pub local_peer_addr: Arc>>, + pub downloading_games: Arc>>, + pub active_downloads: Arc>>>, +} + +/// Context for peer connection handling. +#[derive(Clone)] +pub struct PeerCtx { + pub game_dir: Arc>>, + pub local_game_db: Arc>>, + pub local_peer_addr: Arc>>, + pub downloading_games: Arc>>, + pub peer_game_db: Arc>, + pub tx_notify_ui: tokio::sync::mpsc::UnboundedSender, +} + +impl std::fmt::Debug for PeerCtx { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PeerCtx") + .field("game_dir", &"...") + .field("local_game_db", &"...") + .field("local_peer_addr", &"...") + .field("downloading_games", &"...") + .finish() + } +} + +impl Ctx { + /// Creates a new context with the given peer game database. + pub fn new(peer_game_db: Arc>) -> Self { + Self { + game_dir: Arc::new(RwLock::new(None)), + local_game_db: Arc::new(RwLock::new(None)), + peer_game_db, + local_peer_addr: Arc::new(RwLock::new(None)), + downloading_games: Arc::new(RwLock::new(HashSet::new())), + active_downloads: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Creates a `PeerCtx` from this context. + pub fn to_peer_ctx( + &self, + tx_notify_ui: tokio::sync::mpsc::UnboundedSender, + ) -> PeerCtx { + PeerCtx { + game_dir: self.game_dir.clone(), + local_game_db: self.local_game_db.clone(), + local_peer_addr: self.local_peer_addr.clone(), + downloading_games: self.downloading_games.clone(), + peer_game_db: self.peer_game_db.clone(), + tx_notify_ui, + } + } +} diff --git a/crates/lanspread-peer/src/download.rs b/crates/lanspread-peer/src/download.rs new file mode 100644 index 0000000..b52b6a0 --- /dev/null +++ b/crates/lanspread-peer/src/download.rs @@ -0,0 +1,690 @@ +//! Download pipeline for game files from peers. + +use std::{ + collections::{HashMap, VecDeque}, + net::SocketAddr, + path::{Path, PathBuf}, +}; + +use lanspread_db::db::GameFileDescription; +use tokio::{ + fs::OpenOptions, + io::{AsyncSeekExt, AsyncWriteExt}, + sync::mpsc::UnboundedSender, +}; +use tokio_util::codec::{FramedWrite, LengthDelimitedCodec}; + +use crate::{ + PeerEvent, + config::{CHUNK_SIZE, MAX_RETRY_COUNT}, + network::connect_to_peer, + path_validation::validate_game_file_path, +}; + +// ============================================================================= +// Download data structures +// ============================================================================= + +/// Represents a chunk of a file to be downloaded. +#[derive(Debug, Clone)] +pub struct DownloadChunk { + pub relative_path: String, + pub offset: u64, + pub length: u64, + pub retry_count: usize, + pub last_peer: Option, +} + +/// Download plan for a single peer. +#[derive(Debug, Default)] +pub struct PeerDownloadPlan { + pub chunks: Vec, + pub whole_files: Vec, +} + +/// Result of downloading a chunk. +#[derive(Debug)] +pub struct ChunkDownloadResult { + pub chunk: DownloadChunk, + pub result: eyre::Result<()>, + pub peer_addr: SocketAddr, +} + +// ============================================================================= +// Storage preparation +// ============================================================================= + +/// Prepares storage for game files by creating directories and pre-allocating files. +pub async fn prepare_game_storage( + games_folder: &Path, + file_descs: &[GameFileDescription], +) -> eyre::Result<()> { + for desc in file_descs { + // Validate the path to prevent directory traversal + let validated_path = validate_game_file_path(games_folder, &desc.relative_path)?; + + if desc.is_dir { + tokio::fs::create_dir_all(&validated_path).await?; + } else { + if let Some(parent) = validated_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + // Create and pre-allocate the file with the expected size + let file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&validated_path) + .await?; + + // Pre-allocate the file with the expected size + let size = desc.size; + if let Err(e) = file.set_len(size).await { + log::warn!( + "Failed to pre-allocate file {} (size: {}): {}", + desc.relative_path, + size, + e + ); + // Continue without pre-allocation - the file will grow as chunks are written + } else { + log::debug!( + "Pre-allocated file {} with {} bytes", + desc.relative_path, + size + ); + } + } + } + Ok(()) +} + +// ============================================================================= +// Peer plan building +// ============================================================================= + +/// Resolves which peers have a specific file. +pub fn resolve_file_peers<'a>( + relative_path: &str, + file_peer_map: &'a HashMap>, + fallback: &'a [SocketAddr], +) -> &'a [SocketAddr] { + if let Some(peers) = file_peer_map.get(relative_path) + && !peers.is_empty() + { + return peers; + } + + fallback +} + +/// Builds download plans distributing files across peers. +pub fn build_peer_plans( + peers: &[SocketAddr], + file_descs: &[GameFileDescription], + file_peer_map: &HashMap>, +) -> HashMap { + let mut plans: HashMap = HashMap::new(); + if peers.is_empty() { + return plans; + } + + let mut peer_index = 0usize; + + for desc in file_descs.iter().filter(|d| !d.is_dir) { + let size = desc.file_size(); + let eligible_peers = resolve_file_peers(&desc.relative_path, file_peer_map, peers); + if eligible_peers.is_empty() { + continue; + } + + if size == 0 { + let peer = eligible_peers[peer_index % eligible_peers.len()]; + peer_index += 1; + plans.entry(peer).or_default().chunks.push(DownloadChunk { + relative_path: desc.relative_path.clone(), + offset: 0, + length: 0, + retry_count: 0, + last_peer: Some(peer), + }); + continue; + } + + let mut offset = 0u64; + while offset < size { + let length = std::cmp::min(CHUNK_SIZE, size - offset); + let peer = eligible_peers[peer_index % eligible_peers.len()]; + peer_index += 1; + plans.entry(peer).or_default().chunks.push(DownloadChunk { + relative_path: desc.relative_path.clone(), + offset, + length, + retry_count: 0, + last_peer: Some(peer), + }); + offset += length; + } + } + + plans +} + +// ============================================================================= +// Chunk downloading +// ============================================================================= + +/// Downloads a single chunk from a peer. +pub async fn download_chunk( + conn: &mut s2n_quic::Connection, + base_dir: &Path, + game_id: &str, + chunk: &DownloadChunk, +) -> eyre::Result<()> { + use futures::SinkExt; + use lanspread_proto::{Message, Request}; + + let stream = conn.open_bidirectional_stream().await?; + let (mut rx, tx) = stream.split(); + let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); + + let request = Request::GetGameFileChunk { + game_id: game_id.to_string(), + relative_path: chunk.relative_path.clone(), + offset: chunk.offset, + length: chunk.length, + }; + framed_tx.send(request.encode()).await?; + + framed_tx.close().await?; + + // Validate the path to prevent directory traversal + let validated_path = validate_game_file_path(base_dir, &chunk.relative_path)?; + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&validated_path) + .await?; + if chunk.length == 0 && chunk.offset == 0 { + // fallback-to-whole-file path replaces any existing partial data + file.set_len(0).await?; + } + file.seek(std::io::SeekFrom::Start(chunk.offset)).await?; + + let mut remaining = chunk.length; + let mut received_bytes = 0u64; + + while let Some(bytes) = rx.receive().await? { + file.write_all(&bytes).await?; + received_bytes += bytes.len() as u64; + + if remaining == 0 { + continue; + } + remaining = remaining.saturating_sub(bytes.len() as u64); + if remaining == 0 { + break; + } + } + + // Verify we received the expected amount of data + if chunk.length > 0 && received_bytes != chunk.length { + eyre::bail!( + "Incomplete chunk download: expected {} bytes, received {} bytes for file {} at offset {}", + chunk.length, + received_bytes, + chunk.relative_path, + chunk.offset + ); + } + + file.flush().await?; + + // Verify file integrity by checking the file size + verify_chunk_integrity(&validated_path, chunk.offset, chunk.length).await?; + + Ok(()) +} + +/// Verifies that a chunk was written correctly. +async fn verify_chunk_integrity( + file_path: &Path, + offset: u64, + expected_length: u64, +) -> eyre::Result<()> { + if expected_length == 0 { + return Ok(()); // Skip verification for whole files or zero-length chunks + } + + let metadata = tokio::fs::metadata(file_path).await?; + let file_size = metadata.len(); + + if file_size < offset + expected_length { + eyre::bail!( + "File integrity check failed: file size {} is less than expected {} (offset: {})", + file_size, + offset + expected_length, + offset + ); + } + + Ok(()) +} + +/// Downloads a whole file from a peer. +pub async fn download_whole_file( + conn: &mut s2n_quic::Connection, + base_dir: &Path, + desc: &GameFileDescription, +) -> eyre::Result<()> { + use futures::SinkExt; + use lanspread_proto::{Message, Request}; + + let stream = conn.open_bidirectional_stream().await?; + let (mut rx, tx) = stream.split(); + let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); + + framed_tx + .send(Request::GetGameFileData(desc.clone()).encode()) + .await?; + framed_tx.close().await?; + + // Validate the path to prevent directory traversal + let validated_path = validate_game_file_path(base_dir, &desc.relative_path)?; + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&validated_path) + .await?; + file.seek(std::io::SeekFrom::Start(0)).await?; + + while let Some(bytes) = rx.receive().await? { + file.write_all(&bytes).await?; + } + + file.flush().await?; + Ok(()) +} + +/// Downloads all assigned chunks and files from a single peer. +pub async fn download_from_peer( + peer_addr: SocketAddr, + game_id: &str, + plan: PeerDownloadPlan, + games_folder: PathBuf, +) -> eyre::Result> { + if plan.chunks.is_empty() && plan.whole_files.is_empty() { + return Ok(Vec::new()); + } + + let mut conn = connect_to_peer(peer_addr).await?; + conn.keep_alive(true)?; + conn.keep_alive(true)?; + + let base_dir = games_folder; + let mut results = Vec::new(); + + // Download chunks with error handling + for chunk in &plan.chunks { + log::info!( + "Downloading chunk {} (offset {}, length {}) from {}", + chunk.relative_path, + chunk.offset, + chunk.length, + peer_addr + ); + let result = download_chunk(&mut conn, &base_dir, game_id, chunk).await; + results.push(ChunkDownloadResult { + chunk: chunk.clone(), + result, + peer_addr, + }); + } + + // Download whole files + for desc in &plan.whole_files { + let chunk = DownloadChunk { + relative_path: desc.relative_path.clone(), + offset: 0, + length: 0, // Indicates whole file + retry_count: 0, + last_peer: Some(peer_addr), + }; + + let result = download_whole_file(&mut conn, &base_dir, desc).await; + results.push(ChunkDownloadResult { + chunk, + result, + peer_addr, + }); + } + + Ok(results) +} + +// ============================================================================= +// Retry logic +// ============================================================================= + +/// Selects a peer for retrying a failed chunk. +fn select_retry_peer( + peers: &[SocketAddr], + last_peer: Option, + attempt_offset: usize, +) -> Option { + if peers.is_empty() { + return None; + } + + if peers.len() > 1 + && let Some(last) = last_peer + && let Some(pos) = peers.iter().position(|addr| *addr == last) + { + let next_index = (pos + 1 + attempt_offset) % peers.len(); + return Some(peers[next_index]); + } + + Some(peers[attempt_offset % peers.len()]) +} + +/// Returns a fallback peer address for error reporting. +fn fallback_peer_addr(peers: &[SocketAddr], last_peer: Option) -> SocketAddr { + last_peer + .or_else(|| peers.first().copied()) + .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 0))) +} + +/// Retries downloading failed chunks. +pub async fn retry_failed_chunks( + failed_chunks: Vec, + peers: &[SocketAddr], + base_dir: &Path, + game_id: &str, + file_peer_map: &HashMap>, +) -> Vec { + let mut exhausted = Vec::new(); + let mut queue: VecDeque = failed_chunks.into_iter().collect(); + + while let Some(mut chunk) = queue.pop_front() { + let eligible_peers = resolve_file_peers(&chunk.relative_path, file_peer_map, peers); + + if chunk.retry_count >= MAX_RETRY_COUNT { + exhausted.push(ChunkDownloadResult { + chunk: chunk.clone(), + result: Err(eyre::eyre!( + "Retry budget exhausted for chunk: {}", + chunk.relative_path + )), + peer_addr: fallback_peer_addr(eligible_peers, chunk.last_peer), + }); + continue; + } + + let retry_offset = chunk.retry_count.saturating_sub(1); + let Some(peer_addr) = select_retry_peer(eligible_peers, chunk.last_peer, retry_offset) + else { + exhausted.push(ChunkDownloadResult { + chunk: chunk.clone(), + result: Err(eyre::eyre!( + "No peers available to retry chunk: {}", + chunk.relative_path + )), + peer_addr: fallback_peer_addr(eligible_peers, chunk.last_peer), + }); + continue; + }; + + let mut attempt_chunk = chunk.clone(); + attempt_chunk.last_peer = Some(peer_addr); + + let plan = PeerDownloadPlan { + chunks: vec![attempt_chunk.clone()], + whole_files: Vec::new(), + }; + + match download_from_peer(peer_addr, game_id, plan, base_dir.to_path_buf()).await { + Ok(results) => { + for result in results { + match result.result { + Ok(()) => {} + Err(e) => { + let mut retry_chunk = result.chunk.clone(); + retry_chunk.retry_count = chunk.retry_count + 1; + retry_chunk.last_peer = Some(result.peer_addr); + + if retry_chunk.retry_count >= MAX_RETRY_COUNT { + let context = format!( + "Retry budget exhausted for chunk: {}", + result.chunk.relative_path + ); + exhausted.push(ChunkDownloadResult { + chunk: retry_chunk, + result: Err(e.wrap_err(context)), + peer_addr: result.peer_addr, + }); + } else { + queue.push_back(retry_chunk); + } + } + } + } + } + Err(e) => { + chunk.retry_count += 1; + chunk.last_peer = Some(peer_addr); + + if chunk.retry_count >= MAX_RETRY_COUNT { + exhausted.push(ChunkDownloadResult { + chunk: chunk.clone(), + result: Err(e.wrap_err(format!( + "Retry budget exhausted for chunk after connection failure: {}", + chunk.relative_path + ))), + peer_addr: fallback_peer_addr(eligible_peers, chunk.last_peer), + }); + } else { + queue.push_back(chunk); + } + } + } + } + + exhausted +} + +// ============================================================================= +// Main download orchestration +// ============================================================================= + +/// Downloads all game files from available peers. +pub async fn download_game_files( + game_id: &str, + game_file_descs: Vec, + games_folder: String, + peers: Vec, + file_peer_map: HashMap>, + tx_notify_ui: UnboundedSender, +) -> eyre::Result<()> { + if peers.is_empty() { + eyre::bail!("no peers available for game {game_id}"); + } + + let base_dir = PathBuf::from(&games_folder); + prepare_game_storage(&base_dir, &game_file_descs).await?; + + tx_notify_ui.send(PeerEvent::DownloadGameFilesBegin { + id: game_id.to_string(), + })?; + + let plans = build_peer_plans(&peers, &game_file_descs, &file_peer_map); + + let mut tasks = Vec::new(); + for (peer_addr, plan) in plans { + let base_dir = base_dir.clone(); + let game_id = game_id.to_string(); + tasks.push(tokio::spawn(async move { + download_from_peer(peer_addr, &game_id, plan, base_dir).await + })); + } + + let mut failed_chunks: Vec = Vec::new(); + let mut last_err: Option = None; + + for handle in tasks { + match handle.await { + Ok(Ok(results)) => { + for chunk_result in results { + if let Err(e) = chunk_result.result { + log::warn!( + "Failed to download chunk from {}: {e}", + chunk_result.peer_addr + ); + if chunk_result.chunk.retry_count < MAX_RETRY_COUNT { + let mut retry_chunk = chunk_result.chunk; + retry_chunk.retry_count += 1; + retry_chunk.last_peer = Some(chunk_result.peer_addr); + failed_chunks.push(retry_chunk); + } else { + last_err = Some(eyre::eyre!( + "Max retries exceeded for chunk: {}", + chunk_result.chunk.relative_path + )); + } + } + } + } + Ok(Err(e)) => last_err = Some(e), + Err(e) => last_err = Some(eyre::eyre!("task join error: {e}")), + } + } + + // Retry failed chunks if any + if !failed_chunks.is_empty() && !peers.is_empty() { + log::info!("Retrying {} failed chunks", failed_chunks.len()); + + let retry_results = + retry_failed_chunks(failed_chunks, &peers, &base_dir, game_id, &file_peer_map).await; + + for chunk_result in retry_results { + if let Err(e) = chunk_result.result { + log::error!("Retry failed for chunk: {e}"); + last_err = Some(e); + } + } + } + + if let Some(err) = last_err { + tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { + id: game_id.to_string(), + })?; + return Err(err); + } + + log::info!("all files downloaded for game: {game_id}"); + tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished { + id: game_id.to_string(), + })?; + Ok(()) +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + fn loopback_addr(port: u16) -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], port)) + } + + #[test] + fn build_peer_plans_handles_partial_final_chunk() { + let peers = vec![loopback_addr(12000), loopback_addr(12001)]; + let file_size = CHUNK_SIZE * 2 + CHUNK_SIZE / 4; + let mut file_peer_map = HashMap::new(); + file_peer_map.insert("game/file.dat".to_string(), peers.clone()); + let file_descs = vec![GameFileDescription { + game_id: "test".to_string(), + relative_path: "game/file.dat".to_string(), + is_dir: false, + size: file_size, + }]; + + let plans = build_peer_plans(&peers, &file_descs, &file_peer_map); + let mut chunks: Vec<_> = plans.values().flat_map(|plan| plan.chunks.iter()).collect(); + + assert_eq!(chunks.len(), 3, "expected three chunks for 2.25 blocks"); + + chunks.sort_by_key(|chunk| chunk.offset); + let last_chunk = chunks.last().expect("last chunk exists"); + + assert_eq!(last_chunk.offset, CHUNK_SIZE * 2); + assert_eq!(last_chunk.length, file_size - last_chunk.offset); + assert_eq!(last_chunk.length, CHUNK_SIZE / 4); + assert_eq!( + last_chunk.offset + last_chunk.length, + file_size, + "last chunk should finish the file" + ); + } + + #[test] + fn build_peer_plans_respects_file_peer_map() { + let shared_a = loopback_addr(12010); + let shared_b = loopback_addr(12011); + let exclusive = loopback_addr(12012); + let peers = vec![shared_a, shared_b, exclusive]; + + let mut file_peer_map = HashMap::new(); + file_peer_map.insert("shared.bin".to_string(), vec![shared_a, shared_b]); + file_peer_map.insert("exclusive.bin".to_string(), vec![exclusive]); + + let file_descs = vec![ + GameFileDescription { + game_id: "test".to_string(), + relative_path: "shared.bin".to_string(), + is_dir: false, + size: CHUNK_SIZE * 2, + }, + GameFileDescription { + game_id: "test".to_string(), + relative_path: "exclusive.bin".to_string(), + is_dir: false, + size: CHUNK_SIZE, + }, + ]; + + let plans = build_peer_plans(&peers, &file_descs, &file_peer_map); + let exclusive_plan = plans + .get(&exclusive) + .expect("exclusive peer should have a plan"); + assert!( + exclusive_plan + .chunks + .iter() + .all(|chunk| chunk.relative_path == "exclusive.bin"), + "exclusive peer should only receive exclusive.bin chunks" + ); + + for (peer, plan) in plans { + for chunk in plan.chunks { + match chunk.relative_path.as_str() { + "exclusive.bin" => assert_eq!( + peer, exclusive, + "exclusive.bin chunks should only be assigned to the exclusive peer" + ), + "shared.bin" => assert!( + peer == shared_a || peer == shared_b, + "shared.bin chunks must stay within shared peers" + ), + other => panic!("unexpected file in plan: {other}"), + } + } + } + } +} diff --git a/crates/lanspread-peer/src/error.rs b/crates/lanspread-peer/src/error.rs new file mode 100644 index 0000000..28c5cc0 --- /dev/null +++ b/crates/lanspread-peer/src/error.rs @@ -0,0 +1,43 @@ +//! Error types for peer operations. + +/// Custom error types for peer operations. +#[derive(Debug)] +pub enum PeerError { + /// Failed to determine the size of a file. + FileSizeDetermination { + path: String, + source: std::io::Error, + }, + /// Game directory has not been configured. + GameDirNotSet, + /// General error wrapper. + Other(eyre::Report), +} + +impl std::fmt::Display for PeerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PeerError::FileSizeDetermination { path, source } => { + write!(f, "Failed to determine file size for {path}: {source}") + } + PeerError::GameDirNotSet => write!(f, "Game directory not set"), + PeerError::Other(err) => write!(f, "General error: {err}"), + } + } +} + +impl std::error::Error for PeerError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + PeerError::FileSizeDetermination { source, .. } => Some(source), + PeerError::Other(err) => Some(err.root_cause()), + PeerError::GameDirNotSet => None, + } + } +} + +impl From for PeerError { + fn from(err: eyre::Report) -> Self { + PeerError::Other(err) + } +} diff --git a/crates/lanspread-peer/src/handlers.rs b/crates/lanspread-peer/src/handlers.rs new file mode 100644 index 0000000..3336aa2 --- /dev/null +++ b/crates/lanspread-peer/src/handlers.rs @@ -0,0 +1,378 @@ +//! Command handlers for peer commands. + +use std::{collections::HashSet, net::SocketAddr, sync::Arc}; + +use lanspread_db::db::{Game, GameDB, GameFileDescription}; +use tokio::sync::{RwLock, mpsc::UnboundedSender}; + +use crate::{ + PeerEvent, + context::Ctx, + download::download_game_files, + local_games::{get_game_file_descriptions, load_local_game_db, local_download_available}, + network::{announce_games_to_peer, request_game_details_from_peer}, + peer_db::PeerGameDB, +}; + +// ============================================================================= +// Command handlers +// ============================================================================= + +/// Handles the `ListGames` command. +pub async fn handle_list_games_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender) { + log::info!("ListGames command received"); + emit_peer_game_list(&ctx.peer_game_db, tx_notify_ui).await; +} + +/// Emits the aggregated game list to the UI. +pub async fn emit_peer_game_list( + peer_game_db: &Arc>, + tx_notify_ui: &UnboundedSender, +) { + let all_games = { peer_game_db.read().await.get_all_games() }; + if let Err(e) = tx_notify_ui.send(PeerEvent::ListGames(all_games)) { + log::error!("Failed to send ListGames event: {e}"); + } +} + +/// Tries to serve a game from local files. +async fn try_serve_local_game( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + id: &str, +) -> bool { + let game_dir = { ctx.game_dir.read().await.clone() }; + let Some(game_dir) = game_dir else { + return false; + }; + + let downloading = ctx.downloading_games.read().await; + if !local_download_available(&game_dir, id, &downloading).await { + return false; + } + drop(downloading); + + match get_game_file_descriptions(id, &game_dir).await { + Ok(file_descriptions) => { + log::info!("Serving game {id} from local files"); + if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles { + id: id.to_string(), + file_descriptions, + }) { + log::error!("Failed to send GotGameFiles event: {e}"); + } + true + } + Err(e) => { + log::error!("Failed to enumerate local file descriptions for {id}: {e}"); + false + } + } +} + +/// Handles the `GetGame` command. +pub async fn handle_get_game_command( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + id: String, +) { + if try_serve_local_game(ctx, tx_notify_ui, &id).await { + return; + } + + log::info!("Requesting game from peers: {id}"); + let peers = { ctx.peer_game_db.read().await.peers_with_game(&id) }; + if peers.is_empty() { + log::warn!("No peers have game {id}"); + if let Err(e) = tx_notify_ui.send(PeerEvent::NoPeersHaveGame { id: id.clone() }) { + log::error!("Failed to send NoPeersHaveGame event: {e}"); + } + return; + } + + let peer_game_db = ctx.peer_game_db.clone(); + let tx_notify_ui = tx_notify_ui.clone(); + tokio::spawn(async move { + let mut fetched_any = false; + for peer_addr in peers { + match request_game_details_and_update(peer_addr, &id, peer_game_db.clone()).await { + Ok(_) => { + log::info!("Fetched game file list for {id} from peer {peer_addr}"); + fetched_any = true; + } + Err(e) => { + log::error!("Failed to fetch game files for {id} from {peer_addr}: {e}"); + } + } + } + + if fetched_any { + let aggregated_files = { peer_game_db.read().await.aggregated_game_files(&id) }; + + if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles { + id: id.clone(), + file_descriptions: aggregated_files, + }) { + log::error!("Failed to send GotGameFiles event: {e}"); + } + } else { + log::warn!("Failed to retrieve game files for {id} from any peer"); + } + }); +} + +/// Requests game details from a peer and updates the peer game database. +async fn request_game_details_and_update( + peer_addr: SocketAddr, + game_id: &str, + peer_game_db: Arc>, +) -> eyre::Result> { + let (file_descriptions, _) = request_game_details_from_peer(peer_addr, game_id).await?; + + { + let mut db = peer_game_db.write().await; + db.update_peer_game_files(peer_addr, game_id, file_descriptions.clone()); + } + + Ok(file_descriptions) +} + +/// Handles the `DownloadGameFiles` command. +#[allow(clippy::too_many_lines)] +pub async fn handle_download_game_files_command( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + id: String, + file_descriptions: Vec, +) { + log::info!("Got PeerCommand::DownloadGameFiles"); + let games_folder = { ctx.game_dir.read().await.clone() }; + if games_folder.is_none() { + log::error!("Cannot handle game file descriptions: games_folder is not set"); + return; + } + + let games_folder = games_folder.expect("checked above"); + + // Use majority validation to get trusted file descriptions and peer whitelist + let (validated_descriptions, peer_whitelist, file_peer_map) = { + match ctx + .peer_game_db + .read() + .await + .validate_file_sizes_majority(&id) + { + Ok((files, peers, file_peer_map)) => { + log::info!( + "Majority validation: {} validated files, {} trusted peers for game {id}", + files.len(), + peers.len() + ); + (files, peers, file_peer_map) + } + Err(e) => { + log::error!("File size majority validation failed for {id}: {e}"); + if let Err(send_err) = + tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() }) + { + log::error!("Failed to send DownloadGameFilesFailed event: {send_err}"); + } + return; + } + } + }; + + let resolved_descriptions = if file_descriptions.is_empty() { + validated_descriptions + } else { + // If user provided specific descriptions, still validate them against majority + // but keep user's selection (they might want specific files) + file_descriptions + }; + + if resolved_descriptions.is_empty() { + log::error!( + "No validated file descriptions available to download game {id}; request metadata first" + ); + return; + } + + let downloading = ctx.downloading_games.read().await; + if peer_whitelist.is_empty() { + if local_download_available(&games_folder, &id, &downloading).await { + drop(downloading); + log::info!("Using locally downloaded files for game {id}; skipping peer transfer"); + if let Err(e) = tx_notify_ui.send(PeerEvent::DownloadGameFilesBegin { id: id.clone() }) + { + log::error!("Failed to send DownloadGameFilesBegin event: {e}"); + } + if let Err(e) = + tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished { id: id.clone() }) + { + log::error!("Failed to send DownloadGameFilesFinished event: {e}"); + } + } else { + log::error!("No trusted peers available after majority validation for game {id}"); + } + return; + } + drop(downloading); + + { + let mut in_progress = ctx.downloading_games.write().await; + if !in_progress.insert(id.clone()) { + log::warn!("Download for {id} already in progress; ignoring new request"); + return; + } + } + + let downloading_games = ctx.downloading_games.clone(); + let active_downloads = ctx.active_downloads.clone(); + let tx_notify_ui_clone = tx_notify_ui.clone(); + let download_id = id.clone(); + + let handle = tokio::spawn(async move { + let result = download_game_files( + &download_id, + resolved_descriptions, + games_folder, + peer_whitelist, + file_peer_map, + tx_notify_ui_clone.clone(), + ) + .await; + + { + let mut guard = downloading_games.write().await; + guard.remove(&download_id); + } + + if let Err(e) = result { + log::error!("Download failed for {download_id}: {e}"); + if let Err(send_err) = tx_notify_ui_clone.send(PeerEvent::DownloadGameFilesFailed { + id: download_id.clone(), + }) { + log::error!("Failed to send DownloadGameFilesFailed event: {send_err}"); + } + } + + let _ = active_downloads.write().await.remove(&download_id); + }); + + ctx.active_downloads.write().await.insert(id, handle); +} + +/// Handles the `SetGameDir` command. +pub async fn handle_set_game_dir_command( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + game_dir: String, +) { + *ctx.game_dir.write().await = Some(game_dir.clone()); + log::info!("Game directory set to: {game_dir}"); + + // Load local game database when game directory is set + let game_dir = game_dir.clone(); + let tx_notify_ui = tx_notify_ui.clone(); + let ctx_clone = ctx.clone(); + + tokio::spawn(async move { + match load_local_game_db(&game_dir).await { + Ok(db) => { + update_and_announce_games(&ctx_clone, &tx_notify_ui, db).await; + log::info!("Local game database loaded successfully"); + } + Err(e) => { + log::error!("Failed to load local game database: {e}"); + } + } + }); +} + +/// Handles the `GetPeerCount` command. +pub async fn handle_get_peer_count_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender) { + log::info!("GetPeerCount command received"); + let peer_count = { ctx.peer_game_db.read().await.get_peer_addresses().len() }; + if let Err(e) = tx_notify_ui.send(PeerEvent::PeerCountUpdated(peer_count)) { + log::error!("Failed to send PeerCountUpdated event: {e}"); + } +} + +// ============================================================================= +// Game announcement helpers +// ============================================================================= + +/// Updates the local game database and announces changes to peers. +pub async fn update_and_announce_games( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + new_db: GameDB, +) { + let local_game_db = ctx.local_game_db.clone(); + let mut db_guard = local_game_db.write().await; + + let previous_games = db_guard + .as_ref() + .map(|db| db.games.keys().cloned().collect::>()) + .unwrap_or_default(); + + let current_game_ids = new_db.games.keys().cloned().collect::>(); + + // Check if any games were removed + let removed_games: Vec = previous_games + .difference(¤t_game_ids) + .cloned() + .collect(); + + if removed_games.is_empty() { + // Check if any games were added or updated + if previous_games != current_game_ids { + log::debug!("Local games directory structure changed, updating database"); + *db_guard = Some(new_db); + + let all_games = db_guard + .as_ref() + .map(|db| db.all_games().into_iter().cloned().collect::>()) + .unwrap_or_default(); + + if let Err(e) = tx_notify_ui.send(PeerEvent::LocalGamesUpdated(all_games.clone())) { + log::error!("Failed to send LocalGamesUpdated event: {e}"); + } + + // Broadcast update to all peers + let peer_addresses = { ctx.peer_game_db.read().await.get_peer_addresses() }; + for peer_addr in peer_addresses { + let games_clone = all_games.clone(); + tokio::spawn(async move { + if let Err(e) = announce_games_to_peer(peer_addr, games_clone).await { + log::warn!("Failed to announce games to {peer_addr}: {e}"); + } + }); + } + } + } else { + log::info!("Detected removed games: {removed_games:?}"); + *db_guard = Some(new_db); + + // Notify UI about the change + let all_games = db_guard + .as_ref() + .map(|db| db.all_games().into_iter().cloned().collect::>()) + .unwrap_or_default(); + + if let Err(e) = tx_notify_ui.send(PeerEvent::LocalGamesUpdated(all_games.clone())) { + log::error!("Failed to send LocalGamesUpdated event: {e}"); + } + + // Broadcast update to all peers + let peer_addresses = { ctx.peer_game_db.read().await.get_peer_addresses() }; + for peer_addr in peer_addresses { + let games_clone = all_games.clone(); + tokio::spawn(async move { + if let Err(e) = announce_games_to_peer(peer_addr, games_clone).await { + log::warn!("Failed to announce games to {peer_addr}: {e}"); + } + }); + } + } +} diff --git a/crates/lanspread-peer/src/lib.rs b/crates/lanspread-peer/src/lib.rs index 6a0d53a..302495d 100644 --- a/crates/lanspread-peer/src/lib.rs +++ b/crates/lanspread-peer/src/lib.rs @@ -1,93 +1,136 @@ +//! Peer-to-peer game distribution system. +//! +//! This crate provides the core P2P networking engine for `LanSpread`, handling: +//! - Peer discovery via mDNS +//! - QUIC-based communication +//! - Game file synchronization with chunked transfers +//! - Consensus validation for file integrity + #![allow(clippy::missing_errors_doc)] +// ============================================================================= +// Module declarations +// ============================================================================= + +mod config; +mod context; +mod download; +mod error; +mod handlers; +mod local_games; +mod network; mod path_validation; mod peer; +mod peer_db; +mod services; -use std::{ - cmp::Reverse, - collections::{HashMap, HashSet, VecDeque}, - io::ErrorKind, - net::{IpAddr, SocketAddr}, - path::{Path, PathBuf}, - sync::Arc, - time::{Duration, Instant}, -}; +// ============================================================================= +// Public re-exports +// ============================================================================= -use bytes::BytesMut; -use futures::{SinkExt, StreamExt}; -use if_addrs::{IfAddr, Interface, get_if_addrs}; -use lanspread_db::db::{Game, GameDB, GameFileDescription}; -use lanspread_mdns::{LANSPREAD_SERVICE_TYPE, MdnsAdvertiser, MdnsBrowser}; -use lanspread_proto::{Message, Request, Response}; -use s2n_quic::{ - Client as QuicClient, - Connection, - Server, - client::Connect, - provider::limits::Limits, - stream::BidirectionalStream, +use std::{net::SocketAddr, sync::Arc}; + +pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT}; +pub use error::PeerError; +use lanspread_db::db::{Game, GameFileDescription}; +pub use peer_db::{MajorityValidationResult, PeerGameDB, PeerInfo}; +use tokio::sync::{ + RwLock, + mpsc::{UnboundedReceiver, UnboundedSender}, }; -use tokio::{ - fs::OpenOptions, - io::{AsyncSeekExt, AsyncWriteExt}, - sync::{ - RwLock, - mpsc::{UnboundedReceiver, UnboundedSender}, - }, - task::JoinHandle, -}; -use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec}; -use uuid::Uuid; use crate::{ - path_validation::validate_game_file_path, - peer::{send_game_file_chunk, send_game_file_data}, + context::Ctx, + handlers::{ + handle_download_game_files_command, + handle_get_game_command, + handle_get_peer_count_command, + handle_list_games_command, + handle_set_game_dir_command, + }, + services::{ + run_local_game_monitor, + run_peer_discovery, + run_ping_service, + run_server_component, + }, }; -const PEER_PING_INTERVAL_SECS: u64 = 5; -const PEER_STALE_TIMEOUT_SECS: u64 = 12; +// ============================================================================= +// Public API types +// ============================================================================= -/// Custom error types for peer operations +/// Events sent from the peer system to the UI. #[derive(Debug)] -pub enum PeerError { - FileSizeDetermination { - path: String, - source: std::io::Error, +pub enum PeerEvent { + /// List of available games from peers. + ListGames(Vec), + /// File descriptions for a specific game. + GotGameFiles { + id: String, + file_descriptions: Vec, }, - GameDirNotSet, - Other(eyre::Report), + /// Download has started for a game. + DownloadGameFilesBegin { id: String }, + /// Download has completed successfully. + DownloadGameFilesFinished { id: String }, + /// Download has failed. + DownloadGameFilesFailed { id: String }, + /// All peers with the game have disconnected during download. + DownloadGameFilesAllPeersGone { id: String }, + /// No peers have the requested game. + NoPeersHaveGame { id: String }, + /// A peer has connected. + PeerConnected(SocketAddr), + /// A peer has disconnected. + PeerDisconnected(SocketAddr), + /// A new peer was discovered via mDNS. + PeerDiscovered(SocketAddr), + /// A peer was lost (timed out or disconnected). + PeerLost(SocketAddr), + /// The total peer count has changed. + PeerCountUpdated(usize), + /// Local games have been updated. + LocalGamesUpdated(Vec), } -impl std::fmt::Display for PeerError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PeerError::FileSizeDetermination { path, source } => { - write!(f, "Failed to determine file size for {path}: {source}") - } - PeerError::GameDirNotSet => write!(f, "Game directory not set"), - PeerError::Other(err) => write!(f, "General error: {err}"), - } - } +/// Commands sent to the peer system from the UI. +#[derive(Clone, Debug)] +pub enum PeerCommand { + /// Request a list of all available games. + ListGames, + /// Request file details for a specific game. + GetGame(String), + /// Download game files. + DownloadGameFiles { + id: String, + file_descriptions: Vec, + }, + /// Set the local game directory. + SetGameDir(String), + /// Request the current peer count. + GetPeerCount, } -impl std::error::Error for PeerError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - PeerError::FileSizeDetermination { source, .. } => Some(source), - PeerError::Other(err) => Some(err.root_cause()), - PeerError::GameDirNotSet => None, - } - } -} +// ============================================================================= +// Public API functions +// ============================================================================= -impl From for PeerError { - fn from(err: eyre::Report) -> Self { - PeerError::Other(err) - } -} - -/// Initialize and start the peer system -/// This function replaces the main.rs entry point and allows the peer to be started from other crates +/// Initialize and start the peer system. +/// +/// This is the main entry point for the peer system. It starts all background +/// services (server, discovery, ping, local monitor) and returns a channel +/// for sending commands. +/// +/// # Arguments +/// +/// * `game_dir` - Path to the local game directory +/// * `tx_notify_ui` - Channel for sending events to the UI +/// * `peer_game_db` - Shared peer game database +/// +/// # Returns +/// +/// A channel sender for sending commands to the peer system. pub fn start_peer( game_dir: String, tx_notify_ui: UnboundedSender, @@ -111,1307 +154,15 @@ pub fn start_peer( Ok(tx_control_clone) } -static CERT_PEM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../cert.pem")); -static KEY_PEM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../key.pem")); - -#[derive(Debug)] -pub enum PeerEvent { - ListGames(Vec), - GotGameFiles { - id: String, - file_descriptions: Vec, - }, - DownloadGameFilesBegin { - id: String, - }, - DownloadGameFilesFinished { - id: String, - }, - DownloadGameFilesFailed { - id: String, - }, - DownloadGameFilesAllPeersGone { - id: String, - }, - NoPeersHaveGame { - id: String, - }, - PeerConnected(SocketAddr), - PeerDisconnected(SocketAddr), - PeerDiscovered(SocketAddr), - PeerLost(SocketAddr), - PeerCountUpdated(usize), - LocalGamesUpdated(Vec), -} - -#[derive(Clone, Debug)] -pub struct PeerInfo { - pub addr: SocketAddr, - pub last_seen: Instant, - pub games: HashMap, - pub files: HashMap>, -} - -#[derive(Debug)] -pub struct PeerGameDB { - peers: HashMap, -} - -impl Default for PeerGameDB { - fn default() -> Self { - Self::new() - } -} - -impl PeerGameDB { - #[must_use] - pub fn new() -> Self { - Self { - peers: HashMap::new(), - } - } - - pub fn add_peer(&mut self, addr: SocketAddr) { - let peer_info = PeerInfo { - addr, - last_seen: Instant::now(), - games: HashMap::new(), - files: HashMap::new(), - }; - self.peers.insert(addr, peer_info); - log::info!("Added peer: {addr}"); - } - - pub fn remove_peer(&mut self, addr: &SocketAddr) -> Option { - self.peers.remove(addr) - } - - pub fn update_peer_games(&mut self, addr: SocketAddr, games: Vec) { - if let Some(peer) = self.peers.get_mut(&addr) { - let mut map = HashMap::with_capacity(games.len()); - for game in games { - map.insert(game.id.clone(), game); - } - peer.games = map; - peer.last_seen = Instant::now(); - log::info!("Updated games for peer: {addr}"); - } - } - - pub fn update_peer_game_files( - &mut self, - addr: SocketAddr, - game_id: &str, - files: Vec, - ) { - if let Some(peer) = self.peers.get_mut(&addr) { - peer.files.insert(game_id.to_string(), files); - peer.last_seen = Instant::now(); - } - } - - pub fn update_last_seen(&mut self, addr: &SocketAddr) { - if let Some(peer) = self.peers.get_mut(addr) { - peer.last_seen = Instant::now(); - } - } - - #[must_use] - pub fn get_all_games(&self) -> Vec { - let mut aggregated: HashMap = HashMap::new(); - let mut peer_counts: HashMap = HashMap::new(); - - // Count peers per game - for peer in self.peers.values() { - for game_id in peer.games.keys() { - *peer_counts.entry(game_id.clone()).or_insert(0) += 1; - } - } - - // Aggregate games with peer counts - for peer in self.peers.values() { - for game in peer.games.values() { - aggregated - .entry(game.id.clone()) - .and_modify(|existing| { - if let (Some(new_version), Some(current)) = - (&game.eti_game_version, &existing.eti_game_version) - { - if new_version > current { - existing.eti_game_version = Some(new_version.clone()); - } - } else if existing.eti_game_version.is_none() { - existing.eti_game_version.clone_from(&game.eti_game_version); - } - // Update peer count - existing.peer_count = peer_counts[&game.id]; - }) - .or_insert_with(|| { - let mut game_clone = game.clone(); - game_clone.peer_count = peer_counts[&game.id]; - game_clone - }); - } - } - - let mut games: Vec = aggregated.into_values().collect(); - games.sort_by(|a, b| a.name.cmp(&b.name)); - games - } - - #[must_use] - pub fn get_latest_version_for_game(&self, game_id: &str) -> Option { - let mut latest_version: Option = None; - - for peer in self.peers.values() { - if let Some(game) = peer.games.get(game_id) - && let Some(ref version) = game.eti_game_version - { - match &latest_version { - None => latest_version = Some(version.clone()), - Some(current_latest) => { - if version > current_latest { - latest_version = Some(version.clone()); - } - } - } - } - } - - latest_version - } - - #[must_use] - pub fn get_peer_addresses(&self) -> Vec { - self.peers.keys().copied().collect() - } - - #[must_use] - pub fn contains_peer(&self, addr: &SocketAddr) -> bool { - self.peers.contains_key(addr) - } - - #[must_use] - pub fn peers_with_game(&self, game_id: &str) -> Vec { - self.peers - .iter() - .filter(|(_, peer)| peer.games.contains_key(game_id)) - .map(|(addr, _)| *addr) - .collect() - } - - #[must_use] - pub fn peers_with_latest_version(&self, game_id: &str) -> Vec { - let latest_version = self.get_latest_version_for_game(game_id); - - if let Some(ref latest) = latest_version { - self.peers - .iter() - .filter(|(_, peer)| { - if let Some(game) = peer.games.get(game_id) { - if let Some(ref version) = game.eti_game_version { - version == latest - } else { - false - } - } else { - false - } - }) - .map(|(addr, _)| *addr) - .collect() - } else { - // If no version info is available, fall back to all peers with the game - self.peers_with_game(game_id) - } - } - - #[must_use] - pub fn game_files_for(&self, game_id: &str) -> Vec<(SocketAddr, Vec)> { - self.peers - .iter() - .filter_map(|(addr, peer)| peer.files.get(game_id).cloned().map(|files| (*addr, files))) - .collect() - } - - #[must_use] - pub fn aggregated_game_files(&self, game_id: &str) -> Vec { - let mut seen: HashMap = HashMap::new(); - for (_, files) in self.game_files_for(game_id) { - for file in files { - seen.entry(file.relative_path.clone()).or_insert(file); - } - } - 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 - /// `file_peer_map` lists which peers were validated for each file - pub fn validate_file_sizes_majority( - &self, - game_id: &str, - ) -> eyre::Result { - let game_files = self.game_files_for(game_id); - if game_files.is_empty() { - return Ok((Vec::new(), Vec::new(), HashMap::new())); - } - - let (file_size_map, _peer_files) = collect_file_sizes(&game_files); - let (validated_files, peer_scores, file_peer_map) = - self.validate_each_file_consensus(game_id, file_size_map)?; - let peer_whitelist = create_peer_whitelist(peer_scores); - - Ok((validated_files, peer_whitelist, file_peer_map)) - } - - /// Validates consensus for each file and returns validated files with peer scores - fn validate_each_file_consensus( - &self, - game_id: &str, - file_size_map: FileSizeMap, - ) -> eyre::Result { - let mut validated_files = Vec::new(); - let mut peer_whitelist_scores: HashMap = HashMap::new(); - let mut file_peer_map: HashMap> = HashMap::new(); - - for (relative_path, size_map) in file_size_map { - let total_peers: usize = size_map.values().map(Vec::len).sum(); - - if total_peers == 0 { - continue; // Skip files with no size information - } - - let (consensus_size, consensus_peers) = - self.determine_size_consensus(&size_map, total_peers, &relative_path)?; - update_peer_scores(&consensus_peers, &mut peer_whitelist_scores); - - if let Some((size, peers)) = consensus_size - && let Some(file_desc) = - self.create_validated_file_description(game_id, &relative_path, size, &peers) - { - file_peer_map.insert(relative_path.clone(), peers.clone()); - validated_files.push(file_desc); - } - } - - Ok((validated_files, peer_whitelist_scores, file_peer_map)) - } - - /// Determines the consensus size for a file based on peer reports - /// - /// # Panics - /// - /// Panics if `size_map.iter().next()` returns None when `total_peers` == 1 - #[allow(clippy::unused_self)] - fn determine_size_consensus( - &self, - size_map: &HashMap>, - total_peers: usize, - relative_path: &str, - ) -> eyre::Result<(ConsensusResult, Vec)> { - if total_peers == 1 { - // Only one peer has this file - trust it - let (&size, peers) = size_map - .iter() - .next() - .expect("size_map should have at least one entry when total_peers == 1"); - return Ok((Some((size, peers.clone())), peers.clone())); - } - - let (majority_size, _majority_count) = find_majority_size(size_map); - - if let Some(size) = majority_size { - let majority_peers = &size_map[&size]; - let is_majority = majority_peers.len() > total_peers / 2; - - if is_majority { - // We have a clear majority - Ok((Some((size, majority_peers.clone())), majority_peers.clone())) - } else if total_peers == 2 { - // Two peers with different sizes - ambiguous, fail - eyre::bail!( - "File size ambiguity for '{}': two peers report different sizes, cannot determine majority", - relative_path - ); - } - // If no majority and more than 2 peers, we fall back to plurality (largest group) - else { - Ok((Some((size, majority_peers.clone())), majority_peers.clone())) - } - } else { - // No clear majority and it's a tie between different sizes - if total_peers == 2 { - eyre::bail!( - "File size ambiguity for '{}': two peers report different sizes, cannot determine majority", - relative_path - ); - } - // For more than 2 peers, we could fall back to plurality, but for now let's be strict - eyre::bail!( - "File size ambiguity for '{}': no clear majority among {} peers", - relative_path, - total_peers - ); - } - } - - /// Creates a validated file description from consensus data - fn create_validated_file_description( - &self, - game_id: &str, - relative_path: &str, - size: u64, - peers: &[SocketAddr], - ) -> Option { - if let Some(first_peer) = peers.first() - && let Some(files) = self - .peers - .get(first_peer) - .and_then(|p| p.files.get(game_id)) - && let Some(file_desc) = files - .iter() - .find(|f| f.relative_path == relative_path && f.size == size) - { - return Some(file_desc.clone()); - } - None - } - - #[must_use] - pub fn get_stale_peers(&self, timeout: Duration) -> Vec { - self.peers - .iter() - .filter(|(_, peer)| peer.last_seen.elapsed() > timeout) - .map(|(addr, _)| *addr) - .collect() - } -} - -/// Type alias for file size mapping: path -> size -> peers -type FileSizeMap = HashMap>>; - -/// Type alias for peer file mapping: peer -> path -> size -type PeerFileMap = HashMap>; - -/// Type alias for consensus result: (size, peers) or None -type ConsensusResult = Option<(u64, Vec)>; - -/// Type alias for the aggregated majority validation result -type MajorityValidationResult = ( - Vec, - Vec, - HashMap>, -); - -/// Type alias for per-file consensus aggregation results -type FileConsensusAggregation = ( - Vec, - HashMap, - HashMap>, -); - -/// Collects file sizes from all peers and organizes them by path and size -fn collect_file_sizes( - game_files: &[(SocketAddr, Vec)], -) -> (FileSizeMap, PeerFileMap) { - let mut file_size_map: FileSizeMap = HashMap::new(); - let mut peer_files: PeerFileMap = HashMap::new(); - - for (peer_addr, files) in game_files { - let mut peer_file_sizes = HashMap::new(); - for file in files { - if !file.is_dir { - let size = file.size; - file_size_map - .entry(file.relative_path.clone()) - .or_default() - .entry(size) - .or_default() - .push(*peer_addr); - peer_file_sizes.insert(file.relative_path.clone(), size); - } - } - peer_files.insert(*peer_addr, peer_file_sizes); - } - - (file_size_map, peer_files) -} - -/// Finds the majority size from a map of sizes to peer lists -fn find_majority_size(size_map: &HashMap>) -> (Option, usize) { - let mut majority_size = None; - let mut majority_count = 0; - - for (&size, peers) in size_map { - let count = peers.len(); - if count > majority_count { - majority_count = count; - majority_size = Some(size); - } else if count == majority_count { - // Tie between different sizes - ambiguous, fail - majority_size = None; - break; - } - } - - (majority_size, majority_count) -} - -/// Updates peer scores based on consensus participation -fn update_peer_scores( - peers: &[SocketAddr], - peer_whitelist_scores: &mut HashMap, -) { - for &peer in peers { - *peer_whitelist_scores.entry(peer).or_insert(0) += 1; - } -} - -/// Creates a peer whitelist from scores, including peers with the highest scores -fn create_peer_whitelist(peer_scores: HashMap) -> Vec { - if peer_scores.is_empty() { - return Vec::new(); - } - - let mut peers: Vec<_> = peer_scores - .into_iter() - .filter_map(|(peer, score)| (score > 0).then_some((peer, score))) - .collect(); - - peers.sort_by_key(|(peer, score)| (Reverse(*score), *peer)); - - peers.into_iter().map(|(peer, _)| peer).collect() -} - -#[derive(Debug)] -pub enum PeerCommand { - ListGames, - GetGame(String), - DownloadGameFiles { - id: String, - file_descriptions: Vec, - }, - SetGameDir(String), - GetPeerCount, -} - -async fn connect_to_peer(addr: SocketAddr) -> eyre::Result { - let limits = Limits::default().with_max_handshake_duration(Duration::from_secs(3))?; - - let client = QuicClient::builder() - .with_tls(CERT_PEM)? - .with_io("0.0.0.0:0")? - .with_limits(limits)? - .start()?; - - let conn = Connect::new(addr).with_server_name("localhost"); - let conn = client.connect(conn).await?; - Ok(conn) -} - -async fn initial_peer_alive_check(conn: &mut Connection) -> bool { - let remote_addr = conn.remote_addr().ok(); - - let stream = match conn.open_bidirectional_stream().await { - Ok(stream) => stream, - Err(e) => { - log::error!("{remote_addr:?} failed to open stream: {e}"); - return false; - } - }; - - let (rx, tx) = stream.split(); - let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new()); - let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); - - // send ping - if let Err(e) = framed_tx.send(Request::Ping.encode()).await { - log::error!("{remote_addr:?} failed to send ping to peer: {e}"); - return false; - } - let _ = framed_tx.close().await; - - // receive pong - if let Some(Ok(response_bytes)) = framed_rx.next().await { - let response = Response::decode(response_bytes.freeze()); - match response { - Response::Pong => { - log::trace!("{remote_addr:?} peer is alive"); - return true; - } - _ => { - log::error!("{remote_addr:?} peer sent invalid response to ping: {response:?}"); - } - } - } - - false -} - -const CHUNK_SIZE: u64 = 32 * 1024 * 1024; -const MAX_RETRY_COUNT: usize = 3; - -#[derive(Debug, Clone)] -struct DownloadChunk { - relative_path: String, - offset: u64, - length: u64, - retry_count: usize, - last_peer: Option, -} - -#[derive(Debug, Default)] -struct PeerDownloadPlan { - chunks: Vec, - whole_files: Vec, -} - -#[derive(Debug)] -struct ChunkDownloadResult { - chunk: DownloadChunk, - result: eyre::Result<()>, - peer_addr: SocketAddr, -} - -async fn prepare_game_storage( - games_folder: &Path, - file_descs: &[GameFileDescription], -) -> eyre::Result<()> { - for desc in file_descs { - // Validate the path to prevent directory traversal - let validated_path = validate_game_file_path(games_folder, &desc.relative_path)?; - - if desc.is_dir { - tokio::fs::create_dir_all(&validated_path).await?; - } else { - if let Some(parent) = validated_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - // Create and pre-allocate the file with the expected size - let file = OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&validated_path) - .await?; - - // Pre-allocate the file with the expected size - let size = desc.size; - if let Err(e) = file.set_len(size).await { - log::warn!( - "Failed to pre-allocate file {} (size: {}): {}", - desc.relative_path, - size, - e - ); - // Continue without pre-allocation - the file will grow as chunks are written - } else { - log::debug!( - "Pre-allocated file {} with {} bytes", - desc.relative_path, - size - ); - } - } - } - Ok(()) -} - -fn resolve_file_peers<'a>( - relative_path: &str, - file_peer_map: &'a HashMap>, - fallback: &'a [SocketAddr], -) -> &'a [SocketAddr] { - if let Some(peers) = file_peer_map.get(relative_path) - && !peers.is_empty() - { - return peers; - } - - fallback -} - -fn build_peer_plans( - peers: &[SocketAddr], - file_descs: &[GameFileDescription], - file_peer_map: &HashMap>, -) -> HashMap { - let mut plans: HashMap = HashMap::new(); - if peers.is_empty() { - return plans; - } - - let mut peer_index = 0usize; - - for desc in file_descs.iter().filter(|d| !d.is_dir) { - let size = desc.file_size(); - let eligible_peers = resolve_file_peers(&desc.relative_path, file_peer_map, peers); - if eligible_peers.is_empty() { - continue; - } - - if size == 0 { - let peer = eligible_peers[peer_index % eligible_peers.len()]; - peer_index += 1; - plans.entry(peer).or_default().chunks.push(DownloadChunk { - relative_path: desc.relative_path.clone(), - offset: 0, - length: 0, - retry_count: 0, - last_peer: Some(peer), - }); - continue; - } - - let mut offset = 0u64; - while offset < size { - let length = std::cmp::min(CHUNK_SIZE, size - offset); - let peer = eligible_peers[peer_index % eligible_peers.len()]; - peer_index += 1; - plans.entry(peer).or_default().chunks.push(DownloadChunk { - relative_path: desc.relative_path.clone(), - offset, - length, - retry_count: 0, - last_peer: Some(peer), - }); - offset += length; - } - } - - plans -} - -async fn download_chunk( - conn: &mut Connection, - base_dir: &Path, - game_id: &str, - chunk: &DownloadChunk, -) -> eyre::Result<()> { - let stream = conn.open_bidirectional_stream().await?; - let (mut rx, tx) = stream.split(); - let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); - - let request = Request::GetGameFileChunk { - game_id: game_id.to_string(), - relative_path: chunk.relative_path.clone(), - offset: chunk.offset, - length: chunk.length, - }; - framed_tx.send(request.encode()).await?; - - framed_tx.close().await?; - - // Validate the path to prevent directory traversal - let validated_path = validate_game_file_path(base_dir, &chunk.relative_path)?; - let mut file = OpenOptions::new() - .create(true) - .write(true) - .truncate(false) - .open(&validated_path) - .await?; - if chunk.length == 0 && chunk.offset == 0 { - // fallback-to-whole-file path replaces any existing partial data - file.set_len(0).await?; - } - file.seek(std::io::SeekFrom::Start(chunk.offset)).await?; - - let mut remaining = chunk.length; - let mut received_bytes = 0u64; - - while let Some(bytes) = rx.receive().await? { - file.write_all(&bytes).await?; - received_bytes += bytes.len() as u64; - - if remaining == 0 { - continue; - } - remaining = remaining.saturating_sub(bytes.len() as u64); - if remaining == 0 { - break; - } - } - - // Verify we received the expected amount of data - if chunk.length > 0 && received_bytes != chunk.length { - eyre::bail!( - "Incomplete chunk download: expected {} bytes, received {} bytes for file {} at offset {}", - chunk.length, - received_bytes, - chunk.relative_path, - chunk.offset - ); - } - - file.flush().await?; - - // Verify file integrity by checking the file size - verify_chunk_integrity(&validated_path, chunk.offset, chunk.length).await?; - - Ok(()) -} - -async fn verify_chunk_integrity( - file_path: &Path, - offset: u64, - expected_length: u64, -) -> eyre::Result<()> { - if expected_length == 0 { - return Ok(()); // Skip verification for whole files or zero-length chunks - } - - let metadata = tokio::fs::metadata(file_path).await?; - let file_size = metadata.len(); - - if file_size < offset + expected_length { - eyre::bail!( - "File integrity check failed: file size {} is less than expected {} (offset: {})", - file_size, - offset + expected_length, - offset - ); - } - - Ok(()) -} - -async fn download_whole_file( - conn: &mut Connection, - base_dir: &Path, - desc: &GameFileDescription, -) -> eyre::Result<()> { - let stream = conn.open_bidirectional_stream().await?; - let (mut rx, tx) = stream.split(); - let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); - - framed_tx - .send(Request::GetGameFileData(desc.clone()).encode()) - .await?; - framed_tx.close().await?; - - // Validate the path to prevent directory traversal - let validated_path = validate_game_file_path(base_dir, &desc.relative_path)?; - let mut file = OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&validated_path) - .await?; - file.seek(std::io::SeekFrom::Start(0)).await?; - - while let Some(bytes) = rx.receive().await? { - file.write_all(&bytes).await?; - } - - file.flush().await?; - Ok(()) -} - -async fn download_from_peer( - peer_addr: SocketAddr, - game_id: &str, - plan: PeerDownloadPlan, - games_folder: PathBuf, -) -> eyre::Result> { - if plan.chunks.is_empty() && plan.whole_files.is_empty() { - return Ok(Vec::new()); - } - - let mut conn = connect_to_peer(peer_addr).await?; - conn.keep_alive(true)?; - conn.keep_alive(true)?; - - let base_dir = games_folder; - let mut results = Vec::new(); - - // Download chunks with error handling - for chunk in &plan.chunks { - log::info!( - "Downloading chunk {} (offset {}, length {}) from {}", - chunk.relative_path, - chunk.offset, - chunk.length, - peer_addr - ); - let result = download_chunk(&mut conn, &base_dir, game_id, chunk).await; - results.push(ChunkDownloadResult { - chunk: chunk.clone(), - result, - peer_addr, - }); - } - - // Download whole files - for desc in &plan.whole_files { - let chunk = DownloadChunk { - relative_path: desc.relative_path.clone(), - offset: 0, - length: 0, // Indicates whole file - retry_count: 0, - last_peer: Some(peer_addr), - }; - - let result = download_whole_file(&mut conn, &base_dir, desc).await; - results.push(ChunkDownloadResult { - chunk, - result, - peer_addr, - }); - } - - Ok(results) -} - -async fn download_game_files( - game_id: &str, - game_file_descs: Vec, - games_folder: String, - peers: Vec, - file_peer_map: HashMap>, - tx_notify_ui: UnboundedSender, -) -> eyre::Result<()> { - if peers.is_empty() { - eyre::bail!("no peers available for game {game_id}"); - } - - let base_dir = PathBuf::from(&games_folder); - prepare_game_storage(&base_dir, &game_file_descs).await?; - - tx_notify_ui.send(PeerEvent::DownloadGameFilesBegin { - id: game_id.to_string(), - })?; - - let plans = build_peer_plans(&peers, &game_file_descs, &file_peer_map); - - let mut tasks = Vec::new(); - for (peer_addr, plan) in plans { - let base_dir = base_dir.clone(); - let game_id = game_id.to_string(); - tasks.push(tokio::spawn(async move { - download_from_peer(peer_addr, &game_id, plan, base_dir).await - })); - } - - let mut failed_chunks: Vec = Vec::new(); - let mut last_err: Option = None; - - for handle in tasks { - match handle.await { - Ok(Ok(results)) => { - for chunk_result in results { - if let Err(e) = chunk_result.result { - log::warn!( - "Failed to download chunk from {}: {e}", - chunk_result.peer_addr - ); - if chunk_result.chunk.retry_count < MAX_RETRY_COUNT { - let mut retry_chunk = chunk_result.chunk; - retry_chunk.retry_count += 1; - retry_chunk.last_peer = Some(chunk_result.peer_addr); - failed_chunks.push(retry_chunk); - } else { - last_err = Some(eyre::eyre!( - "Max retries exceeded for chunk: {}", - chunk_result.chunk.relative_path - )); - } - } - } - } - Ok(Err(e)) => last_err = Some(e), - Err(e) => last_err = Some(eyre::eyre!("task join error: {e}")), - } - } - - // Retry failed chunks if any - if !failed_chunks.is_empty() && !peers.is_empty() { - log::info!("Retrying {} failed chunks", failed_chunks.len()); - - let retry_results = - retry_failed_chunks(failed_chunks, &peers, &base_dir, game_id, &file_peer_map).await; - - for chunk_result in retry_results { - if let Err(e) = chunk_result.result { - log::error!("Retry failed for chunk: {e}"); - last_err = Some(e); - } - } - } - - if let Some(err) = last_err { - tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { - id: game_id.to_string(), - })?; - return Err(err); - } - - log::info!("all files downloaded for game: {game_id}"); - tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished { - id: game_id.to_string(), - })?; - Ok(()) -} - -fn select_retry_peer( - peers: &[SocketAddr], - last_peer: Option, - attempt_offset: usize, -) -> Option { - if peers.is_empty() { - return None; - } - - if peers.len() > 1 - && let Some(last) = last_peer - && let Some(pos) = peers.iter().position(|addr| *addr == last) - { - let next_index = (pos + 1 + attempt_offset) % peers.len(); - return Some(peers[next_index]); - } - - Some(peers[attempt_offset % peers.len()]) -} - -fn fallback_peer_addr(peers: &[SocketAddr], last_peer: Option) -> SocketAddr { - last_peer - .or_else(|| peers.first().copied()) - .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 0))) -} - -async fn retry_failed_chunks( - failed_chunks: Vec, - peers: &[SocketAddr], - base_dir: &Path, - game_id: &str, - file_peer_map: &HashMap>, -) -> Vec { - let mut exhausted = Vec::new(); - let mut queue: VecDeque = failed_chunks.into_iter().collect(); - - while let Some(mut chunk) = queue.pop_front() { - let eligible_peers = resolve_file_peers(&chunk.relative_path, file_peer_map, peers); - - if chunk.retry_count >= MAX_RETRY_COUNT { - exhausted.push(ChunkDownloadResult { - chunk: chunk.clone(), - result: Err(eyre::eyre!( - "Retry budget exhausted for chunk: {}", - chunk.relative_path - )), - peer_addr: fallback_peer_addr(eligible_peers, chunk.last_peer), - }); - continue; - } - - let retry_offset = chunk.retry_count.saturating_sub(1); - let Some(peer_addr) = select_retry_peer(eligible_peers, chunk.last_peer, retry_offset) - else { - exhausted.push(ChunkDownloadResult { - chunk: chunk.clone(), - result: Err(eyre::eyre!( - "No peers available to retry chunk: {}", - chunk.relative_path - )), - peer_addr: fallback_peer_addr(eligible_peers, chunk.last_peer), - }); - continue; - }; - - let mut attempt_chunk = chunk.clone(); - attempt_chunk.last_peer = Some(peer_addr); - - let plan = PeerDownloadPlan { - chunks: vec![attempt_chunk.clone()], - whole_files: Vec::new(), - }; - - match download_from_peer(peer_addr, game_id, plan, base_dir.to_path_buf()).await { - Ok(results) => { - for result in results { - match result.result { - Ok(()) => {} - Err(e) => { - let mut retry_chunk = result.chunk.clone(); - retry_chunk.retry_count = chunk.retry_count + 1; - retry_chunk.last_peer = Some(result.peer_addr); - - if retry_chunk.retry_count >= MAX_RETRY_COUNT { - let context = format!( - "Retry budget exhausted for chunk: {}", - result.chunk.relative_path - ); - exhausted.push(ChunkDownloadResult { - chunk: retry_chunk, - result: Err(e.wrap_err(context)), - peer_addr: result.peer_addr, - }); - } else { - queue.push_back(retry_chunk); - } - } - } - } - } - Err(e) => { - chunk.retry_count += 1; - chunk.last_peer = Some(peer_addr); - - if chunk.retry_count >= MAX_RETRY_COUNT { - exhausted.push(ChunkDownloadResult { - chunk: chunk.clone(), - result: Err(e.wrap_err(format!( - "Retry budget exhausted for chunk after connection failure: {}", - chunk.relative_path - ))), - peer_addr: fallback_peer_addr(eligible_peers, chunk.last_peer), - }); - } else { - queue.push_back(chunk); - } - } - } - } - - exhausted -} - -/// Load local game database combining locally installed games -async fn load_local_game_db(game_dir: &str) -> eyre::Result { - let game_path = PathBuf::from(game_dir); - - let metadata = match tokio::fs::metadata(&game_path).await { - Ok(metadata) => metadata, - Err(err) => { - if err.kind() == ErrorKind::NotFound { - log::warn!( - "Local game directory {} missing; reporting empty game database", - game_path.display() - ); - return Ok(GameDB::empty()); - } - return Err(err.into()); - } - }; - - if !metadata.is_dir() { - log::warn!( - "Configured game directory {} is not a directory; reporting empty game database", - game_path.display() - ); - return Ok(GameDB::empty()); - } - - let mut games = Vec::new(); - - // Scan game directory and create entries for installed games - let mut entries = tokio::fs::read_dir(&game_path).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.is_dir() - && let Some(game_id) = path.file_name().and_then(|n| n.to_str()) - { - let eti_path = path.join(format!("{game_id}.eti")); - let downloaded = tokio::fs::metadata(&eti_path).await.is_ok(); - if !downloaded { - continue; - } - - let installed = local_dir_has_content(&path).await; - let local_version = if installed { - match lanspread_db::db::read_version_from_ini(&path) { - Ok(version) => version, - Err(e) => { - log::warn!("Failed to read version.ini for installed game {game_id}: {e}"); - None - } - } - } else { - None - }; - - let size = calculate_directory_size(&path, true).await?; - let game = Game { - id: game_id.to_string(), - name: game_id.to_string(), - description: String::new(), - release_year: String::new(), - publisher: String::new(), - max_players: 1, - version: "1.0".to_string(), - genre: String::new(), - size, - downloaded, - installed, - eti_game_version: local_version.clone(), - local_version, - peer_count: 0, // Local games start with 0 peers - }; - games.push(game); - } - } - - Ok(GameDB::from(games)) -} - -async fn local_dir_has_content(path: &Path) -> bool { - let local_dir = path.join("local"); - if tokio::fs::metadata(&local_dir).await.is_err() { - return false; - } - - let mut entries = match tokio::fs::read_dir(&local_dir).await { - Ok(entries) => entries, - Err(e) => { - log::warn!("Failed to read local dir {}: {e}", local_dir.display()); - return false; - } - }; - - match entries.next_entry().await { - Ok(Some(_)) => true, - Ok(None) => false, - Err(e) => { - log::warn!("Failed to iterate local dir {}: {e}", local_dir.display()); - false - } - } -} - -async fn calculate_directory_size(dir: &Path, is_root: bool) -> eyre::Result { - let mut total_size = 0u64; - let mut entries = tokio::fs::read_dir(dir).await?; - - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - - if is_root { - if name_str == ".sync" || name_str == ".softlan_first_start_done" { - continue; - } - if entry.file_type().await?.is_dir() && is_local_dir_name(&name_str) { - continue; - } - } - - let metadata = tokio::fs::metadata(&path).await?; - - if metadata.is_dir() { - total_size += Box::pin(calculate_directory_size(&path, false)).await?; - } else { - total_size += metadata.len(); - } - } - - Ok(total_size) -} - -async fn local_download_available( - game_dir: &str, - game_id: &str, - downloading_games: &Arc>>, -) -> bool { - if downloading_games.read().await.contains(game_id) { - log::debug!("Not serving game {game_id} locally because it is still downloading"); - return false; - } - - let game_path = PathBuf::from(game_dir).join(game_id); - let eti_path = game_path.join(format!("{game_id}.eti")); - - if tokio::fs::metadata(&eti_path).await.is_err() { - return false; - } - - // Only treat as pending install if the local installation directory is empty/missing - !local_dir_has_content(game_path.as_path()).await -} - -#[derive(Clone)] -struct Ctx { - game_dir: Arc>>, - local_game_db: Arc>>, - peer_game_db: Arc>, - local_peer_addr: Arc>>, - downloading_games: Arc>>, - active_downloads: Arc>>>, -} - -#[derive(Clone)] -struct PeerCtx { - game_dir: Arc>>, - local_game_db: Arc>>, - local_peer_addr: Arc>>, - downloading_games: Arc>>, - peer_game_db: Arc>, - tx_notify_ui: UnboundedSender, -} - -impl std::fmt::Debug for PeerCtx { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("PeerCtx") - .field("game_dir", &"...") - .field("local_game_db", &"...") - .field("local_peer_addr", &"...") - .field("downloading_games", &"...") - .finish() - } -} - /// Main peer execution loop that handles peer commands and manages the peer system. -/// -/// # Panics -/// -/// This function will panic if the games folder is None after being checked for None. -/// The panic occurs at line 908 where `games_folder.expect("checked above")` is called. -pub async fn run_peer( +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: 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())), - }; - - let peer_ctx = PeerCtx { - game_dir: ctx.game_dir.clone(), - local_game_db: ctx.local_game_db.clone(), - local_peer_addr: ctx.local_peer_addr.clone(), - downloading_games: ctx.downloading_games.clone(), - peer_game_db: ctx.peer_game_db.clone(), - tx_notify_ui: tx_notify_ui.clone(), - }; + // Create the shared context + let ctx = Ctx::new(peer_game_db.clone()); + let peer_ctx = ctx.to_peer_ctx(tx_notify_ui.clone()); // Start server component let server_addr = "0.0.0.0:0".parse::()?; @@ -1491,1365 +242,3 @@ pub async fn run_peer( Ok(()) } - -async fn run_server_component( - addr: SocketAddr, - ctx: PeerCtx, - tx_notify_ui: UnboundedSender, -) -> eyre::Result<()> { - let limits = Limits::default() - .with_max_handshake_duration(Duration::from_secs(3))? - .with_max_idle_timeout(Duration::from_secs(3))?; - - let mut server = Server::builder() - .with_tls((CERT_PEM, KEY_PEM))? - .with_io(addr)? - .with_limits(limits)? - .start()?; - - let server_addr = server.local_addr()?; - log::info!("Peer server listening on {server_addr}"); - - let advertise_ip = select_advertise_ip()?; - let advertise_addr = SocketAddr::new(advertise_ip, server_addr.port()); - log::info!("Advertising peer via mDNS from {advertise_addr}"); - { - let mut guard = ctx.local_peer_addr.write().await; - *guard = Some(advertise_addr); - } - - // Start mDNS advertising for peer discovery - let peer_id = Uuid::now_v7().simple().to_string(); - let hostname = gethostname::gethostname(); - let hostname_str = hostname.to_str().unwrap_or(""); - - // Calculate maximum hostname length that fits with UUID in 63 char limit - let max_hostname_len = 63usize.saturating_sub(peer_id.len() + 1); - let truncated_hostname = if hostname_str.len() > max_hostname_len { - hostname_str.get(..max_hostname_len).unwrap_or(hostname_str) - } else { - hostname_str - }; - - let combined_str = if truncated_hostname.is_empty() { - peer_id - } else { - format!("{truncated_hostname}-{peer_id}") - }; - - let mdns = tokio::task::spawn_blocking(move || { - MdnsAdvertiser::new(LANSPREAD_SERVICE_TYPE, &combined_str, advertise_addr) - }) - .await??; - - // Monitor mDNS events - let _tx_notify_ui_mdns = tx_notify_ui.clone(); - let hostname = truncated_hostname.to_string(); - tokio::spawn(async move { - log::info!("Registering mDNS service with hostname: {hostname}"); - while let Ok(event) = mdns.monitor.recv() { - match event { - lanspread_mdns::DaemonEvent::Error(e) => { - log::error!("mDNS error: {e}"); - tokio::time::sleep(Duration::from_secs(1)).await; - } - _ => { - log::trace!("mDNS event: {event:?}"); - } - } - } - }); - - while let Some(connection) = server.accept().await { - let ctx = ctx.clone(); - let tx_notify_ui = tx_notify_ui.clone(); - - tokio::spawn(async move { - if let Err(e) = handle_peer_connection(connection, ctx, tx_notify_ui).await { - log::error!("Peer connection error: {e}"); - } - }); - } - - Ok(()) -} -async fn handle_list_games_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender) { - log::info!("ListGames command received"); - emit_peer_game_list(&ctx.peer_game_db, tx_notify_ui).await; -} - -async fn emit_peer_game_list( - peer_game_db: &Arc>, - tx_notify_ui: &UnboundedSender, -) { - let all_games = { peer_game_db.read().await.get_all_games() }; - if let Err(e) = tx_notify_ui.send(PeerEvent::ListGames(all_games)) { - log::error!("Failed to send ListGames event: {e}"); - } -} - -async fn try_serve_local_game( - ctx: &Ctx, - tx_notify_ui: &UnboundedSender, - id: &str, -) -> bool { - let game_dir = { ctx.game_dir.read().await.clone() }; - let Some(game_dir) = game_dir else { - return false; - }; - - if !local_download_available(&game_dir, id, &ctx.downloading_games).await { - return false; - } - - match get_game_file_descriptions(id, &game_dir).await { - Ok(file_descriptions) => { - log::info!("Serving game {id} from local files"); - if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles { - id: id.to_string(), - file_descriptions, - }) { - log::error!("Failed to send GotGameFiles event: {e}"); - } - true - } - Err(e) => { - log::error!("Failed to enumerate local file descriptions for {id}: {e}"); - false - } - } -} - -async fn handle_get_game_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: String) { - if try_serve_local_game(ctx, tx_notify_ui, &id).await { - return; - } - - log::info!("Requesting game from peers: {id}"); - let peers = { ctx.peer_game_db.read().await.peers_with_game(&id) }; - if peers.is_empty() { - log::warn!("No peers have game {id}"); - if let Err(e) = tx_notify_ui.send(PeerEvent::NoPeersHaveGame { id: id.clone() }) { - log::error!("Failed to send NoPeersHaveGame event: {e}"); - } - return; - } - - let peer_game_db = ctx.peer_game_db.clone(); - let tx_notify_ui = tx_notify_ui.clone(); - tokio::spawn(async move { - let mut fetched_any = false; - for peer_addr in peers { - match request_game_details_from_peer(peer_addr, &id, peer_game_db.clone()).await { - Ok(_) => { - log::info!("Fetched game file list for {id} from peer {peer_addr}"); - fetched_any = true; - } - Err(e) => { - log::error!("Failed to fetch game files for {id} from {peer_addr}: {e}"); - } - } - } - - if fetched_any { - let aggregated_files = { peer_game_db.read().await.aggregated_game_files(&id) }; - - if let Err(e) = tx_notify_ui.send(PeerEvent::GotGameFiles { - id: id.clone(), - file_descriptions: aggregated_files, - }) { - log::error!("Failed to send GotGameFiles event: {e}"); - } - } else { - log::warn!("Failed to retrieve game files for {id} from any peer"); - } - }); -} - -async fn handle_download_game_files_command( - ctx: &Ctx, - tx_notify_ui: &UnboundedSender, - id: String, - file_descriptions: Vec, -) { - log::info!("Got PeerCommand::DownloadGameFiles"); - let games_folder = { ctx.game_dir.read().await.clone() }; - if games_folder.is_none() { - log::error!("Cannot handle game file descriptions: games_folder is not set"); - return; - } - - let games_folder = games_folder.expect("checked above"); - - // Use majority validation to get trusted file descriptions and peer whitelist - let (validated_descriptions, peer_whitelist, file_peer_map) = { - match ctx - .peer_game_db - .read() - .await - .validate_file_sizes_majority(&id) - { - Ok((files, peers, file_peer_map)) => { - log::info!( - "Majority validation: {} validated files, {} trusted peers for game {id}", - files.len(), - peers.len() - ); - (files, peers, file_peer_map) - } - Err(e) => { - log::error!("File size majority validation failed for {id}: {e}"); - if let Err(send_err) = - tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() }) - { - log::error!("Failed to send DownloadGameFilesFailed event: {send_err}"); - } - return; - } - } - }; - - let resolved_descriptions = if file_descriptions.is_empty() { - validated_descriptions - } else { - // If user provided specific descriptions, still validate them against majority - // but keep user's selection (they might want specific files) - file_descriptions - }; - - if resolved_descriptions.is_empty() { - log::error!( - "No validated file descriptions available to download game {id}; request metadata first" - ); - return; - } - - if peer_whitelist.is_empty() { - if local_download_available(&games_folder, &id, &ctx.downloading_games).await { - log::info!("Using locally downloaded files for game {id}; skipping peer transfer"); - if let Err(e) = tx_notify_ui.send(PeerEvent::DownloadGameFilesBegin { id: id.clone() }) - { - log::error!("Failed to send DownloadGameFilesBegin event: {e}"); - } - if let Err(e) = - tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished { id: id.clone() }) - { - log::error!("Failed to send DownloadGameFilesFinished event: {e}"); - } - } else { - log::error!("No trusted peers available after majority validation for game {id}"); - } - return; - } - - { - let mut in_progress = ctx.downloading_games.write().await; - if !in_progress.insert(id.clone()) { - log::warn!("Download for {id} already in progress; ignoring new request"); - return; - } - } - - let downloading_games = ctx.downloading_games.clone(); - let active_downloads = ctx.active_downloads.clone(); - let tx_notify_ui_clone = tx_notify_ui.clone(); - let download_id = id.clone(); - - let handle = tokio::spawn(async move { - let result = download_game_files( - &download_id, - resolved_descriptions, - games_folder, - peer_whitelist, - file_peer_map, - tx_notify_ui_clone.clone(), - ) - .await; - - { - let mut guard = downloading_games.write().await; - guard.remove(&download_id); - } - - if let Err(e) = result { - log::error!("Download failed for {download_id}: {e}"); - if let Err(send_err) = tx_notify_ui_clone.send(PeerEvent::DownloadGameFilesFailed { - id: download_id.clone(), - }) { - log::error!("Failed to send DownloadGameFilesFailed event: {send_err}"); - } - } - - let _ = active_downloads.write().await.remove(&download_id); - }); - - ctx.active_downloads.write().await.insert(id, handle); -} - -async fn handle_set_game_dir_command( - ctx: &Ctx, - tx_notify_ui: &UnboundedSender, - game_dir: String, -) { - *ctx.game_dir.write().await = Some(game_dir.clone()); - log::info!("Game directory set to: {game_dir}"); - - // Load local game database when game directory is set - let game_dir = game_dir.clone(); - let tx_notify_ui = tx_notify_ui.clone(); - let ctx_clone = ctx.clone(); - - tokio::spawn(async move { - match load_local_game_db(&game_dir).await { - Ok(db) => { - update_and_announce_games(&ctx_clone, &tx_notify_ui, db).await; - log::info!("Local game database loaded successfully"); - } - Err(e) => { - log::error!("Failed to load local game database: {e}"); - } - } - }); -} - -async fn update_and_announce_games( - ctx: &Ctx, - tx_notify_ui: &UnboundedSender, - new_db: GameDB, -) { - let local_game_db = ctx.local_game_db.clone(); - let mut db_guard = local_game_db.write().await; - - let previous_games = db_guard - .as_ref() - .map(|db| db.games.keys().cloned().collect::>()) - .unwrap_or_default(); - - let current_game_ids = new_db.games.keys().cloned().collect::>(); - - // Check if any games were removed - let removed_games: Vec = previous_games - .difference(¤t_game_ids) - .cloned() - .collect(); - - if removed_games.is_empty() { - // Check if any games were added or updated - if previous_games != current_game_ids { - log::debug!("Local games directory structure changed, updating database"); - *db_guard = Some(new_db); - - let all_games = db_guard - .as_ref() - .map(|db| db.all_games().into_iter().cloned().collect::>()) - .unwrap_or_default(); - - if let Err(e) = tx_notify_ui.send(PeerEvent::LocalGamesUpdated(all_games.clone())) { - log::error!("Failed to send LocalGamesUpdated event: {e}"); - } - - // Broadcast update to all peers - let peer_addresses = { ctx.peer_game_db.read().await.get_peer_addresses() }; - for peer_addr in peer_addresses { - let games_clone = all_games.clone(); - tokio::spawn(async move { - if let Err(e) = announce_games_to_peer(peer_addr, games_clone).await { - log::warn!("Failed to announce games to {peer_addr}: {e}"); - } - }); - } - } - } else { - log::info!("Detected removed games: {removed_games:?}"); - *db_guard = Some(new_db); - - // Notify UI about the change - let all_games = db_guard - .as_ref() - .map(|db| db.all_games().into_iter().cloned().collect::>()) - .unwrap_or_default(); - - if let Err(e) = tx_notify_ui.send(PeerEvent::LocalGamesUpdated(all_games.clone())) { - log::error!("Failed to send LocalGamesUpdated event: {e}"); - } - - // Broadcast update to all peers - let peer_addresses = { ctx.peer_game_db.read().await.get_peer_addresses() }; - for peer_addr in peer_addresses { - let games_clone = all_games.clone(); - tokio::spawn(async move { - if let Err(e) = announce_games_to_peer(peer_addr, games_clone).await { - log::warn!("Failed to announce games to {peer_addr}: {e}"); - } - }); - } - } -} - -async fn handle_get_peer_count_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender) { - log::info!("GetPeerCount command received"); - let peer_count = { ctx.peer_game_db.read().await.get_peer_addresses().len() }; - if let Err(e) = tx_notify_ui.send(PeerEvent::PeerCountUpdated(peer_count)) { - log::error!("Failed to send PeerCountUpdated event: {e}"); - } -} - -async fn handle_peer_connection( - mut connection: Connection, - ctx: PeerCtx, - tx_notify_ui: UnboundedSender, -) -> eyre::Result<()> { - let remote_addr = connection.remote_addr()?; - log::info!("{remote_addr} peer connected"); - - if let Err(e) = tx_notify_ui.send(PeerEvent::PeerConnected(remote_addr)) { - log::error!("Failed to send PeerConnected event: {e}"); - } - - // handle streams - while let Ok(Some(stream)) = connection.accept_bidirectional_stream().await { - let ctx = ctx.clone(); - let remote_addr = Some(remote_addr); - - tokio::spawn(async move { - if let Err(e) = handle_peer_stream(stream, ctx, remote_addr).await { - log::error!("{remote_addr:?} peer stream error: {e}"); - } - }); - } - - if let Err(e) = tx_notify_ui.send(PeerEvent::PeerDisconnected(remote_addr)) { - log::error!("Failed to send PeerDisconnected event: {e}"); - } - - Ok(()) -} - -#[allow(clippy::too_many_lines)] -async fn handle_peer_stream( - stream: BidirectionalStream, - ctx: PeerCtx, - remote_addr: Option, -) -> eyre::Result<()> { - let (rx, tx) = stream.split(); - let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new()); - let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); - - log::trace!("{remote_addr:?} peer stream opened"); - - // handle streams - loop { - match framed_rx.next().await { - Some(Ok(data)) => { - log::trace!( - "{:?} msg: (raw): {}", - remote_addr, - String::from_utf8_lossy(&data) - ); - - let request = Request::decode(data.freeze()); - log::debug!("{remote_addr:?} msg: {request:?}"); - - match request { - Request::Ping => { - // Respond with pong - if let Err(e) = framed_tx.send(Response::Pong.encode()).await { - log::error!("Failed to send pong: {e}"); - } - } - Request::ListGames => { - // Return list of games from this peer - log::info!("Received ListGames request from peer"); - let snapshot = { - let db_guard = ctx.local_game_db.read().await; - if let Some(ref db) = *db_guard { - db.all_games().into_iter().cloned().collect::>() - } else { - // Local database not loaded yet, return empty result - log::info!( - "Local game database not yet loaded, responding with empty game list" - ); - Vec::new() - } - }; - let games = if snapshot.is_empty() { - snapshot - } else { - let downloading = ctx.downloading_games.read().await; - snapshot - .into_iter() - .filter(|game| !downloading.contains(&game.id)) - .collect() - }; - - if let Err(e) = framed_tx.send(Response::ListGames(games).encode()).await { - log::error!("Failed to send ListGames response: {e}"); - } - } - Request::GetGame { id } => { - log::info!("Received GetGame request for {id} from peer"); - let downloading = ctx.downloading_games.read().await.contains(&id); - let response = if downloading { - log::info!( - "Declining to serve GetGame for {id} because download is in progress" - ); - Response::GameNotFound(id) - } else if let Some(ref game_dir) = *ctx.game_dir.read().await { - if let Some(ref db) = *ctx.local_game_db.read().await { - if db.get_game_by_id(&id).is_some() { - match get_game_file_descriptions(&id, game_dir).await { - Ok(file_descriptions) => Response::GetGame { - id, - file_descriptions, - }, - Err(PeerError::FileSizeDetermination { path, source }) => { - let error_msg = format!( - "Failed to determine file size for {path}: {source}" - ); - log::error!( - "File size determination error for game {id}: {error_msg}" - ); - Response::InternalPeerError(error_msg) - } - Err(e) => { - log::error!( - "Failed to get game file descriptions for {id}: {e}" - ); - Response::GameNotFound(id) - } - } - } else { - Response::GameNotFound(id) - } - } else { - Response::GameNotFound(id) - } - } else { - Response::GameNotFound(id) - }; - - if let Err(e) = framed_tx.send(response.encode()).await { - log::error!("Failed to send GetGame response: {e}"); - } - } - Request::GetGameFileData(desc) => { - log::info!( - "Received GetGameFileData request for {} from peer", - desc.relative_path - ); - - let maybe_game_dir = ctx.game_dir.read().await.clone(); - if let Some(game_dir) = maybe_game_dir { - let base_dir = PathBuf::from(game_dir); - // For file data, we need the raw stream, so we unwrap the FramedWrite - let mut tx = framed_tx.into_inner(); - send_game_file_data(&desc, &mut tx, &base_dir).await; - // Re-wrap for next iteration (though usually stream closes after file transfer) - framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); - } else if let Err(e) = framed_tx - .send( - Response::InvalidRequest( - desc.relative_path.as_bytes().to_vec().into(), - "Game directory not set".to_string(), - ) - .encode(), - ) - .await - { - log::error!("Failed to send GetGameFileData error: {e}"); - } - } - Request::GetGameFileChunk { - game_id, - relative_path, - offset, - length, - } => { - log::info!( - "{remote_addr:?} received GetGameFileChunk request for {relative_path} (offset {offset}, length {length})" - ); - - let maybe_game_dir = ctx.game_dir.read().await.clone(); - if let Some(game_dir) = maybe_game_dir { - let base_dir = PathBuf::from(game_dir); - // For file data, we need the raw stream, so we unwrap the FramedWrite - let mut tx = framed_tx.into_inner(); - send_game_file_chunk( - &game_id, - &relative_path, - offset, - length, - &mut tx, - &base_dir, - ) - .await; - // Re-wrap for next iteration - framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); - } else if let Err(e) = framed_tx - .send( - Response::InvalidRequest( - relative_path.as_bytes().to_vec().into(), - "Game directory not set".to_string(), - ) - .encode(), - ) - .await - { - log::error!("Failed to send GetGameFileChunk error: {e}"); - } - } - Request::Invalid(_, _) => { - log::error!("Received invalid request from peer"); - } - Request::AnnounceGames(games) => { - log::info!( - "Received {} announced games from peer {remote_addr:?}", - games.len() - ); - if let Some(addr) = remote_addr { - let aggregated_games = { - let mut db = ctx.peer_game_db.write().await; - db.update_peer_games(addr, games); - db.get_all_games() - }; - - if let Err(e) = ctx - .tx_notify_ui - .send(PeerEvent::ListGames(aggregated_games)) - { - log::error!("Failed to send ListGames event: {e}"); - } - } - } - } - } - - Some(Err(e)) => { - log::error!("{remote_addr:?} peer stream error: {e}"); - break; - } - None => { - log::trace!("{remote_addr:?} peer stream closed"); - break; - } - } - } - - Ok(()) -} - -async fn run_peer_discovery( - tx_notify_ui: UnboundedSender, - peer_game_db: Arc>, - local_peer_addr: Arc>>, -) { - log::info!("Starting peer discovery task"); - - let service_type = LANSPREAD_SERVICE_TYPE.to_string(); - - loop { - let (addr_tx, mut addr_rx) = tokio::sync::mpsc::unbounded_channel(); - let service_type_clone = service_type.clone(); - - let worker_handle = tokio::task::spawn_blocking(move || -> eyre::Result<()> { - let browser = MdnsBrowser::new(&service_type_clone)?; - loop { - if let Some(addr) = browser.next_address(None)? { - if addr_tx.send(addr).is_err() { - log::debug!("Peer discovery consumer dropped; stopping worker"); - break; - } - } else { - log::warn!("mDNS browser closed; stopping peer discovery worker"); - break; - } - } - Ok(()) - }); - - while let Some(peer_addr) = addr_rx.recv().await { - let is_self = { - let guard = local_peer_addr.read().await; - guard.as_ref().is_some_and(|addr| *addr == peer_addr) - }; - - if is_self { - log::trace!("Ignoring self advertisement at {peer_addr}"); - continue; - } - - let is_new_peer = { - let mut db = peer_game_db.write().await; - if db.contains_peer(&peer_addr) { - db.update_last_seen(&peer_addr); - false - } else { - db.add_peer(peer_addr); - true - } - }; - - if is_new_peer { - log::info!("Discovered peer at: {peer_addr}"); - - if let Err(e) = tx_notify_ui.send(PeerEvent::PeerDiscovered(peer_addr)) { - log::error!("Failed to send PeerDiscovered event: {e}"); - } - - let current_peer_count = { peer_game_db.read().await.get_peer_addresses().len() }; - if let Err(e) = tx_notify_ui.send(PeerEvent::PeerCountUpdated(current_peer_count)) { - log::error!("Failed to send PeerCountUpdated event: {e}"); - } - - let tx_notify_ui_clone = tx_notify_ui.clone(); - let peer_game_db_clone = peer_game_db.clone(); - tokio::spawn(async move { - if let Err(e) = request_games_from_peer( - peer_addr, - tx_notify_ui_clone, - peer_game_db_clone, - 0, - ) - .await - { - log::error!("Failed to request games from peer {peer_addr}: {e}"); - } - }); - } - } - - match worker_handle.await { - Ok(Ok(())) => { - log::warn!("Peer discovery worker exited; restarting shortly"); - } - Ok(Err(e)) => { - log::error!("Peer discovery worker failed: {e}"); - } - Err(e) => { - log::error!("Peer discovery worker join error: {e}"); - } - } - - tokio::time::sleep(Duration::from_secs(5)).await; - } -} - -async fn request_games_from_peer( - peer_addr: SocketAddr, - tx_notify_ui: UnboundedSender, - peer_game_db: Arc>, - mut retry_count: u32, -) -> eyre::Result<()> { - loop { - match fetch_games_from_peer(peer_addr).await { - Ok(games) => { - log::info!("Received {} games from peer {peer_addr}", games.len()); - - if games.is_empty() && retry_count < 1 { - log::info!("Received 0 games from peer {peer_addr}, scheduling retry in 5s"); - tokio::time::sleep(Duration::from_secs(5)).await; - retry_count += 1; - continue; - } - - let aggregated_games = { - let mut db = peer_game_db.write().await; - db.update_peer_games(peer_addr, games); - db.get_all_games() - }; - - if let Err(e) = tx_notify_ui.send(PeerEvent::ListGames(aggregated_games)) { - log::error!("Failed to send ListGames event: {e}"); - } - return Ok(()); - } - Err(e) => return Err(e), - } - } -} - -async fn fetch_games_from_peer(peer_addr: SocketAddr) -> eyre::Result> { - let mut conn = connect_to_peer(peer_addr).await?; - - let stream = conn.open_bidirectional_stream().await?; - let (rx, tx) = stream.split(); - let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new()); - let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); - - // Send ListGames request - framed_tx.send(Request::ListGames.encode()).await?; - let _ = framed_tx.close().await; - - // Receive response - let mut data = BytesMut::new(); - while let Some(Ok(bytes)) = framed_rx.next().await { - data.extend_from_slice(&bytes); - } - - let response = Response::decode(data.freeze()); - if let Response::ListGames(games) = response { - Ok(games) - } else { - log::warn!("Unexpected response from peer {peer_addr}: {response:?}"); - Ok(Vec::new()) // Treat unexpected response as empty list or error? - } -} - -async fn announce_games_to_peer(peer_addr: SocketAddr, games: Vec) -> eyre::Result<()> { - let mut conn = connect_to_peer(peer_addr).await?; - - let stream = conn.open_bidirectional_stream().await?; - let (_, tx) = stream.split(); - let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); - - // Send AnnounceGames request - framed_tx - .send(Request::AnnounceGames(games).encode()) - .await?; - let _ = framed_tx.close().await; - - Ok(()) -} - -async fn request_game_details_from_peer( - peer_addr: SocketAddr, - game_id: &str, - peer_game_db: Arc>, -) -> eyre::Result> { - let mut conn = connect_to_peer(peer_addr).await?; - - let stream = conn.open_bidirectional_stream().await?; - let (rx, tx) = stream.split(); - let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new()); - let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); - - framed_tx - .send( - Request::GetGame { - id: game_id.to_string(), - } - .encode(), - ) - .await?; - framed_tx.close().await?; - - let mut data = BytesMut::new(); - while let Some(Ok(bytes)) = framed_rx.next().await { - data.extend_from_slice(&bytes); - } - - let response = Response::decode(data.freeze()); - match response { - Response::GetGame { - id, - file_descriptions, - } => { - if id != game_id { - eyre::bail!("peer {peer_addr} responded with mismatched game id {id}"); - } - - { - let mut db = peer_game_db.write().await; - db.update_peer_game_files(peer_addr, game_id, file_descriptions.clone()); - } - - Ok(file_descriptions) - } - Response::GameNotFound(_) => { - eyre::bail!("peer {peer_addr} does not have game {game_id}") - } - Response::InternalPeerError(error_msg) => { - eyre::bail!("peer {peer_addr} reported internal error: {error_msg}") - } - _ => eyre::bail!("unexpected response from {peer_addr}: {response:?}"), - } -} - -async fn handle_active_downloads_without_peers( - peer_game_db: &Arc>, - downloading_games: &Arc>>, - active_downloads: &Arc>>>, - tx_notify_ui: &UnboundedSender, -) { - let active_ids: Vec = { downloading_games.read().await.iter().cloned().collect() }; - if active_ids.is_empty() { - return; - } - - for id in active_ids { - let has_peers = { - let guard = peer_game_db.read().await; - !guard.peers_with_game(&id).is_empty() - }; - - if has_peers { - continue; - } - - let removed_from_tracking = { - let mut guard = downloading_games.write().await; - guard.remove(&id) - }; - - if !removed_from_tracking { - continue; - } - - if let Some(handle) = { active_downloads.write().await.remove(&id) } { - handle.abort(); - } - - if let Err(e) = - tx_notify_ui.send(PeerEvent::DownloadGameFilesAllPeersGone { id: id.clone() }) - { - log::error!("Failed to send DownloadGameFilesAllPeersGone event: {e}"); - } - } -} - -#[allow(clippy::too_many_lines)] -async fn run_ping_service( - tx_notify_ui: UnboundedSender, - peer_game_db: Arc>, - downloading_games: Arc>>, - active_downloads: Arc>>>, -) { - log::info!( - "Starting ping service ({PEER_PING_INTERVAL_SECS}s interval, \ -{PEER_STALE_TIMEOUT_SECS}s timeout)" - ); - - let mut interval = tokio::time::interval(Duration::from_secs(PEER_PING_INTERVAL_SECS)); - - loop { - interval.tick().await; - - let peer_addresses = { peer_game_db.read().await.get_peer_addresses() }; - - for peer_addr in peer_addresses { - let tx_notify_ui_clone = tx_notify_ui.clone(); - let peer_game_db_clone = peer_game_db.clone(); - let downloading_games_clone = downloading_games.clone(); - let active_downloads_clone = active_downloads.clone(); - - tokio::spawn(async move { - match ping_peer(peer_addr).await { - Ok(is_alive) => { - if is_alive { - // Update last seen time - peer_game_db_clone - .write() - .await - .update_last_seen(&peer_addr); - } else { - log::warn!("Peer {peer_addr} failed ping check"); - - // Remove stale peer - let removed_peer = - peer_game_db_clone.write().await.remove_peer(&peer_addr); - if removed_peer.is_some() { - log::info!("Removed stale peer: {peer_addr}"); - if let Err(e) = - tx_notify_ui_clone.send(PeerEvent::PeerLost(peer_addr)) - { - log::error!("Failed to send PeerLost event: {e}"); - } - - // Send updated peer count - let current_peer_count = - { peer_game_db_clone.read().await.get_peer_addresses().len() }; - if let Err(e) = tx_notify_ui_clone - .send(PeerEvent::PeerCountUpdated(current_peer_count)) - { - log::error!("Failed to send PeerCountUpdated event: {e}"); - } - - emit_peer_game_list(&peer_game_db_clone, &tx_notify_ui_clone).await; - handle_active_downloads_without_peers( - &peer_game_db_clone, - &downloading_games_clone, - &active_downloads_clone, - &tx_notify_ui_clone, - ) - .await; - } - } - } - Err(e) => { - log::error!("Failed to ping peer {peer_addr}: {e}"); - - // Remove peer on error - let removed_peer = peer_game_db_clone.write().await.remove_peer(&peer_addr); - if removed_peer.is_some() { - log::info!("Removed peer due to ping error: {peer_addr}"); - if let Err(e) = tx_notify_ui_clone.send(PeerEvent::PeerLost(peer_addr)) - { - log::error!("Failed to send PeerLost event: {e}"); - } - - // Send updated peer count - let current_peer_count = - { peer_game_db_clone.read().await.get_peer_addresses().len() }; - if let Err(e) = tx_notify_ui_clone - .send(PeerEvent::PeerCountUpdated(current_peer_count)) - { - log::error!("Failed to send PeerCountUpdated event: {e}"); - } - - emit_peer_game_list(&peer_game_db_clone, &tx_notify_ui_clone).await; - handle_active_downloads_without_peers( - &peer_game_db_clone, - &downloading_games_clone, - &active_downloads_clone, - &tx_notify_ui_clone, - ) - .await; - } - } - } - }); - } - - // Also clean up stale peers - let stale_peers = { - peer_game_db - .read() - .await - .get_stale_peers(Duration::from_secs(PEER_STALE_TIMEOUT_SECS)) - }; - let mut removed_any = false; - for stale_addr in stale_peers { - let removed_peer = peer_game_db.write().await.remove_peer(&stale_addr); - if removed_peer.is_some() { - log::info!("Removed stale peer: {stale_addr}"); - if let Err(e) = tx_notify_ui.send(PeerEvent::PeerLost(stale_addr)) { - log::error!("Failed to send PeerLost event: {e}"); - } - - // Send updated peer count - let current_peer_count = { peer_game_db.read().await.get_peer_addresses().len() }; - if let Err(e) = tx_notify_ui.send(PeerEvent::PeerCountUpdated(current_peer_count)) { - log::error!("Failed to send PeerCountUpdated event: {e}"); - } - removed_any = true; - } - } - - if removed_any { - emit_peer_game_list(&peer_game_db, &tx_notify_ui).await; - handle_active_downloads_without_peers( - &peer_game_db, - &downloading_games, - &active_downloads, - &tx_notify_ui, - ) - .await; - } - } -} - -/// Monitor local game directory for changes and update the local game database -async fn run_local_game_monitor(tx_notify_ui: UnboundedSender, ctx: Ctx) { - log::info!("Starting local game directory monitor (5s interval)"); - - let mut interval = tokio::time::interval(Duration::from_secs(5)); - - loop { - interval.tick().await; - - let game_dir = { - let guard = ctx.game_dir.read().await; - guard.clone() - }; - - if let Some(ref game_dir) = game_dir { - match scan_local_games(game_dir).await { - Ok(current_games) => { - update_and_announce_games(&ctx, &tx_notify_ui, current_games).await; - } - Err(e) => { - log::error!("Failed to scan local games directory: {e}"); - } - } - } - } -} - -/// Scan the local games directory and return a `GameDB` with current games -async fn scan_local_games(game_dir: &str) -> eyre::Result { - load_local_game_db(game_dir).await -} - -async fn ping_peer(peer_addr: SocketAddr) -> eyre::Result { - let mut conn = connect_to_peer(peer_addr).await?; - - let is_alive = initial_peer_alive_check(&mut conn).await; - Ok(is_alive) -} - -fn select_advertise_ip() -> eyre::Result { - let mut best_candidate: Option<(u8, IpAddr)> = None; - let mut loopback_fallback = None; - - for interface in get_if_addrs()? { - if interface.is_loopback() { - loopback_fallback.get_or_insert(interface.ip()); - continue; - } - - if let Some(candidate) = classify_interface(&interface) - && best_candidate - .as_ref() - .is_none_or(|(rank, _)| candidate.0 < *rank) - { - best_candidate = Some(candidate); - } - } - - if let Some((_, ip)) = best_candidate { - return Ok(ip); - } - - if let Some(ip) = loopback_fallback { - log::warn!( - "No non-loopback interface suitable for mDNS advertisement; falling back to {ip}" - ); - return Ok(ip); - } - - eyre::bail!("No usable network interface found for mDNS advertisement"); -} - -fn classify_interface(interface: &Interface) -> Option<(u8, IpAddr)> { - match interface.addr { - IfAddr::V4(ref v4) => { - let ip = v4.ip; - - if ip.is_unspecified() || ip.is_link_local() { - return None; - } - - let mut rank = if ip.is_private() { 0 } else { 2 }; - - if is_virtual_interface(&interface.name) { - rank += 2; - } - - Some((rank, IpAddr::V4(ip))) - } - IfAddr::V6(_) => None, - } -} - -fn is_virtual_interface(name: &str) -> bool { - const VIRTUAL_HINTS: &[&str] = &[ - "awdl", - "br-", - "bridge", - "docker", - "ham", - "llw", - "tap", - "tailscale", - "tun", - "utun", - "vbox", - "veth", - "virbr", - "vmnet", - "wg", - "zt", - ]; - - let lower = name.to_ascii_lowercase(); - VIRTUAL_HINTS.iter().any(|hint| lower.contains(hint)) -} - -#[cfg(target_os = "windows")] -fn is_local_dir_name(name: &str) -> bool { - name.eq_ignore_ascii_case("local") -} - -#[cfg(not(target_os = "windows"))] -fn is_local_dir_name(name: &str) -> bool { - name == "local" -} - -async fn get_game_file_descriptions( - game_id: &str, - game_dir: &str, -) -> Result, PeerError> { - let base_dir = PathBuf::from(game_dir); - let game_path = base_dir.join(game_id); - - if !game_path.exists() { - return Err(PeerError::Other(eyre::eyre!( - "Game directory does not exist: {}", - game_path.display() - ))); - } - - let mut file_descriptions = Vec::new(); - - for entry in walkdir::WalkDir::new(&game_path) - .into_iter() - .filter_entry(|entry| { - if entry.depth() == 1 { - if entry.file_type().is_dir() - && entry.file_name().to_str().is_some_and(is_local_dir_name) - { - // Skip the local install folder entirely so WalkDir never enters it. - return false; - } - - if let Some(name) = entry.file_name().to_str() { - if entry.file_type().is_dir() && name == ".sync" { - return false; - } - if entry.file_type().is_file() && name == ".softlan_game_installed" { - return false; - } - } - } - - true - }) - .filter_map(std::result::Result::ok) - { - let relative_path = match entry.path().strip_prefix(&base_dir) { - Ok(path) => path.to_string_lossy().to_string(), - Err(e) => { - log::error!( - "Failed to get relative path for {}: {}", - entry.path().display(), - e - ); - continue; - } - }; - - let is_dir = entry.file_type().is_dir(); - let size = if is_dir { - 0 - } else { - match tokio::fs::metadata(entry.path()).await { - Ok(metadata) => metadata.len(), - Err(e) => { - log::error!("Failed to read metadata for {relative_path}: {e}"); - return Err(PeerError::FileSizeDetermination { - path: relative_path.clone(), - source: e, - }); - } - } - }; - - let file_desc = GameFileDescription { - game_id: game_id.to_string(), - relative_path, - is_dir, - size, - }; - - file_descriptions.push(file_desc); - } - - Ok(file_descriptions) -} - -#[cfg(test)] -mod tests { - use std::net::SocketAddr; - - use super::*; - - fn loopback_addr(port: u16) -> SocketAddr { - SocketAddr::from(([127, 0, 0, 1], port)) - } - - #[test] - fn build_peer_plans_handles_partial_final_chunk() { - let peers = vec![loopback_addr(12000), loopback_addr(12001)]; - let file_size = CHUNK_SIZE * 2 + CHUNK_SIZE / 4; - let mut file_peer_map = HashMap::new(); - file_peer_map.insert("game/file.dat".to_string(), peers.clone()); - let file_descs = vec![GameFileDescription { - game_id: "test".to_string(), - relative_path: "game/file.dat".to_string(), - is_dir: false, - size: file_size, - }]; - - let plans = build_peer_plans(&peers, &file_descs, &file_peer_map); - let mut chunks: Vec<_> = plans.values().flat_map(|plan| plan.chunks.iter()).collect(); - - assert_eq!(chunks.len(), 3, "expected three chunks for 2.25 blocks"); - - chunks.sort_by_key(|chunk| chunk.offset); - let last_chunk = chunks.last().expect("last chunk exists"); - - assert_eq!(last_chunk.offset, CHUNK_SIZE * 2); - assert_eq!(last_chunk.length, file_size - last_chunk.offset); - assert_eq!(last_chunk.length, CHUNK_SIZE / 4); - assert_eq!( - last_chunk.offset + last_chunk.length, - file_size, - "last chunk should finish the file" - ); - } - - #[test] - fn build_peer_plans_respects_file_peer_map() { - let shared_a = loopback_addr(12010); - let shared_b = loopback_addr(12011); - let exclusive = loopback_addr(12012); - let peers = vec![shared_a, shared_b, exclusive]; - - let mut file_peer_map = HashMap::new(); - file_peer_map.insert("shared.bin".to_string(), vec![shared_a, shared_b]); - file_peer_map.insert("exclusive.bin".to_string(), vec![exclusive]); - - let file_descs = vec![ - GameFileDescription { - game_id: "test".to_string(), - relative_path: "shared.bin".to_string(), - is_dir: false, - size: CHUNK_SIZE * 2, - }, - GameFileDescription { - game_id: "test".to_string(), - relative_path: "exclusive.bin".to_string(), - is_dir: false, - size: CHUNK_SIZE, - }, - ]; - - let plans = build_peer_plans(&peers, &file_descs, &file_peer_map); - let exclusive_plan = plans - .get(&exclusive) - .expect("exclusive peer should have a plan"); - assert!( - exclusive_plan - .chunks - .iter() - .all(|chunk| chunk.relative_path == "exclusive.bin"), - "exclusive peer should only receive exclusive.bin chunks" - ); - - for (peer, plan) in plans { - for chunk in plan.chunks { - match chunk.relative_path.as_str() { - "exclusive.bin" => assert_eq!( - peer, exclusive, - "exclusive.bin chunks should only be assigned to the exclusive peer" - ), - "shared.bin" => assert!( - peer == shared_a || peer == shared_b, - "shared.bin chunks must stay within shared peers" - ), - other => panic!("unexpected file in plan: {other}"), - } - } - } - } -} diff --git a/crates/lanspread-peer/src/local_games.rs b/crates/lanspread-peer/src/local_games.rs new file mode 100644 index 0000000..658c36b --- /dev/null +++ b/crates/lanspread-peer/src/local_games.rs @@ -0,0 +1,281 @@ +//! Local game scanning and database management. + +use std::{ + collections::HashSet, + io::ErrorKind, + path::{Path, PathBuf}, +}; + +use lanspread_db::db::{Game, GameDB, GameFileDescription}; + +use crate::error::PeerError; + +// ============================================================================= +// Local directory helpers +// ============================================================================= + +#[cfg(target_os = "windows")] +pub fn is_local_dir_name(name: &str) -> bool { + name.eq_ignore_ascii_case("local") +} + +#[cfg(not(target_os = "windows"))] +pub fn is_local_dir_name(name: &str) -> bool { + name == "local" +} + +/// Checks if a local directory has any content. +pub async fn local_dir_has_content(path: &Path) -> bool { + let local_dir = path.join("local"); + if tokio::fs::metadata(&local_dir).await.is_err() { + return false; + } + + let mut entries = match tokio::fs::read_dir(&local_dir).await { + Ok(entries) => entries, + Err(e) => { + log::warn!("Failed to read local dir {}: {e}", local_dir.display()); + return false; + } + }; + + match entries.next_entry().await { + Ok(Some(_)) => true, + Ok(None) => false, + Err(e) => { + log::warn!("Failed to iterate local dir {}: {e}", local_dir.display()); + false + } + } +} + +/// Checks if a game is available for download locally. +pub async fn local_download_available( + game_dir: &str, + game_id: &str, + downloading_games: &HashSet, +) -> bool { + if downloading_games.contains(game_id) { + log::debug!("Not serving game {game_id} locally because it is still downloading"); + return false; + } + + let game_path = PathBuf::from(game_dir).join(game_id); + let eti_path = game_path.join(format!("{game_id}.eti")); + + if tokio::fs::metadata(&eti_path).await.is_err() { + return false; + } + + // Only treat as pending install if the local installation directory is empty/missing + !local_dir_has_content(game_path.as_path()).await +} + +// ============================================================================= +// Directory size calculation +// ============================================================================= + +/// Calculates the total size of a directory recursively. +pub async fn calculate_directory_size(dir: &Path, is_root: bool) -> eyre::Result { + let mut total_size = 0u64; + let mut entries = tokio::fs::read_dir(dir).await?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + if is_root { + if name_str == ".sync" || name_str == ".softlan_first_start_done" { + continue; + } + if entry.file_type().await?.is_dir() && is_local_dir_name(&name_str) { + continue; + } + } + + let metadata = tokio::fs::metadata(&path).await?; + + if metadata.is_dir() { + total_size += Box::pin(calculate_directory_size(&path, false)).await?; + } else { + total_size += metadata.len(); + } + } + + Ok(total_size) +} + +// ============================================================================= +// Game database loading +// ============================================================================= + +/// Loads the local game database from the game directory. +pub async fn load_local_game_db(game_dir: &str) -> eyre::Result { + let game_path = PathBuf::from(game_dir); + + let metadata = match tokio::fs::metadata(&game_path).await { + Ok(metadata) => metadata, + Err(err) => { + if err.kind() == ErrorKind::NotFound { + log::warn!( + "Local game directory {} missing; reporting empty game database", + game_path.display() + ); + return Ok(GameDB::empty()); + } + return Err(err.into()); + } + }; + + if !metadata.is_dir() { + log::warn!( + "Configured game directory {} is not a directory; reporting empty game database", + game_path.display() + ); + return Ok(GameDB::empty()); + } + + let mut games = Vec::new(); + + // Scan game directory and create entries for installed games + let mut entries = tokio::fs::read_dir(&game_path).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.is_dir() + && let Some(game_id) = path.file_name().and_then(|n| n.to_str()) + { + let eti_path = path.join(format!("{game_id}.eti")); + let downloaded = tokio::fs::metadata(&eti_path).await.is_ok(); + if !downloaded { + continue; + } + + let installed = local_dir_has_content(&path).await; + let local_version = if installed { + match lanspread_db::db::read_version_from_ini(&path) { + Ok(version) => version, + Err(e) => { + log::warn!("Failed to read version.ini for installed game {game_id}: {e}"); + None + } + } + } else { + None + }; + + let size = calculate_directory_size(&path, true).await?; + let game = Game { + id: game_id.to_string(), + name: game_id.to_string(), + description: String::new(), + release_year: String::new(), + publisher: String::new(), + max_players: 1, + version: "1.0".to_string(), + genre: String::new(), + size, + downloaded, + installed, + eti_game_version: local_version.clone(), + local_version, + peer_count: 0, // Local games start with 0 peers + }; + games.push(game); + } + } + + Ok(GameDB::from(games)) +} + +/// Scans the local games directory and returns a `GameDB` with current games. +pub async fn scan_local_games(game_dir: &str) -> eyre::Result { + load_local_game_db(game_dir).await +} + +// ============================================================================= +// Game file descriptions +// ============================================================================= + +/// Gets file descriptions for a game from the local filesystem. +pub async fn get_game_file_descriptions( + game_id: &str, + game_dir: &str, +) -> Result, PeerError> { + let base_dir = PathBuf::from(game_dir); + let game_path = base_dir.join(game_id); + + if !game_path.exists() { + return Err(PeerError::Other(eyre::eyre!( + "Game directory does not exist: {}", + game_path.display() + ))); + } + + let mut file_descriptions = Vec::new(); + + for entry in walkdir::WalkDir::new(&game_path) + .into_iter() + .filter_entry(|entry| { + if entry.depth() == 1 { + if entry.file_type().is_dir() + && entry.file_name().to_str().is_some_and(is_local_dir_name) + { + // Skip the local install folder entirely so WalkDir never enters it. + return false; + } + + if let Some(name) = entry.file_name().to_str() { + if entry.file_type().is_dir() && name == ".sync" { + return false; + } + if entry.file_type().is_file() && name == ".softlan_game_installed" { + return false; + } + } + } + + true + }) + .filter_map(std::result::Result::ok) + { + let relative_path = match entry.path().strip_prefix(&base_dir) { + Ok(path) => path.to_string_lossy().to_string(), + Err(e) => { + log::error!( + "Failed to get relative path for {}: {}", + entry.path().display(), + e + ); + continue; + } + }; + + let is_dir = entry.file_type().is_dir(); + let size = if is_dir { + 0 + } else { + match tokio::fs::metadata(entry.path()).await { + Ok(metadata) => metadata.len(), + Err(e) => { + log::error!("Failed to read metadata for {relative_path}: {e}"); + return Err(PeerError::FileSizeDetermination { + path: relative_path.clone(), + source: e, + }); + } + } + }; + + let file_desc = GameFileDescription { + game_id: game_id.to_string(), + relative_path, + is_dir, + size, + }; + + file_descriptions.push(file_desc); + } + + Ok(file_descriptions) +} diff --git a/crates/lanspread-peer/src/network.rs b/crates/lanspread-peer/src/network.rs new file mode 100644 index 0000000..7f1c2b4 --- /dev/null +++ b/crates/lanspread-peer/src/network.rs @@ -0,0 +1,256 @@ +//! Network utilities for QUIC connections and peer communication. + +use std::{ + net::{IpAddr, SocketAddr}, + time::Duration, +}; + +use bytes::BytesMut; +use futures::{SinkExt, StreamExt}; +use if_addrs::{IfAddr, Interface, get_if_addrs}; +use lanspread_db::db::{Game, GameFileDescription}; +use lanspread_proto::{Message, Request, Response}; +use s2n_quic::{Client as QuicClient, Connection, client::Connect, provider::limits::Limits}; +use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec}; + +use crate::config::CERT_PEM; + +/// Establishes a QUIC connection to a peer. +pub async fn connect_to_peer(addr: SocketAddr) -> eyre::Result { + let limits = Limits::default().with_max_handshake_duration(Duration::from_secs(3))?; + + let client = QuicClient::builder() + .with_tls(CERT_PEM)? + .with_io("0.0.0.0:0")? + .with_limits(limits)? + .start()?; + + let conn = Connect::new(addr).with_server_name("localhost"); + let conn = client.connect(conn).await?; + Ok(conn) +} + +/// Performs an initial ping check to verify peer is alive. +pub async fn initial_peer_alive_check(conn: &mut Connection) -> bool { + let remote_addr = conn.remote_addr().ok(); + + let stream = match conn.open_bidirectional_stream().await { + Ok(stream) => stream, + Err(e) => { + log::error!("{remote_addr:?} failed to open stream: {e}"); + return false; + } + }; + + let (rx, tx) = stream.split(); + let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new()); + let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); + + // send ping + if let Err(e) = framed_tx.send(Request::Ping.encode()).await { + log::error!("{remote_addr:?} failed to send ping to peer: {e}"); + return false; + } + let _ = framed_tx.close().await; + + // receive pong + if let Some(Ok(response_bytes)) = framed_rx.next().await { + let response = Response::decode(response_bytes.freeze()); + match response { + Response::Pong => { + log::trace!("{remote_addr:?} peer is alive"); + return true; + } + _ => { + log::error!("{remote_addr:?} peer sent invalid response to ping: {response:?}"); + } + } + } + + false +} + +/// Pings a peer to check if it's alive. +pub async fn ping_peer(peer_addr: SocketAddr) -> eyre::Result { + let mut conn = connect_to_peer(peer_addr).await?; + let is_alive = initial_peer_alive_check(&mut conn).await; + Ok(is_alive) +} + +/// Fetches the list of games from a peer. +pub async fn fetch_games_from_peer(peer_addr: SocketAddr) -> eyre::Result> { + let mut conn = connect_to_peer(peer_addr).await?; + + let stream = conn.open_bidirectional_stream().await?; + let (rx, tx) = stream.split(); + let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new()); + let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); + + // Send ListGames request + framed_tx.send(Request::ListGames.encode()).await?; + let _ = framed_tx.close().await; + + // Receive response + let mut data = BytesMut::new(); + while let Some(Ok(bytes)) = framed_rx.next().await { + data.extend_from_slice(&bytes); + } + + let response = Response::decode(data.freeze()); + if let Response::ListGames(games) = response { + Ok(games) + } else { + log::warn!("Unexpected response from peer {peer_addr}: {response:?}"); + Ok(Vec::new()) + } +} + +/// Announces local games to a peer. +pub async fn announce_games_to_peer(peer_addr: SocketAddr, games: Vec) -> eyre::Result<()> { + let mut conn = connect_to_peer(peer_addr).await?; + + let stream = conn.open_bidirectional_stream().await?; + let (_, tx) = stream.split(); + let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); + + // Send AnnounceGames request + framed_tx + .send(Request::AnnounceGames(games).encode()) + .await?; + let _ = framed_tx.close().await; + + Ok(()) +} + +/// Requests game file details from a peer. +pub async fn request_game_details_from_peer( + peer_addr: SocketAddr, + game_id: &str, +) -> eyre::Result<(Vec, Response)> { + let mut conn = connect_to_peer(peer_addr).await?; + + let stream = conn.open_bidirectional_stream().await?; + let (rx, tx) = stream.split(); + let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new()); + let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); + + framed_tx + .send( + Request::GetGame { + id: game_id.to_string(), + } + .encode(), + ) + .await?; + framed_tx.close().await?; + + let mut data = BytesMut::new(); + while let Some(Ok(bytes)) = framed_rx.next().await { + data.extend_from_slice(&bytes); + } + + let response = Response::decode(data.freeze()); + match &response { + Response::GetGame { + id, + file_descriptions, + } => { + if id != game_id { + eyre::bail!("peer {peer_addr} responded with mismatched game id {id}"); + } + Ok((file_descriptions.clone(), response)) + } + Response::GameNotFound(_) => { + eyre::bail!("peer {peer_addr} does not have game {game_id}") + } + Response::InternalPeerError(error_msg) => { + eyre::bail!("peer {peer_addr} reported internal error: {error_msg}") + } + _ => eyre::bail!("unexpected response from {peer_addr}: {response:?}"), + } +} + +// ============================================================================= +// IP address selection for mDNS advertisement +// ============================================================================= + +/// Selects the best IP address to advertise via mDNS. +pub fn select_advertise_ip() -> eyre::Result { + let mut best_candidate: Option<(u8, IpAddr)> = None; + let mut loopback_fallback = None; + + for interface in get_if_addrs()? { + if interface.is_loopback() { + loopback_fallback.get_or_insert(interface.ip()); + continue; + } + + if let Some(candidate) = classify_interface(&interface) + && best_candidate + .as_ref() + .is_none_or(|(rank, _)| candidate.0 < *rank) + { + best_candidate = Some(candidate); + } + } + + if let Some((_, ip)) = best_candidate { + return Ok(ip); + } + + if let Some(ip) = loopback_fallback { + log::warn!( + "No non-loopback interface suitable for mDNS advertisement; falling back to {ip}" + ); + return Ok(ip); + } + + eyre::bail!("No usable network interface found for mDNS advertisement"); +} + +/// Classifies a network interface for mDNS advertisement priority. +fn classify_interface(interface: &Interface) -> Option<(u8, IpAddr)> { + match interface.addr { + IfAddr::V4(ref v4) => { + let ip = v4.ip; + + if ip.is_unspecified() || ip.is_link_local() { + return None; + } + + let mut rank = if ip.is_private() { 0 } else { 2 }; + + if is_virtual_interface(&interface.name) { + rank += 2; + } + + Some((rank, IpAddr::V4(ip))) + } + IfAddr::V6(_) => None, + } +} + +/// Checks if an interface name suggests it's a virtual interface. +fn is_virtual_interface(name: &str) -> bool { + const VIRTUAL_HINTS: &[&str] = &[ + "awdl", + "br-", + "bridge", + "docker", + "ham", + "llw", + "tap", + "tailscale", + "tun", + "utun", + "vbox", + "veth", + "virbr", + "vmnet", + "wg", + "zt", + ]; + + let lower = name.to_ascii_lowercase(); + VIRTUAL_HINTS.iter().any(|hint| lower.contains(hint)) +} diff --git a/crates/lanspread-peer/src/peer_db.rs b/crates/lanspread-peer/src/peer_db.rs new file mode 100644 index 0000000..cdafdd2 --- /dev/null +++ b/crates/lanspread-peer/src/peer_db.rs @@ -0,0 +1,502 @@ +//! Peer database and consensus validation for tracking remote peers and their games. + +use std::{ + cmp::Reverse, + collections::HashMap, + net::SocketAddr, + time::{Duration, Instant}, +}; + +use lanspread_db::db::{Game, GameFileDescription}; + +/// Information about a discovered peer. +#[derive(Clone, Debug)] +pub struct PeerInfo { + /// Network address of the peer. + pub addr: SocketAddr, + /// Last time we heard from this peer. + pub last_seen: Instant, + /// Games this peer has available, keyed by game ID. + pub games: HashMap, + /// File descriptions for each game, keyed by game ID. + pub files: HashMap>, +} + +/// Database tracking all discovered peers and their games. +#[derive(Debug)] +pub struct PeerGameDB { + peers: HashMap, +} + +impl Default for PeerGameDB { + fn default() -> Self { + Self::new() + } +} + +impl PeerGameDB { + #[must_use] + pub fn new() -> Self { + Self { + peers: HashMap::new(), + } + } + + /// Adds a new peer to the database. + pub fn add_peer(&mut self, addr: SocketAddr) { + let peer_info = PeerInfo { + addr, + last_seen: Instant::now(), + games: HashMap::new(), + files: HashMap::new(), + }; + self.peers.insert(addr, peer_info); + log::info!("Added peer: {addr}"); + } + + /// Removes a peer from the database. + pub fn remove_peer(&mut self, addr: &SocketAddr) -> Option { + self.peers.remove(addr) + } + + /// Updates the games list for a peer. + pub fn update_peer_games(&mut self, addr: SocketAddr, games: Vec) { + if let Some(peer) = self.peers.get_mut(&addr) { + let mut map = HashMap::with_capacity(games.len()); + for game in games { + map.insert(game.id.clone(), game); + } + peer.games = map; + peer.last_seen = Instant::now(); + log::info!("Updated games for peer: {addr}"); + } + } + + /// Updates the file descriptions for a specific game from a peer. + pub fn update_peer_game_files( + &mut self, + addr: SocketAddr, + game_id: &str, + files: Vec, + ) { + if let Some(peer) = self.peers.get_mut(&addr) { + peer.files.insert(game_id.to_string(), files); + peer.last_seen = Instant::now(); + } + } + + /// Updates the last seen timestamp for a peer. + pub fn update_last_seen(&mut self, addr: &SocketAddr) { + if let Some(peer) = self.peers.get_mut(addr) { + peer.last_seen = Instant::now(); + } + } + + /// Returns all games aggregated from all peers. + #[must_use] + pub fn get_all_games(&self) -> Vec { + let mut aggregated: HashMap = HashMap::new(); + let mut peer_counts: HashMap = HashMap::new(); + + // Count peers per game + for peer in self.peers.values() { + for game_id in peer.games.keys() { + *peer_counts.entry(game_id.clone()).or_insert(0) += 1; + } + } + + // Aggregate games with peer counts + for peer in self.peers.values() { + for game in peer.games.values() { + aggregated + .entry(game.id.clone()) + .and_modify(|existing| { + if let (Some(new_version), Some(current)) = + (&game.eti_game_version, &existing.eti_game_version) + { + if new_version > current { + existing.eti_game_version = Some(new_version.clone()); + } + } else if existing.eti_game_version.is_none() { + existing.eti_game_version.clone_from(&game.eti_game_version); + } + // Update peer count + existing.peer_count = peer_counts[&game.id]; + }) + .or_insert_with(|| { + let mut game_clone = game.clone(); + game_clone.peer_count = peer_counts[&game.id]; + game_clone + }); + } + } + + let mut games: Vec = aggregated.into_values().collect(); + games.sort_by(|a, b| a.name.cmp(&b.name)); + games + } + + /// Returns the latest version of a game across all peers. + #[must_use] + pub fn get_latest_version_for_game(&self, game_id: &str) -> Option { + let mut latest_version: Option = None; + + for peer in self.peers.values() { + if let Some(game) = peer.games.get(game_id) + && let Some(ref version) = game.eti_game_version + { + match &latest_version { + None => latest_version = Some(version.clone()), + Some(current_latest) => { + if version > current_latest { + latest_version = Some(version.clone()); + } + } + } + } + } + + latest_version + } + + /// Returns all peer addresses. + #[must_use] + pub fn get_peer_addresses(&self) -> Vec { + self.peers.keys().copied().collect() + } + + /// Checks if a peer is in the database. + #[must_use] + pub fn contains_peer(&self, addr: &SocketAddr) -> bool { + self.peers.contains_key(addr) + } + + /// Returns addresses of peers that have a specific game. + #[must_use] + pub fn peers_with_game(&self, game_id: &str) -> Vec { + self.peers + .iter() + .filter(|(_, peer)| peer.games.contains_key(game_id)) + .map(|(addr, _)| *addr) + .collect() + } + + /// Returns addresses of peers that have the latest version of a game. + #[must_use] + pub fn peers_with_latest_version(&self, game_id: &str) -> Vec { + let latest_version = self.get_latest_version_for_game(game_id); + + if let Some(ref latest) = latest_version { + self.peers + .iter() + .filter(|(_, peer)| { + if let Some(game) = peer.games.get(game_id) { + if let Some(ref version) = game.eti_game_version { + version == latest + } else { + false + } + } else { + false + } + }) + .map(|(addr, _)| *addr) + .collect() + } else { + // If no version info is available, fall back to all peers with the game + self.peers_with_game(game_id) + } + } + + /// Returns file descriptions for a game from all peers. + #[must_use] + pub fn game_files_for(&self, game_id: &str) -> Vec<(SocketAddr, Vec)> { + self.peers + .iter() + .filter_map(|(addr, peer)| peer.files.get(game_id).cloned().map(|files| (*addr, files))) + .collect() + } + + /// Returns aggregated file descriptions for a game across all peers. + #[must_use] + pub fn aggregated_game_files(&self, game_id: &str) -> Vec { + let mut seen: HashMap = HashMap::new(); + for (_, files) in self.game_files_for(game_id) { + for file in files { + seen.entry(file.relative_path.clone()).or_insert(file); + } + } + seen.into_values().collect() + } + + /// Returns the majority-agreed size for a game. + #[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 + /// `file_peer_map` lists which peers were validated for each file. + pub fn validate_file_sizes_majority( + &self, + game_id: &str, + ) -> eyre::Result { + let game_files = self.game_files_for(game_id); + if game_files.is_empty() { + return Ok((Vec::new(), Vec::new(), HashMap::new())); + } + + let (file_size_map, _peer_files) = collect_file_sizes(&game_files); + let (validated_files, peer_scores, file_peer_map) = + self.validate_each_file_consensus(game_id, file_size_map)?; + let peer_whitelist = create_peer_whitelist(peer_scores); + + Ok((validated_files, peer_whitelist, file_peer_map)) + } + + /// Validates consensus for each file and returns validated files with peer scores. + fn validate_each_file_consensus( + &self, + game_id: &str, + file_size_map: FileSizeMap, + ) -> eyre::Result { + let mut validated_files = Vec::new(); + let mut peer_whitelist_scores: HashMap = HashMap::new(); + let mut file_peer_map: HashMap> = HashMap::new(); + + for (relative_path, size_map) in file_size_map { + let total_peers: usize = size_map.values().map(Vec::len).sum(); + + if total_peers == 0 { + continue; // Skip files with no size information + } + + let (consensus_size, consensus_peers) = + self.determine_size_consensus(&size_map, total_peers, &relative_path)?; + update_peer_scores(&consensus_peers, &mut peer_whitelist_scores); + + if let Some((size, peers)) = consensus_size + && let Some(file_desc) = + self.create_validated_file_description(game_id, &relative_path, size, &peers) + { + file_peer_map.insert(relative_path.clone(), peers.clone()); + validated_files.push(file_desc); + } + } + + Ok((validated_files, peer_whitelist_scores, file_peer_map)) + } + + /// Determines the consensus size for a file based on peer reports. + /// + /// # Panics + /// + /// Panics if `size_map.iter().next()` returns None when `total_peers` == 1 + #[allow(clippy::unused_self)] + fn determine_size_consensus( + &self, + size_map: &HashMap>, + total_peers: usize, + relative_path: &str, + ) -> eyre::Result<(ConsensusResult, Vec)> { + if total_peers == 1 { + // Only one peer has this file - trust it + let (&size, peers) = size_map + .iter() + .next() + .expect("size_map should have at least one entry when total_peers == 1"); + return Ok((Some((size, peers.clone())), peers.clone())); + } + + let (majority_size, _majority_count) = find_majority_size(size_map); + + if let Some(size) = majority_size { + let majority_peers = &size_map[&size]; + let is_majority = majority_peers.len() > total_peers / 2; + + if is_majority { + // We have a clear majority + Ok((Some((size, majority_peers.clone())), majority_peers.clone())) + } else if total_peers == 2 { + // Two peers with different sizes - ambiguous, fail + eyre::bail!( + "File size ambiguity for '{}': two peers report different sizes, cannot determine majority", + relative_path + ); + } + // If no majority and more than 2 peers, we fall back to plurality (largest group) + else { + Ok((Some((size, majority_peers.clone())), majority_peers.clone())) + } + } else { + // No clear majority and it's a tie between different sizes + if total_peers == 2 { + eyre::bail!( + "File size ambiguity for '{}': two peers report different sizes, cannot determine majority", + relative_path + ); + } + // For more than 2 peers, we could fall back to plurality, but for now let's be strict + eyre::bail!( + "File size ambiguity for '{}': no clear majority among {} peers", + relative_path, + total_peers + ); + } + } + + /// Creates a validated file description from consensus data. + fn create_validated_file_description( + &self, + game_id: &str, + relative_path: &str, + size: u64, + peers: &[SocketAddr], + ) -> Option { + if let Some(first_peer) = peers.first() + && let Some(files) = self + .peers + .get(first_peer) + .and_then(|p| p.files.get(game_id)) + && let Some(file_desc) = files + .iter() + .find(|f| f.relative_path == relative_path && f.size == size) + { + return Some(file_desc.clone()); + } + None + } + + /// Returns peers that haven't been seen within the timeout duration. + #[must_use] + pub fn get_stale_peers(&self, timeout: Duration) -> Vec { + self.peers + .iter() + .filter(|(_, peer)| peer.last_seen.elapsed() > timeout) + .map(|(addr, _)| *addr) + .collect() + } +} + +// ============================================================================= +// Type aliases for consensus validation +// ============================================================================= + +/// Type alias for file size mapping: path -> size -> peers +type FileSizeMap = HashMap>>; + +/// Type alias for peer file mapping: peer -> path -> size +type PeerFileMap = HashMap>; + +/// Type alias for consensus result: (size, peers) or None +type ConsensusResult = Option<(u64, Vec)>; + +/// Type alias for the aggregated majority validation result. +pub type MajorityValidationResult = ( + Vec, + Vec, + HashMap>, +); + +/// Type alias for per-file consensus aggregation results. +type FileConsensusAggregation = ( + Vec, + HashMap, + HashMap>, +); + +// ============================================================================= +// Helper functions for consensus validation +// ============================================================================= + +/// Collects file sizes from all peers and organizes them by path and size. +fn collect_file_sizes( + game_files: &[(SocketAddr, Vec)], +) -> (FileSizeMap, PeerFileMap) { + let mut file_size_map: FileSizeMap = HashMap::new(); + let mut peer_files: PeerFileMap = HashMap::new(); + + for (peer_addr, files) in game_files { + let mut peer_file_sizes = HashMap::new(); + for file in files { + if !file.is_dir { + let size = file.size; + file_size_map + .entry(file.relative_path.clone()) + .or_default() + .entry(size) + .or_default() + .push(*peer_addr); + peer_file_sizes.insert(file.relative_path.clone(), size); + } + } + peer_files.insert(*peer_addr, peer_file_sizes); + } + + (file_size_map, peer_files) +} + +/// Finds the majority size from a map of sizes to peer lists. +fn find_majority_size(size_map: &HashMap>) -> (Option, usize) { + let mut majority_size = None; + let mut majority_count = 0; + + for (&size, peers) in size_map { + let count = peers.len(); + if count > majority_count { + majority_count = count; + majority_size = Some(size); + } else if count == majority_count { + // Tie between different sizes - ambiguous, fail + majority_size = None; + break; + } + } + + (majority_size, majority_count) +} + +/// Updates peer scores based on consensus participation. +fn update_peer_scores( + peers: &[SocketAddr], + peer_whitelist_scores: &mut HashMap, +) { + for &peer in peers { + *peer_whitelist_scores.entry(peer).or_insert(0) += 1; + } +} + +/// Creates a peer whitelist from scores, including peers with the highest scores. +fn create_peer_whitelist(peer_scores: HashMap) -> Vec { + if peer_scores.is_empty() { + return Vec::new(); + } + + let mut peers: Vec<_> = peer_scores + .into_iter() + .filter_map(|(peer, score)| (score > 0).then_some((peer, score))) + .collect(); + + peers.sort_by_key(|(peer, score)| (Reverse(*score), *peer)); + + peers.into_iter().map(|(peer, _)| peer).collect() +} diff --git a/crates/lanspread-peer/src/services.rs b/crates/lanspread-peer/src/services.rs new file mode 100644 index 0000000..7497300 --- /dev/null +++ b/crates/lanspread-peer/src/services.rs @@ -0,0 +1,731 @@ +//! Background services for the peer system. + +use std::{ + collections::{HashMap, HashSet}, + net::SocketAddr, + path::PathBuf, + sync::Arc, + time::Duration, +}; + +use futures::{SinkExt, StreamExt}; +use lanspread_db::db::Game; +use lanspread_mdns::{LANSPREAD_SERVICE_TYPE, MdnsAdvertiser, MdnsBrowser}; +use lanspread_proto::{Message, Request, Response}; +use s2n_quic::{Connection, Server, provider::limits::Limits, stream::BidirectionalStream}; +use tokio::{ + sync::{RwLock, mpsc::UnboundedSender}, + task::JoinHandle, +}; +use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec}; +use uuid::Uuid; + +use crate::{ + PeerEvent, + config::{ + CERT_PEM, + KEY_PEM, + LOCAL_GAME_MONITOR_INTERVAL_SECS, + PEER_PING_INTERVAL_SECS, + peer_stale_timeout, + }, + context::{Ctx, PeerCtx}, + error::PeerError, + handlers::{emit_peer_game_list, update_and_announce_games}, + local_games::{get_game_file_descriptions, scan_local_games}, + network::{fetch_games_from_peer, ping_peer, select_advertise_ip}, + peer::{send_game_file_chunk, send_game_file_data}, + peer_db::PeerGameDB, +}; + +// ============================================================================= +// Server component +// ============================================================================= + +/// Runs the QUIC server and mDNS advertiser. +pub async fn run_server_component( + addr: SocketAddr, + ctx: PeerCtx, + tx_notify_ui: UnboundedSender, +) -> eyre::Result<()> { + let limits = Limits::default() + .with_max_handshake_duration(Duration::from_secs(3))? + .with_max_idle_timeout(Duration::from_secs(3))?; + + let mut server = Server::builder() + .with_tls((CERT_PEM, KEY_PEM))? + .with_io(addr)? + .with_limits(limits)? + .start()?; + + let server_addr = server.local_addr()?; + log::info!("Peer server listening on {server_addr}"); + + let advertise_ip = select_advertise_ip()?; + let advertise_addr = SocketAddr::new(advertise_ip, server_addr.port()); + log::info!("Advertising peer via mDNS from {advertise_addr}"); + { + let mut guard = ctx.local_peer_addr.write().await; + *guard = Some(advertise_addr); + } + + // Start mDNS advertising for peer discovery + let peer_id = Uuid::now_v7().simple().to_string(); + let hostname = gethostname::gethostname(); + let hostname_str = hostname.to_str().unwrap_or(""); + + // Calculate maximum hostname length that fits with UUID in 63 char limit + let max_hostname_len = 63usize.saturating_sub(peer_id.len() + 1); + let truncated_hostname = if hostname_str.len() > max_hostname_len { + hostname_str.get(..max_hostname_len).unwrap_or(hostname_str) + } else { + hostname_str + }; + + let combined_str = if truncated_hostname.is_empty() { + peer_id + } else { + format!("{truncated_hostname}-{peer_id}") + }; + + let mdns = tokio::task::spawn_blocking(move || { + MdnsAdvertiser::new(LANSPREAD_SERVICE_TYPE, &combined_str, advertise_addr) + }) + .await??; + + // Monitor mDNS events + let _tx_notify_ui_mdns = tx_notify_ui.clone(); + let hostname = truncated_hostname.to_string(); + tokio::spawn(async move { + log::info!("Registering mDNS service with hostname: {hostname}"); + while let Ok(event) = mdns.monitor.recv() { + match event { + lanspread_mdns::DaemonEvent::Error(e) => { + log::error!("mDNS error: {e}"); + tokio::time::sleep(Duration::from_secs(1)).await; + } + _ => { + log::trace!("mDNS event: {event:?}"); + } + } + } + }); + + while let Some(connection) = server.accept().await { + let ctx = ctx.clone(); + let tx_notify_ui = tx_notify_ui.clone(); + + tokio::spawn(async move { + if let Err(e) = handle_peer_connection(connection, ctx, tx_notify_ui).await { + log::error!("Peer connection error: {e}"); + } + }); + } + + Ok(()) +} + +/// Handles an incoming peer connection. +async fn handle_peer_connection( + mut connection: Connection, + ctx: PeerCtx, + tx_notify_ui: UnboundedSender, +) -> eyre::Result<()> { + let remote_addr = connection.remote_addr()?; + log::info!("{remote_addr} peer connected"); + + if let Err(e) = tx_notify_ui.send(PeerEvent::PeerConnected(remote_addr)) { + log::error!("Failed to send PeerConnected event: {e}"); + } + + // handle streams + while let Ok(Some(stream)) = connection.accept_bidirectional_stream().await { + let ctx = ctx.clone(); + let remote_addr = Some(remote_addr); + + tokio::spawn(async move { + if let Err(e) = handle_peer_stream(stream, ctx, remote_addr).await { + log::error!("{remote_addr:?} peer stream error: {e}"); + } + }); + } + + if let Err(e) = tx_notify_ui.send(PeerEvent::PeerDisconnected(remote_addr)) { + log::error!("Failed to send PeerDisconnected event: {e}"); + } + + Ok(()) +} + +/// Handles a bidirectional stream from a peer. +#[allow(clippy::too_many_lines)] +async fn handle_peer_stream( + stream: BidirectionalStream, + ctx: PeerCtx, + remote_addr: Option, +) -> eyre::Result<()> { + let (rx, tx) = stream.split(); + let mut framed_rx = FramedRead::new(rx, LengthDelimitedCodec::new()); + let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); + + log::trace!("{remote_addr:?} peer stream opened"); + + // handle streams + loop { + match framed_rx.next().await { + Some(Ok(data)) => { + log::trace!( + "{:?} msg: (raw): {}", + remote_addr, + String::from_utf8_lossy(&data) + ); + + let request = Request::decode(data.freeze()); + log::debug!("{remote_addr:?} msg: {request:?}"); + + match request { + Request::Ping => { + // Respond with pong + if let Err(e) = framed_tx.send(Response::Pong.encode()).await { + log::error!("Failed to send pong: {e}"); + } + } + Request::ListGames => { + // Return list of games from this peer + log::info!("Received ListGames request from peer"); + let snapshot = { + let db_guard = ctx.local_game_db.read().await; + if let Some(ref db) = *db_guard { + db.all_games().into_iter().cloned().collect::>() + } else { + // Local database not loaded yet, return empty result + log::info!( + "Local game database not yet loaded, responding with empty game list" + ); + Vec::new() + } + }; + let games = if snapshot.is_empty() { + snapshot + } else { + let downloading = ctx.downloading_games.read().await; + snapshot + .into_iter() + .filter(|game| !downloading.contains(&game.id)) + .collect() + }; + + if let Err(e) = framed_tx.send(Response::ListGames(games).encode()).await { + log::error!("Failed to send ListGames response: {e}"); + } + } + Request::GetGame { id } => { + log::info!("Received GetGame request for {id} from peer"); + let downloading = ctx.downloading_games.read().await.contains(&id); + let response = if downloading { + log::info!( + "Declining to serve GetGame for {id} because download is in progress" + ); + Response::GameNotFound(id) + } else if let Some(ref game_dir) = *ctx.game_dir.read().await { + if let Some(ref db) = *ctx.local_game_db.read().await { + if db.get_game_by_id(&id).is_some() { + match get_game_file_descriptions(&id, game_dir).await { + Ok(file_descriptions) => Response::GetGame { + id, + file_descriptions, + }, + Err(PeerError::FileSizeDetermination { path, source }) => { + let error_msg = format!( + "Failed to determine file size for {path}: {source}" + ); + log::error!( + "File size determination error for game {id}: {error_msg}" + ); + Response::InternalPeerError(error_msg) + } + Err(e) => { + log::error!( + "Failed to get game file descriptions for {id}: {e}" + ); + Response::GameNotFound(id) + } + } + } else { + Response::GameNotFound(id) + } + } else { + Response::GameNotFound(id) + } + } else { + Response::GameNotFound(id) + }; + + if let Err(e) = framed_tx.send(response.encode()).await { + log::error!("Failed to send GetGame response: {e}"); + } + } + Request::GetGameFileData(desc) => { + log::info!( + "Received GetGameFileData request for {} from peer", + desc.relative_path + ); + + let maybe_game_dir = ctx.game_dir.read().await.clone(); + if let Some(game_dir) = maybe_game_dir { + let base_dir = PathBuf::from(game_dir); + // For file data, we need the raw stream, so we unwrap the FramedWrite + let mut tx = framed_tx.into_inner(); + send_game_file_data(&desc, &mut tx, &base_dir).await; + // Re-wrap for next iteration (though usually stream closes after file transfer) + framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); + } else if let Err(e) = framed_tx + .send( + Response::InvalidRequest( + desc.relative_path.as_bytes().to_vec().into(), + "Game directory not set".to_string(), + ) + .encode(), + ) + .await + { + log::error!("Failed to send GetGameFileData error: {e}"); + } + } + Request::GetGameFileChunk { + game_id, + relative_path, + offset, + length, + } => { + log::info!( + "{remote_addr:?} received GetGameFileChunk request for {relative_path} (offset {offset}, length {length})" + ); + + let maybe_game_dir = ctx.game_dir.read().await.clone(); + if let Some(game_dir) = maybe_game_dir { + let base_dir = PathBuf::from(game_dir); + // For file data, we need the raw stream, so we unwrap the FramedWrite + let mut tx = framed_tx.into_inner(); + send_game_file_chunk( + &game_id, + &relative_path, + offset, + length, + &mut tx, + &base_dir, + ) + .await; + // Re-wrap for next iteration + framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); + } else if let Err(e) = framed_tx + .send( + Response::InvalidRequest( + relative_path.as_bytes().to_vec().into(), + "Game directory not set".to_string(), + ) + .encode(), + ) + .await + { + log::error!("Failed to send GetGameFileChunk error: {e}"); + } + } + Request::Invalid(_, _) => { + log::error!("Received invalid request from peer"); + } + Request::AnnounceGames(games) => { + log::info!( + "Received {} announced games from peer {remote_addr:?}", + games.len() + ); + if let Some(addr) = remote_addr { + let aggregated_games = { + let mut db = ctx.peer_game_db.write().await; + db.update_peer_games(addr, games); + db.get_all_games() + }; + + if let Err(e) = ctx + .tx_notify_ui + .send(PeerEvent::ListGames(aggregated_games)) + { + log::error!("Failed to send ListGames event: {e}"); + } + } + } + } + } + + Some(Err(e)) => { + log::error!("{remote_addr:?} peer stream error: {e}"); + break; + } + None => { + log::trace!("{remote_addr:?} peer stream closed"); + break; + } + } + } + + Ok(()) +} + +// ============================================================================= +// Peer discovery +// ============================================================================= + +/// Runs the peer discovery service using mDNS. +pub async fn run_peer_discovery( + tx_notify_ui: UnboundedSender, + peer_game_db: Arc>, + local_peer_addr: Arc>>, +) { + log::info!("Starting peer discovery task"); + + let service_type = LANSPREAD_SERVICE_TYPE.to_string(); + + loop { + let (addr_tx, mut addr_rx) = tokio::sync::mpsc::unbounded_channel(); + let service_type_clone = service_type.clone(); + + let worker_handle = tokio::task::spawn_blocking(move || -> eyre::Result<()> { + let browser = MdnsBrowser::new(&service_type_clone)?; + loop { + if let Some(addr) = browser.next_address(None)? { + if addr_tx.send(addr).is_err() { + log::debug!("Peer discovery consumer dropped; stopping worker"); + break; + } + } else { + log::warn!("mDNS browser closed; stopping peer discovery worker"); + break; + } + } + Ok(()) + }); + + while let Some(peer_addr) = addr_rx.recv().await { + let is_self = { + let guard = local_peer_addr.read().await; + guard.as_ref().is_some_and(|addr| *addr == peer_addr) + }; + + if is_self { + log::trace!("Ignoring self advertisement at {peer_addr}"); + continue; + } + + let is_new_peer = { + let mut db = peer_game_db.write().await; + if db.contains_peer(&peer_addr) { + db.update_last_seen(&peer_addr); + false + } else { + db.add_peer(peer_addr); + true + } + }; + + if is_new_peer { + log::info!("Discovered peer at: {peer_addr}"); + + if let Err(e) = tx_notify_ui.send(PeerEvent::PeerDiscovered(peer_addr)) { + log::error!("Failed to send PeerDiscovered event: {e}"); + } + + let current_peer_count = { peer_game_db.read().await.get_peer_addresses().len() }; + if let Err(e) = tx_notify_ui.send(PeerEvent::PeerCountUpdated(current_peer_count)) { + log::error!("Failed to send PeerCountUpdated event: {e}"); + } + + let tx_notify_ui_clone = tx_notify_ui.clone(); + let peer_game_db_clone = peer_game_db.clone(); + tokio::spawn(async move { + if let Err(e) = request_games_from_peer( + peer_addr, + tx_notify_ui_clone, + peer_game_db_clone, + 0, + ) + .await + { + log::error!("Failed to request games from peer {peer_addr}: {e}"); + } + }); + } + } + + match worker_handle.await { + Ok(Ok(())) => { + log::warn!("Peer discovery worker exited; restarting shortly"); + } + Ok(Err(e)) => { + log::error!("Peer discovery worker failed: {e}"); + } + Err(e) => { + log::error!("Peer discovery worker join error: {e}"); + } + } + + tokio::time::sleep(Duration::from_secs(5)).await; + } +} + +/// Requests games from a peer with retry logic. +async fn request_games_from_peer( + peer_addr: SocketAddr, + tx_notify_ui: UnboundedSender, + peer_game_db: Arc>, + mut retry_count: u32, +) -> eyre::Result<()> { + loop { + match fetch_games_from_peer(peer_addr).await { + Ok(games) => { + log::info!("Received {} games from peer {peer_addr}", games.len()); + + if games.is_empty() && retry_count < 1 { + log::info!("Received 0 games from peer {peer_addr}, scheduling retry in 5s"); + tokio::time::sleep(Duration::from_secs(5)).await; + retry_count += 1; + continue; + } + + let aggregated_games = { + let mut db = peer_game_db.write().await; + db.update_peer_games(peer_addr, games); + db.get_all_games() + }; + + if let Err(e) = tx_notify_ui.send(PeerEvent::ListGames(aggregated_games)) { + log::error!("Failed to send ListGames event: {e}"); + } + return Ok(()); + } + Err(e) => return Err(e), + } + } +} + +// ============================================================================= +// Ping service +// ============================================================================= + +/// Runs the ping service to check peer liveness. +#[allow(clippy::too_many_lines)] +pub async fn run_ping_service( + tx_notify_ui: UnboundedSender, + peer_game_db: Arc>, + downloading_games: Arc>>, + active_downloads: Arc>>>, +) { + log::info!( + "Starting ping service ({PEER_PING_INTERVAL_SECS}s interval, \ +{}s timeout)", + peer_stale_timeout().as_secs() + ); + + let mut interval = tokio::time::interval(Duration::from_secs(PEER_PING_INTERVAL_SECS)); + + loop { + interval.tick().await; + + let peer_addresses = { peer_game_db.read().await.get_peer_addresses() }; + + for peer_addr in peer_addresses { + let tx_notify_ui_clone = tx_notify_ui.clone(); + let peer_game_db_clone = peer_game_db.clone(); + let downloading_games_clone = downloading_games.clone(); + let active_downloads_clone = active_downloads.clone(); + + tokio::spawn(async move { + match ping_peer(peer_addr).await { + Ok(is_alive) => { + if is_alive { + // Update last seen time + peer_game_db_clone + .write() + .await + .update_last_seen(&peer_addr); + } else { + log::warn!("Peer {peer_addr} failed ping check"); + + // Remove stale peer + let removed_peer = + peer_game_db_clone.write().await.remove_peer(&peer_addr); + if removed_peer.is_some() { + log::info!("Removed stale peer: {peer_addr}"); + if let Err(e) = + tx_notify_ui_clone.send(PeerEvent::PeerLost(peer_addr)) + { + log::error!("Failed to send PeerLost event: {e}"); + } + + // Send updated peer count + let current_peer_count = + { peer_game_db_clone.read().await.get_peer_addresses().len() }; + if let Err(e) = tx_notify_ui_clone + .send(PeerEvent::PeerCountUpdated(current_peer_count)) + { + log::error!("Failed to send PeerCountUpdated event: {e}"); + } + + emit_peer_game_list(&peer_game_db_clone, &tx_notify_ui_clone).await; + handle_active_downloads_without_peers( + &peer_game_db_clone, + &downloading_games_clone, + &active_downloads_clone, + &tx_notify_ui_clone, + ) + .await; + } + } + } + Err(e) => { + log::error!("Failed to ping peer {peer_addr}: {e}"); + + // Remove peer on error + let removed_peer = peer_game_db_clone.write().await.remove_peer(&peer_addr); + if removed_peer.is_some() { + log::info!("Removed peer due to ping error: {peer_addr}"); + if let Err(e) = tx_notify_ui_clone.send(PeerEvent::PeerLost(peer_addr)) + { + log::error!("Failed to send PeerLost event: {e}"); + } + + // Send updated peer count + let current_peer_count = + { peer_game_db_clone.read().await.get_peer_addresses().len() }; + if let Err(e) = tx_notify_ui_clone + .send(PeerEvent::PeerCountUpdated(current_peer_count)) + { + log::error!("Failed to send PeerCountUpdated event: {e}"); + } + + emit_peer_game_list(&peer_game_db_clone, &tx_notify_ui_clone).await; + handle_active_downloads_without_peers( + &peer_game_db_clone, + &downloading_games_clone, + &active_downloads_clone, + &tx_notify_ui_clone, + ) + .await; + } + } + } + }); + } + + // Also clean up stale peers + let stale_peers = { + peer_game_db + .read() + .await + .get_stale_peers(peer_stale_timeout()) + }; + let mut removed_any = false; + for stale_addr in stale_peers { + let removed_peer = peer_game_db.write().await.remove_peer(&stale_addr); + if removed_peer.is_some() { + log::info!("Removed stale peer: {stale_addr}"); + if let Err(e) = tx_notify_ui.send(PeerEvent::PeerLost(stale_addr)) { + log::error!("Failed to send PeerLost event: {e}"); + } + + // Send updated peer count + let current_peer_count = { peer_game_db.read().await.get_peer_addresses().len() }; + if let Err(e) = tx_notify_ui.send(PeerEvent::PeerCountUpdated(current_peer_count)) { + log::error!("Failed to send PeerCountUpdated event: {e}"); + } + removed_any = true; + } + } + + if removed_any { + emit_peer_game_list(&peer_game_db, &tx_notify_ui).await; + handle_active_downloads_without_peers( + &peer_game_db, + &downloading_games, + &active_downloads, + &tx_notify_ui, + ) + .await; + } + } +} + +/// Handles downloads that no longer have peers available. +async fn handle_active_downloads_without_peers( + peer_game_db: &Arc>, + downloading_games: &Arc>>, + active_downloads: &Arc>>>, + tx_notify_ui: &UnboundedSender, +) { + let active_ids: Vec = { downloading_games.read().await.iter().cloned().collect() }; + if active_ids.is_empty() { + return; + } + + for id in active_ids { + let has_peers = { + let guard = peer_game_db.read().await; + !guard.peers_with_game(&id).is_empty() + }; + + if has_peers { + continue; + } + + let removed_from_tracking = { + let mut guard = downloading_games.write().await; + guard.remove(&id) + }; + + if !removed_from_tracking { + continue; + } + + if let Some(handle) = { active_downloads.write().await.remove(&id) } { + handle.abort(); + } + + if let Err(e) = + tx_notify_ui.send(PeerEvent::DownloadGameFilesAllPeersGone { id: id.clone() }) + { + log::error!("Failed to send DownloadGameFilesAllPeersGone event: {e}"); + } + } +} + +// ============================================================================= +// Local game monitor +// ============================================================================= + +/// Monitors the local game directory for changes. +pub async fn run_local_game_monitor(tx_notify_ui: UnboundedSender, ctx: Ctx) { + log::info!( + "Starting local game directory monitor ({LOCAL_GAME_MONITOR_INTERVAL_SECS}s interval)" + ); + + let mut interval = tokio::time::interval(Duration::from_secs(LOCAL_GAME_MONITOR_INTERVAL_SECS)); + + loop { + interval.tick().await; + + let game_dir = { + let guard = ctx.game_dir.read().await; + guard.clone() + }; + + if let Some(ref game_dir) = game_dir { + match scan_local_games(game_dir).await { + Ok(current_games) => { + update_and_announce_games(&ctx, &tx_notify_ui, current_games).await; + } + Err(e) => { + log::error!("Failed to scan local games directory: {e}"); + } + } + } + } +}