87d00e7df6
Peer startup used to bootstrap itself by spawning the runtime and immediately sending a SetGameDir command back through its own control channel. The Tauri integration then polled shared state until a directory appeared and waited two seconds before asking peers for games. That made startup ordering implicit and left a race-prone sleep in the UI bridge. Install the initial game directory directly into the peer context instead. The runtime now attempts the initial local-library scan before starting discovery, then launches the server, discovery, liveness, and local monitor services from that initialized context. Later directory changes still use SetGameDir, so the existing UI command surface stays intact. Use PathBuf and Path references across peer filesystem boundaries so directory state is represented as a path rather than an optional string. The Tauri layer now validates a selected game directory before storing it, loads the bundled catalog on first use, and starts or updates the peer runtime from one helper. Peer event fan-out is split into named handlers so the Tauri setup closure only wires state and starts the event loop. Shutdown goodbye notifications are still best-effort, but they are now awaited with a short timeout instead of being spawned and forgotten. The tradeoff is a small bounded wait during peer runtime shutdown in exchange for clearer task ownership. Test Plan: - cargo test -p lanspread-peer - cargo clippy - cargo clippy --benches - cargo clippy --tests - cargo +nightly fmt - git diff --check Refs: none
363 lines
12 KiB
Rust
363 lines
12 KiB
Rust
//! Command handlers for peer commands.
|
|
|
|
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
|
|
|
use lanspread_db::db::GameFileDescription;
|
|
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
|
|
|
use crate::{
|
|
PeerEvent,
|
|
context::Ctx,
|
|
download::download_game_files,
|
|
events,
|
|
identity::FEATURE_LIBRARY_DELTA,
|
|
local_games::{
|
|
LocalLibraryScan,
|
|
get_game_file_descriptions,
|
|
local_download_available,
|
|
scan_local_library,
|
|
},
|
|
network::{announce_games_to_peer, request_game_details_from_peer, send_library_delta},
|
|
peer_db::PeerGameDB,
|
|
remote_peer::ensure_peer_id_for_addr,
|
|
};
|
|
|
|
// =============================================================================
|
|
// Command handlers
|
|
// =============================================================================
|
|
|
|
/// Handles the `ListGames` command.
|
|
pub async fn handle_list_games_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
|
|
log::info!("ListGames command received");
|
|
events::emit_peer_game_list(&ctx.peer_game_db, tx_notify_ui).await;
|
|
}
|
|
|
|
/// Tries to serve a game from local files.
|
|
async fn try_serve_local_game(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: &str,
|
|
) -> bool {
|
|
let game_dir = { ctx.game_dir.read().await.clone() };
|
|
|
|
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<PeerEvent>,
|
|
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<RwLock<PeerGameDB>>,
|
|
) -> eyre::Result<Vec<GameFileDescription>> {
|
|
let (file_descriptions, _) = request_game_details_from_peer(peer_addr, game_id).await?;
|
|
let peer_id = ensure_peer_id_for_addr(&peer_game_db, peer_addr).await;
|
|
|
|
{
|
|
let mut db = peer_game_db.write().await;
|
|
db.update_peer_game_files(&peer_id, 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<PeerEvent>,
|
|
id: String,
|
|
file_descriptions: Vec<GameFileDescription>,
|
|
) {
|
|
log::info!("Got PeerCommand::DownloadGameFiles");
|
|
let games_folder = { ctx.game_dir.read().await.clone() };
|
|
|
|
// 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 local_dl_available = {
|
|
let downloading = ctx.downloading_games.read().await;
|
|
local_download_available(&games_folder, &id, &downloading).await
|
|
};
|
|
|
|
if peer_whitelist.is_empty() {
|
|
if local_dl_available {
|
|
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);
|
|
}
|
|
|
|
/// Handles the `SetGameDir` command.
|
|
pub async fn handle_set_game_dir_command(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
game_dir: PathBuf,
|
|
) {
|
|
*ctx.game_dir.write().await = game_dir.clone();
|
|
log::info!("Game directory set to: {}", game_dir.display());
|
|
|
|
let tx_notify_ui = tx_notify_ui.clone();
|
|
let ctx_clone = ctx.clone();
|
|
|
|
tokio::spawn(async move {
|
|
match load_local_library(&ctx_clone, &tx_notify_ui).await {
|
|
Ok(()) => log::info!("Local game database loaded successfully"),
|
|
Err(e) => {
|
|
log::error!("Failed to load local game database: {e}");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Loads the configured local library and announces the result.
|
|
pub async fn load_local_library(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
) -> eyre::Result<()> {
|
|
let game_dir = { ctx.game_dir.read().await.clone() };
|
|
let scan = scan_local_library(&game_dir).await?;
|
|
update_and_announce_games(ctx, tx_notify_ui, scan).await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Handles the `GetPeerCount` command.
|
|
pub async fn handle_get_peer_count_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
|
|
log::info!("GetPeerCount command received");
|
|
events::emit_peer_count(&ctx.peer_game_db, tx_notify_ui).await;
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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<PeerEvent>,
|
|
scan: LocalLibraryScan,
|
|
) {
|
|
let LocalLibraryScan {
|
|
game_db,
|
|
summaries,
|
|
revision,
|
|
} = scan;
|
|
|
|
let delta = {
|
|
let mut library_guard = ctx.local_library.write().await;
|
|
library_guard.update_from_scan(summaries, revision)
|
|
};
|
|
|
|
let Some(delta) = delta else {
|
|
return;
|
|
};
|
|
|
|
{
|
|
let mut db_guard = ctx.local_game_db.write().await;
|
|
*db_guard = Some(game_db.clone());
|
|
}
|
|
|
|
let all_games = game_db.all_games().into_iter().cloned().collect::<Vec<_>>();
|
|
|
|
if let Err(e) = tx_notify_ui.send(PeerEvent::LocalGamesUpdated(all_games.clone())) {
|
|
log::error!("Failed to send LocalGamesUpdated event: {e}");
|
|
}
|
|
|
|
let peer_targets = {
|
|
let db = ctx.peer_game_db.read().await;
|
|
db.peer_identities()
|
|
.into_iter()
|
|
.map(|(peer_id, addr)| {
|
|
let features = db.peer_features(&peer_id);
|
|
(peer_id, addr, features)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
};
|
|
|
|
for (_peer_id, peer_addr, features) in peer_targets {
|
|
if features
|
|
.iter()
|
|
.any(|feature| feature == FEATURE_LIBRARY_DELTA)
|
|
{
|
|
let delta = delta.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(e) = send_library_delta(peer_addr, delta).await {
|
|
log::warn!("Failed to send library delta to {peer_addr}: {e}");
|
|
}
|
|
});
|
|
} else {
|
|
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}");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|