373def6d44
Add a streamed-install prototype that can receive archive-derived install bytes straight into local/ without first storing the peer-owned root archive payload. This is intended for low-disk clients that want to install a game but opt out of becoming a downloadable peer source for that game. The protocol gains a current-version-only StreamInstall request and framed StreamInstallFrame responses. The peer core owns the generic transport, transaction, path validation, size checks, CRC32 verification, and lifecycle state. The archive-specific work is hidden behind StreamInstallProvider so the prototype can use unrar while the final implementation can swap in a better provider without rewriting the peer command path. The receiver writes into .local.installing and only promotes to local/ after the full stream verifies. It deliberately does not write the root version.ini or archive files, so the settled local state is installed=true, downloaded=false, and availability=LocalOnly. That preserves the existing rule that local/ is not served to peers and makes streamed receivers non-sources by construction. The CLI is the only caller for now. It exposes stream-install and provides the prototype unrar implementation with unrar lt for entry metadata and unrar p for file bytes. This is simple and good enough to prove non-solid archive streaming, but it is not the production provider shape for solid archives because per-file unrar p would repeatedly decompress prefixes. The Tauri app explicitly passes stream_install_provider: None, so the GUI behavior stays unchanged until a real product path is designed. Document the production-readiness work in NEXT_STEPS.md. The main follow-up is to make the provider abstraction final-ish and replace the per-file CLI unrar provider with a one-pass archive provider, then wire a deliberate GUI low-disk mode, retry semantics, and broader failure scenarios. Test Plan: - just fmt - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test - python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py \ S39 S40 --build-image - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just clippy - git diff --check - git diff --cached --check Follow-up: NEXT_STEPS.md
2311 lines
78 KiB
Rust
2311 lines
78 KiB
Rust
//! Command handlers for peer commands.
|
|
|
|
use std::{
|
|
collections::{HashSet, hash_map::Entry},
|
|
future::Future,
|
|
net::SocketAddr,
|
|
path::{Path, PathBuf},
|
|
sync::Arc,
|
|
time::Duration,
|
|
};
|
|
|
|
use lanspread_db::db::{GameDB, GameFileDescription};
|
|
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
use crate::{
|
|
InstallOperation,
|
|
PeerEvent,
|
|
context::{Ctx, OperationGuard, OperationKind},
|
|
download::download_game_files,
|
|
events,
|
|
install,
|
|
local_games::{
|
|
LocalLibraryScan,
|
|
game_from_summary,
|
|
get_game_file_descriptions,
|
|
local_dir_is_directory,
|
|
local_download_matches_catalog,
|
|
rescan_local_game,
|
|
scan_local_library,
|
|
version_ini_is_regular_file,
|
|
},
|
|
network::{request_game_details_from_peer, send_library_delta},
|
|
peer_db::PeerGameDB,
|
|
remote_peer::ensure_peer_id_for_addr,
|
|
services::{HandshakeCtx, perform_handshake_with_peer},
|
|
stream_install::receive_streamed_install,
|
|
};
|
|
|
|
// =============================================================================
|
|
// Command handlers
|
|
// =============================================================================
|
|
|
|
const OUTBOUND_TRANSFER_DRAIN_POLL_INTERVAL: Duration = Duration::from_millis(10);
|
|
const OUTBOUND_TRANSFER_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
|
|
|
|
/// 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, &ctx.catalog, 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 active_operations = ctx.active_operations.read().await;
|
|
let catalog = ctx.catalog.read().await;
|
|
if !local_download_matches_catalog(&game_dir, id, &active_operations, &catalog).await {
|
|
return false;
|
|
}
|
|
drop(active_operations);
|
|
drop(catalog);
|
|
|
|
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(crate) async fn handle_get_game_command(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: String,
|
|
source: GameDetailSource,
|
|
) {
|
|
if source.allows_local() && try_serve_local_game(ctx, tx_notify_ui, &id).await {
|
|
return;
|
|
}
|
|
|
|
log::info!("Requesting game from peers: {id}");
|
|
let expected_version = catalog_expected_version(ctx, &id).await;
|
|
let peers = {
|
|
let peer_game_db = ctx.peer_game_db.read().await;
|
|
source.select_peers(&peer_game_db, &id, expected_version.as_deref())
|
|
};
|
|
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();
|
|
ctx.task_tracker.spawn(fetch_game_details_from_peers(
|
|
peers,
|
|
id,
|
|
expected_version,
|
|
peer_game_db,
|
|
tx_notify_ui,
|
|
|peer_addr, game_id, peer_game_db| async move {
|
|
request_game_details_and_update(peer_addr, &game_id, peer_game_db).await
|
|
},
|
|
));
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub(crate) enum GameDetailSource {
|
|
LocalOrPeers,
|
|
LatestPeersOnly,
|
|
}
|
|
|
|
impl GameDetailSource {
|
|
fn allows_local(self) -> bool {
|
|
matches!(self, Self::LocalOrPeers)
|
|
}
|
|
|
|
fn select_peers(
|
|
self,
|
|
peer_game_db: &PeerGameDB,
|
|
id: &str,
|
|
expected_version: Option<&str>,
|
|
) -> Vec<SocketAddr> {
|
|
match self {
|
|
Self::LocalOrPeers | Self::LatestPeersOnly => {
|
|
peer_game_db.peers_with_expected_version(id, expected_version)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
async fn fetch_game_details_from_peers<F, Fut>(
|
|
peers: Vec<SocketAddr>,
|
|
id: String,
|
|
expected_version: Option<String>,
|
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
|
tx_notify_ui: UnboundedSender<PeerEvent>,
|
|
mut fetch_details: F,
|
|
) where
|
|
F: FnMut(SocketAddr, String, Arc<RwLock<PeerGameDB>>) -> Fut + Send + 'static,
|
|
Fut: Future<Output = eyre::Result<Vec<GameFileDescription>>> + Send,
|
|
{
|
|
let mut fetched_any = false;
|
|
for peer_addr in peers {
|
|
match fetch_details(peer_addr, id.clone(), 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, expected_version.as_deref())
|
|
};
|
|
|
|
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");
|
|
if let Err(e) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() }) {
|
|
log::error!("Failed to send DownloadGameFilesFailed event: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
install_after_download: bool,
|
|
) {
|
|
log::info!("Got PeerCommand::DownloadGameFiles");
|
|
if !catalog_contains(ctx, &id).await {
|
|
log::warn!("Ignoring download command for non-catalog game {id}");
|
|
if let Err(send_err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id }) {
|
|
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
|
|
}
|
|
return;
|
|
}
|
|
|
|
let games_folder = { ctx.game_dir.read().await.clone() };
|
|
let expected_version = catalog_expected_version(ctx, &id).await;
|
|
|
|
// 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, expected_version.as_deref())
|
|
{
|
|
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"
|
|
);
|
|
if let Err(send_err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id }) {
|
|
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
|
|
}
|
|
return;
|
|
}
|
|
|
|
let local_dl_available = {
|
|
let active_operations = ctx.active_operations.read().await;
|
|
let catalog = ctx.catalog.read().await;
|
|
local_download_matches_catalog(&games_folder, &id, &active_operations, &catalog).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}");
|
|
}
|
|
if install_after_download {
|
|
spawn_install_operation(ctx, tx_notify_ui, id.clone());
|
|
}
|
|
} else {
|
|
log::error!("No trusted peers available after majority validation for game {id}");
|
|
if let Err(send_err) =
|
|
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() })
|
|
{
|
|
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
|
|
BeginOperationResult::Started => {}
|
|
BeginOperationResult::AlreadyActive => {
|
|
log::warn!("Operation for {id} already in progress; ignoring new download request");
|
|
return;
|
|
}
|
|
BeginOperationResult::DrainTimedOut => {
|
|
log::error!("Timed out waiting for outbound transfers before downloading {id}");
|
|
send_download_failed(tx_notify_ui, &id);
|
|
return;
|
|
}
|
|
}
|
|
|
|
let active_operations = ctx.active_operations.clone();
|
|
let active_downloads = ctx.active_downloads.clone();
|
|
let tx_notify_ui_clone = tx_notify_ui.clone();
|
|
let download_id = id.clone();
|
|
let cancel_token = ctx.shutdown.child_token();
|
|
let ctx_clone = ctx.clone();
|
|
|
|
ctx.active_downloads
|
|
.write()
|
|
.await
|
|
.insert(id, cancel_token.clone());
|
|
|
|
ctx.task_tracker.spawn(async move {
|
|
let download_state_guard = OperationGuard::download(
|
|
download_id.clone(),
|
|
active_operations,
|
|
active_downloads,
|
|
tx_notify_ui_clone.clone(),
|
|
);
|
|
|
|
let result = download_game_files(
|
|
&download_id,
|
|
resolved_descriptions,
|
|
games_folder,
|
|
peer_whitelist,
|
|
file_peer_map,
|
|
tx_notify_ui_clone.clone(),
|
|
cancel_token.clone(),
|
|
)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(()) => {
|
|
let Some(prepared) =
|
|
prepare_install_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await
|
|
else {
|
|
if let Err(err) = refresh_local_game_for_ending_operation(
|
|
&ctx_clone,
|
|
&tx_notify_ui_clone,
|
|
&download_id,
|
|
)
|
|
.await
|
|
{
|
|
log::error!("Failed to refresh local library after download: {err}");
|
|
}
|
|
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
|
download_state_guard.disarm();
|
|
send_download_finished(&tx_notify_ui_clone, &download_id);
|
|
return;
|
|
};
|
|
|
|
if install_after_download {
|
|
if transition_download_to_install(
|
|
&ctx_clone,
|
|
&tx_notify_ui_clone,
|
|
&download_id,
|
|
prepared.operation_kind,
|
|
)
|
|
.await
|
|
{
|
|
clear_active_download(&ctx_clone, &download_id).await;
|
|
send_download_finished(&tx_notify_ui_clone, &download_id);
|
|
download_state_guard.disarm();
|
|
run_started_install_operation(
|
|
&ctx_clone,
|
|
&tx_notify_ui_clone,
|
|
download_id,
|
|
prepared,
|
|
)
|
|
.await;
|
|
} else {
|
|
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
|
download_state_guard.disarm();
|
|
send_download_finished(&tx_notify_ui_clone, &download_id);
|
|
}
|
|
} else {
|
|
if let Err(err) = refresh_local_game_for_ending_operation(
|
|
&ctx_clone,
|
|
&tx_notify_ui_clone,
|
|
&download_id,
|
|
)
|
|
.await
|
|
{
|
|
log::error!("Failed to refresh local library after download: {err}");
|
|
}
|
|
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
|
download_state_guard.disarm();
|
|
send_download_finished(&tx_notify_ui_clone, &download_id);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
if let Err(refresh_err) = refresh_local_game_for_ending_operation(
|
|
&ctx_clone,
|
|
&tx_notify_ui_clone,
|
|
&download_id,
|
|
)
|
|
.await
|
|
{
|
|
log::error!(
|
|
"Failed to refresh local library after download failure: {refresh_err}"
|
|
);
|
|
}
|
|
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
|
|
download_state_guard.disarm();
|
|
let download_was_cancelled = cancel_token.is_cancelled();
|
|
if download_was_cancelled {
|
|
log::info!("Download cancelled for {download_id}: {e}");
|
|
} else {
|
|
log::error!("Download failed for {download_id}: {e}");
|
|
}
|
|
send_download_failed_unless_cancelled(
|
|
&tx_notify_ui_clone,
|
|
&download_id,
|
|
download_was_cancelled,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Handles the `InstallGame` command.
|
|
pub async fn handle_install_game_command(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: String,
|
|
) {
|
|
spawn_install_operation(ctx, tx_notify_ui, id);
|
|
}
|
|
|
|
pub async fn handle_stream_install_game_command(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: String,
|
|
) {
|
|
if !catalog_contains(ctx, &id).await {
|
|
log::warn!("Ignoring streamed install command for non-catalog game {id}");
|
|
send_download_failed(tx_notify_ui, &id);
|
|
return;
|
|
}
|
|
|
|
let games_folder = { ctx.game_dir.read().await.clone() };
|
|
let game_root = games_folder.join(&id);
|
|
if local_dir_is_directory(&game_root).await {
|
|
log::warn!("Ignoring streamed install command for already-installed game {id}");
|
|
send_download_failed(tx_notify_ui, &id);
|
|
return;
|
|
}
|
|
|
|
let expected_version = catalog_expected_version(ctx, &id).await;
|
|
let mut peers = {
|
|
match ctx
|
|
.peer_game_db
|
|
.read()
|
|
.await
|
|
.validate_file_sizes_majority(&id, expected_version.as_deref())
|
|
{
|
|
Ok((validated_files, peer_whitelist, _)) if !validated_files.is_empty() => {
|
|
peer_whitelist
|
|
}
|
|
Ok(_) => {
|
|
log::error!("No trusted peers available for streamed install of {id}");
|
|
send_download_failed(tx_notify_ui, &id);
|
|
return;
|
|
}
|
|
Err(err) => {
|
|
log::error!(
|
|
"File size majority validation failed for streamed install {id}: {err}"
|
|
);
|
|
send_download_failed(tx_notify_ui, &id);
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
peers.sort();
|
|
let Some(peer_addr) = peers.into_iter().next() else {
|
|
log::error!("No peer selected for streamed install of {id}");
|
|
send_download_failed(tx_notify_ui, &id);
|
|
return;
|
|
};
|
|
|
|
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
|
|
BeginOperationResult::Started => {}
|
|
BeginOperationResult::AlreadyActive => {
|
|
log::warn!("Operation for {id} already in progress; ignoring streamed install request");
|
|
return;
|
|
}
|
|
BeginOperationResult::DrainTimedOut => {
|
|
log::error!("Timed out waiting for outbound transfers before streamed install of {id}");
|
|
send_download_failed(tx_notify_ui, &id);
|
|
return;
|
|
}
|
|
}
|
|
|
|
let cancel_token = ctx.shutdown.child_token();
|
|
ctx.active_downloads
|
|
.write()
|
|
.await
|
|
.insert(id.clone(), cancel_token.clone());
|
|
|
|
let ctx_clone = ctx.clone();
|
|
let tx_notify_ui = tx_notify_ui.clone();
|
|
ctx.task_tracker.spawn(async move {
|
|
run_stream_install_operation(
|
|
ctx_clone,
|
|
tx_notify_ui,
|
|
id,
|
|
game_root,
|
|
peer_addr,
|
|
cancel_token,
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
|
|
/// Handles the `UninstallGame` command.
|
|
pub async fn handle_uninstall_game_command(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: String,
|
|
) {
|
|
let ctx = ctx.clone();
|
|
let tx_notify_ui = tx_notify_ui.clone();
|
|
ctx.task_tracker.clone().spawn(async move {
|
|
run_uninstall_operation(&ctx, &tx_notify_ui, id).await;
|
|
});
|
|
}
|
|
|
|
pub async fn handle_remove_downloaded_game_command(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: String,
|
|
) {
|
|
let ctx = ctx.clone();
|
|
let tx_notify_ui = tx_notify_ui.clone();
|
|
ctx.task_tracker.clone().spawn(async move {
|
|
run_remove_downloaded_operation(&ctx, &tx_notify_ui, id).await;
|
|
});
|
|
}
|
|
|
|
pub async fn handle_cancel_download_command(
|
|
ctx: &Ctx,
|
|
_tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: String,
|
|
) {
|
|
let cancel_token = ctx.active_downloads.read().await.get(&id).cloned();
|
|
let Some(cancel_token) = cancel_token else {
|
|
log::warn!("Ignoring cancel request for inactive download {id}");
|
|
return;
|
|
};
|
|
|
|
log::info!("Cancelling download for game {id}");
|
|
cancel_token.cancel();
|
|
}
|
|
|
|
async fn run_stream_install_operation(
|
|
ctx: Ctx,
|
|
tx_notify_ui: UnboundedSender<PeerEvent>,
|
|
id: String,
|
|
game_root: PathBuf,
|
|
peer_addr: SocketAddr,
|
|
cancel_token: CancellationToken,
|
|
) {
|
|
let download_guard = OperationGuard::download(
|
|
id.clone(),
|
|
ctx.active_operations.clone(),
|
|
ctx.active_downloads.clone(),
|
|
tx_notify_ui.clone(),
|
|
);
|
|
|
|
events::send(
|
|
&tx_notify_ui,
|
|
PeerEvent::DownloadGameFilesBegin { id: id.clone() },
|
|
);
|
|
|
|
let transaction = match install::begin_streamed_install(&game_root, ctx.state_dir.as_ref(), &id)
|
|
.await
|
|
{
|
|
Ok(transaction) => transaction,
|
|
Err(err) => {
|
|
log::error!("Failed to prepare streamed install for {id}: {err}");
|
|
finish_failed_stream_download(&ctx, &tx_notify_ui, &id, download_guard, false).await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
let receive_result = receive_streamed_install(
|
|
peer_addr,
|
|
&id,
|
|
transaction.staging_dir(),
|
|
tx_notify_ui.clone(),
|
|
cancel_token.clone(),
|
|
)
|
|
.await;
|
|
|
|
match receive_result {
|
|
Ok(()) => {
|
|
if transition_download_to_install(&ctx, &tx_notify_ui, &id, OperationKind::Installing)
|
|
.await
|
|
{
|
|
clear_active_download(&ctx, &id).await;
|
|
send_download_finished(&tx_notify_ui, &id);
|
|
download_guard.disarm();
|
|
commit_streamed_install(&ctx, &tx_notify_ui, id, transaction).await;
|
|
} else {
|
|
if let Err(err) = transaction.rollback().await {
|
|
log::error!("Failed to roll back streamed install for {id}: {err}");
|
|
}
|
|
finish_failed_stream_download(&ctx, &tx_notify_ui, &id, download_guard, false)
|
|
.await;
|
|
}
|
|
}
|
|
Err(err) => {
|
|
if let Err(rollback_err) = transaction.rollback().await {
|
|
log::error!("Failed to roll back streamed install for {id}: {rollback_err}");
|
|
}
|
|
let download_was_cancelled = cancel_token.is_cancelled();
|
|
if download_was_cancelled {
|
|
log::info!("Streamed install download cancelled for {id}: {err}");
|
|
} else {
|
|
log::error!("Streamed install download failed for {id}: {err}");
|
|
}
|
|
finish_failed_stream_download(
|
|
&ctx,
|
|
&tx_notify_ui,
|
|
&id,
|
|
download_guard,
|
|
download_was_cancelled,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn finish_failed_stream_download(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: &str,
|
|
guard: OperationGuard,
|
|
cancelled: bool,
|
|
) {
|
|
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, id).await {
|
|
log::error!("Failed to refresh local library after streamed install failure: {err}");
|
|
}
|
|
end_download_operation(ctx, tx_notify_ui, id).await;
|
|
guard.disarm();
|
|
send_download_failed_unless_cancelled(tx_notify_ui, id, cancelled);
|
|
}
|
|
|
|
async fn commit_streamed_install(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: String,
|
|
transaction: install::StreamedInstallTransaction,
|
|
) {
|
|
let operation_guard = OperationGuard::new(
|
|
id.clone(),
|
|
ctx.active_operations.clone(),
|
|
tx_notify_ui.clone(),
|
|
);
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameBegin {
|
|
id: id.clone(),
|
|
operation: InstallOperation::Installing,
|
|
},
|
|
);
|
|
|
|
match transaction.commit().await {
|
|
Ok(()) => {
|
|
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
|
{
|
|
log::error!("Failed to refresh local library after streamed install: {err}");
|
|
}
|
|
end_operation(ctx, tx_notify_ui, &id).await;
|
|
operation_guard.disarm();
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameFinished { id: id.clone() },
|
|
);
|
|
}
|
|
Err(err) => {
|
|
log::error!("Streamed install commit failed for {id}: {err}");
|
|
if let Err(refresh_err) =
|
|
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
|
{
|
|
log::error!(
|
|
"Failed to refresh local library after streamed install commit failure: {refresh_err}"
|
|
);
|
|
}
|
|
end_operation(ctx, tx_notify_ui, &id).await;
|
|
operation_guard.disarm();
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameFailed { id: id.clone() },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
|
let ctx = ctx.clone();
|
|
let tx_notify_ui = tx_notify_ui.clone();
|
|
ctx.task_tracker.clone().spawn(async move {
|
|
run_install_operation(&ctx, &tx_notify_ui, id).await;
|
|
});
|
|
}
|
|
|
|
async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
|
let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &id).await else {
|
|
return;
|
|
};
|
|
|
|
match begin_operation(ctx, tx_notify_ui, &id, prepared.operation_kind).await {
|
|
BeginOperationResult::Started => {}
|
|
BeginOperationResult::AlreadyActive => {
|
|
log::warn!("Operation for {id} already in progress; ignoring install command");
|
|
return;
|
|
}
|
|
BeginOperationResult::DrainTimedOut => {
|
|
log::error!("Timed out waiting for outbound transfers before install/update of {id}");
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameFailed { id: id.clone() },
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
run_started_install_operation(ctx, tx_notify_ui, id, prepared).await;
|
|
}
|
|
|
|
struct PreparedInstallOperation {
|
|
game_root: PathBuf,
|
|
operation: InstallOperation,
|
|
operation_kind: OperationKind,
|
|
}
|
|
|
|
async fn prepare_install_operation(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: &str,
|
|
) -> Option<PreparedInstallOperation> {
|
|
if !catalog_contains(ctx, id).await {
|
|
log::warn!("Ignoring install command for non-catalog game {id}");
|
|
return None;
|
|
}
|
|
|
|
let game_root = { ctx.game_dir.read().await.join(id) };
|
|
if !version_ini_is_regular_file(&game_root).await {
|
|
log::warn!("Ignoring install command for {id}: version.ini sentinel is absent");
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameFailed { id: id.to_string() },
|
|
);
|
|
return None;
|
|
}
|
|
|
|
let local_present = local_dir_is_directory(&game_root).await;
|
|
let operation = if local_present {
|
|
InstallOperation::Updating
|
|
} else {
|
|
InstallOperation::Installing
|
|
};
|
|
let operation_kind = match operation {
|
|
InstallOperation::Installing => OperationKind::Installing,
|
|
InstallOperation::Updating => OperationKind::Updating,
|
|
};
|
|
|
|
Some(PreparedInstallOperation {
|
|
game_root,
|
|
operation,
|
|
operation_kind,
|
|
})
|
|
}
|
|
|
|
async fn run_started_install_operation(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: String,
|
|
prepared: PreparedInstallOperation,
|
|
) {
|
|
let PreparedInstallOperation {
|
|
game_root,
|
|
operation,
|
|
..
|
|
} = prepared;
|
|
|
|
let operation_guard = OperationGuard::new(
|
|
id.clone(),
|
|
ctx.active_operations.clone(),
|
|
tx_notify_ui.clone(),
|
|
);
|
|
let result = {
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameBegin {
|
|
id: id.clone(),
|
|
operation,
|
|
},
|
|
);
|
|
|
|
let state_dir = ctx.state_dir.as_ref();
|
|
match operation {
|
|
InstallOperation::Installing => {
|
|
install::install(&game_root, state_dir, &id, ctx.unpacker.clone()).await
|
|
}
|
|
InstallOperation::Updating => {
|
|
install::update(&game_root, state_dir, &id, ctx.unpacker.clone()).await
|
|
}
|
|
}
|
|
};
|
|
|
|
match result {
|
|
Ok(()) => {
|
|
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
|
{
|
|
log::error!("Failed to refresh local library after install: {err}");
|
|
}
|
|
end_operation(ctx, tx_notify_ui, &id).await;
|
|
operation_guard.disarm();
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameFinished { id: id.clone() },
|
|
);
|
|
}
|
|
Err(err) => {
|
|
log::error!("Install operation failed for {id}: {err}");
|
|
if let Err(refresh_err) =
|
|
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
|
{
|
|
log::error!("Failed to refresh local library after install failure: {refresh_err}");
|
|
}
|
|
end_operation(ctx, tx_notify_ui, &id).await;
|
|
operation_guard.disarm();
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::InstallGameFailed { id: id.clone() },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
|
if !catalog_contains(ctx, &id).await {
|
|
log::warn!("Ignoring uninstall command for non-catalog game {id}");
|
|
return;
|
|
}
|
|
|
|
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Uninstalling).await {
|
|
BeginOperationResult::Started => {}
|
|
BeginOperationResult::AlreadyActive => {
|
|
log::warn!("Operation for {id} already in progress; ignoring uninstall command");
|
|
return;
|
|
}
|
|
BeginOperationResult::DrainTimedOut => {
|
|
log::error!("Timed out waiting for outbound transfers before uninstall of {id}");
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::UninstallGameFailed { id: id.clone() },
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
let game_root = { ctx.game_dir.read().await.join(&id) };
|
|
let operation_guard = OperationGuard::new(
|
|
id.clone(),
|
|
ctx.active_operations.clone(),
|
|
tx_notify_ui.clone(),
|
|
);
|
|
let result = {
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::UninstallGameBegin { id: id.clone() },
|
|
);
|
|
|
|
install::uninstall(&game_root, ctx.state_dir.as_ref(), &id).await
|
|
};
|
|
|
|
match result {
|
|
Ok(()) => {
|
|
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
|
{
|
|
log::error!("Failed to refresh local library after uninstall: {err}");
|
|
}
|
|
end_operation(ctx, tx_notify_ui, &id).await;
|
|
operation_guard.disarm();
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::UninstallGameFinished { id: id.clone() },
|
|
);
|
|
}
|
|
Err(err) => {
|
|
log::error!("Uninstall operation failed for {id}: {err}");
|
|
if let Err(refresh_err) =
|
|
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
|
{
|
|
log::error!(
|
|
"Failed to refresh local library after uninstall failure: {refresh_err}"
|
|
);
|
|
}
|
|
end_operation(ctx, tx_notify_ui, &id).await;
|
|
operation_guard.disarm();
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::UninstallGameFailed { id: id.clone() },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run_remove_downloaded_operation(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: String,
|
|
) {
|
|
if !catalog_contains(ctx, &id).await {
|
|
log::warn!("Ignoring downloaded-file removal for non-catalog game {id}");
|
|
return;
|
|
}
|
|
|
|
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::RemovingDownload).await {
|
|
BeginOperationResult::Started => {}
|
|
BeginOperationResult::AlreadyActive => {
|
|
log::warn!("Operation for {id} already in progress; ignoring downloaded-file removal");
|
|
return;
|
|
}
|
|
BeginOperationResult::DrainTimedOut => {
|
|
log::error!("Timed out waiting for outbound transfers before removal of {id}");
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::RemoveDownloadedGameFailed { id: id.clone() },
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
let game_dir = { ctx.game_dir.read().await.clone() };
|
|
let operation_guard = OperationGuard::new(
|
|
id.clone(),
|
|
ctx.active_operations.clone(),
|
|
tx_notify_ui.clone(),
|
|
);
|
|
let result = {
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::RemoveDownloadedGameBegin { id: id.clone() },
|
|
);
|
|
|
|
install::remove_downloaded(&game_dir, &id).await
|
|
};
|
|
|
|
match result {
|
|
Ok(()) => {
|
|
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
|
{
|
|
log::error!("Failed to refresh local library after downloaded-file removal: {err}");
|
|
}
|
|
end_operation(ctx, tx_notify_ui, &id).await;
|
|
operation_guard.disarm();
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::RemoveDownloadedGameFinished { id: id.clone() },
|
|
);
|
|
}
|
|
Err(err) => {
|
|
log::error!("Downloaded-file removal failed for {id}: {err}");
|
|
if let Err(refresh_err) =
|
|
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
|
|
{
|
|
log::error!(
|
|
"Failed to refresh local library after downloaded-file removal failure: {refresh_err}"
|
|
);
|
|
}
|
|
end_operation(ctx, tx_notify_ui, &id).await;
|
|
operation_guard.disarm();
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::RemoveDownloadedGameFailed { id: id.clone() },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum BeginOperationResult {
|
|
Started,
|
|
AlreadyActive,
|
|
DrainTimedOut,
|
|
}
|
|
|
|
async fn begin_operation(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: &str,
|
|
operation: OperationKind,
|
|
) -> BeginOperationResult {
|
|
begin_operation_with_drain_timeout(
|
|
ctx,
|
|
tx_notify_ui,
|
|
id,
|
|
operation,
|
|
OUTBOUND_TRANSFER_DRAIN_TIMEOUT,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn begin_operation_with_drain_timeout(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: &str,
|
|
operation: OperationKind,
|
|
drain_timeout: Duration,
|
|
) -> BeginOperationResult {
|
|
let started = {
|
|
let mut active_operations = ctx.active_operations.write().await;
|
|
match active_operations.entry(id.to_string()) {
|
|
Entry::Vacant(entry) => {
|
|
entry.insert(operation);
|
|
true
|
|
}
|
|
Entry::Occupied(_) => false,
|
|
}
|
|
};
|
|
|
|
if !started {
|
|
return BeginOperationResult::AlreadyActive;
|
|
}
|
|
|
|
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
|
|
|
|
if operation_requires_outbound_drain(operation)
|
|
&& !cancel_and_wait_for_outbound_transfers(ctx, id, drain_timeout).await
|
|
{
|
|
end_operation(ctx, tx_notify_ui, id).await;
|
|
return BeginOperationResult::DrainTimedOut;
|
|
}
|
|
|
|
BeginOperationResult::Started
|
|
}
|
|
|
|
fn operation_requires_outbound_drain(operation: OperationKind) -> bool {
|
|
operation == OperationKind::Updating || operation == OperationKind::RemovingDownload
|
|
}
|
|
|
|
async fn cancel_and_wait_for_outbound_transfers(
|
|
ctx: &Ctx,
|
|
id: &str,
|
|
drain_timeout: Duration,
|
|
) -> bool {
|
|
let mut tokens_to_cancel = Vec::new();
|
|
{
|
|
let active = ctx.active_outbound_transfers.read().await;
|
|
if let Some(transfers) = active.get(id) {
|
|
for (_, token) in transfers {
|
|
tokens_to_cancel.push(token.clone());
|
|
}
|
|
}
|
|
}
|
|
for token in tokens_to_cancel {
|
|
token.cancel();
|
|
}
|
|
|
|
let drained = tokio::time::timeout(drain_timeout, async {
|
|
loop {
|
|
let count = {
|
|
let active = ctx.active_outbound_transfers.read().await;
|
|
active.get(id).map_or(0, Vec::len)
|
|
};
|
|
if count == 0 {
|
|
break;
|
|
}
|
|
tokio::time::sleep(OUTBOUND_TRANSFER_DRAIN_POLL_INTERVAL).await;
|
|
}
|
|
})
|
|
.await
|
|
.is_ok();
|
|
|
|
if !drained {
|
|
let count = {
|
|
let active = ctx.active_outbound_transfers.read().await;
|
|
active.get(id).map_or(0, Vec::len)
|
|
};
|
|
log::error!(
|
|
"Timed out after {drain_timeout:?} waiting for {count} outbound transfer(s) to drain for {id}"
|
|
);
|
|
}
|
|
|
|
drained
|
|
}
|
|
|
|
async fn transition_download_to_install(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: &str,
|
|
operation: OperationKind,
|
|
) -> bool {
|
|
let transitioned = {
|
|
let mut active_operations = ctx.active_operations.write().await;
|
|
match active_operations.get_mut(id) {
|
|
Some(current) if *current == OperationKind::Downloading => {
|
|
*current = operation;
|
|
true
|
|
}
|
|
Some(current) => {
|
|
log::warn!(
|
|
"Cannot transition {id} from download to install; current operation is {current:?}"
|
|
);
|
|
false
|
|
}
|
|
None => {
|
|
log::warn!(
|
|
"Cannot transition {id} from download to install; operation is not active"
|
|
);
|
|
false
|
|
}
|
|
}
|
|
};
|
|
|
|
if transitioned {
|
|
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
|
|
}
|
|
|
|
transitioned
|
|
}
|
|
|
|
async fn end_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
|
|
if ctx.active_operations.write().await.remove(id).is_some() {
|
|
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
|
|
}
|
|
}
|
|
|
|
async fn clear_active_download(ctx: &Ctx, id: &str) {
|
|
ctx.active_downloads.write().await.remove(id);
|
|
}
|
|
|
|
fn send_download_finished(tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
|
|
if let Err(err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished { id: id.into() }) {
|
|
log::error!("Failed to send DownloadGameFilesFinished event: {err}");
|
|
}
|
|
}
|
|
|
|
fn send_download_failed(tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
|
|
if let Err(err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.into() }) {
|
|
log::error!("Failed to send DownloadGameFilesFailed event: {err}");
|
|
}
|
|
}
|
|
|
|
fn send_download_failed_unless_cancelled(
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: &str,
|
|
cancelled: bool,
|
|
) -> bool {
|
|
if cancelled {
|
|
return false;
|
|
}
|
|
|
|
send_download_failed(tx_notify_ui, id);
|
|
true
|
|
}
|
|
|
|
async fn end_download_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
|
|
end_operation(ctx, tx_notify_ui, id).await;
|
|
clear_active_download(ctx, id).await;
|
|
}
|
|
|
|
async fn catalog_contains(ctx: &Ctx, id: &str) -> bool {
|
|
ctx.catalog.read().await.contains(id)
|
|
}
|
|
|
|
async fn catalog_expected_version(ctx: &Ctx, id: &str) -> Option<String> {
|
|
ctx.catalog
|
|
.read()
|
|
.await
|
|
.expected_version(id)
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
/// Handles the `SetGameDir` command.
|
|
pub async fn handle_set_game_dir_command(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
game_dir: PathBuf,
|
|
) {
|
|
let current_game_dir = ctx.game_dir.read().await.clone();
|
|
if current_game_dir == game_dir {
|
|
log::info!(
|
|
"Game directory {} unchanged; refreshing without recovery",
|
|
game_dir.display()
|
|
);
|
|
let tx_notify_ui = tx_notify_ui.clone();
|
|
let ctx_clone = ctx.clone();
|
|
ctx.task_tracker.spawn(async move {
|
|
if let Err(err) = refresh_local_library(&ctx_clone, &tx_notify_ui).await {
|
|
log::error!("Failed to refresh local game database: {err}");
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
let active_ids = active_operation_ids(ctx).await;
|
|
if !active_ids.is_empty() {
|
|
log::warn!(
|
|
"Rejecting game directory change to {} while operations are active for: {}",
|
|
game_dir.display(),
|
|
active_ids.into_iter().collect::<Vec<_>>().join(", ")
|
|
);
|
|
return;
|
|
}
|
|
|
|
*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();
|
|
|
|
ctx.task_tracker.spawn(async move {
|
|
match load_local_library_with_policy(
|
|
&ctx_clone,
|
|
&tx_notify_ui,
|
|
LocalLibraryEventPolicy::ForceSnapshot,
|
|
)
|
|
.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<()> {
|
|
load_local_library_with_policy(ctx, tx_notify_ui, LocalLibraryEventPolicy::OnChange).await
|
|
}
|
|
|
|
async fn load_local_library_with_policy(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
event_policy: LocalLibraryEventPolicy,
|
|
) -> eyre::Result<()> {
|
|
let game_dir = { ctx.game_dir.read().await.clone() };
|
|
let active_ids = active_operation_ids(ctx).await;
|
|
install::recover_on_startup(&game_dir, ctx.state_dir.as_ref(), &active_ids).await?;
|
|
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir, event_policy).await
|
|
}
|
|
|
|
async fn refresh_local_library(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
) -> eyre::Result<()> {
|
|
let game_dir = { ctx.game_dir.read().await.clone() };
|
|
scan_and_announce_local_library(
|
|
ctx,
|
|
tx_notify_ui,
|
|
&game_dir,
|
|
LocalLibraryEventPolicy::OnChange,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn scan_and_announce_local_library(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
game_dir: &Path,
|
|
event_policy: LocalLibraryEventPolicy,
|
|
) -> eyre::Result<()> {
|
|
let catalog = ctx.catalog.read().await.clone();
|
|
let scan = scan_local_library(game_dir, ctx.state_dir.as_ref(), &catalog).await?;
|
|
update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy, None).await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Refreshes the game whose operation has completed before clearing its
|
|
/// active-operation snapshot, while preserving freeze behavior for other games.
|
|
async fn refresh_local_game_for_ending_operation(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: &str,
|
|
) -> eyre::Result<()> {
|
|
let game_dir = { ctx.game_dir.read().await.clone() };
|
|
let catalog = ctx.catalog.read().await.clone();
|
|
let scan = rescan_local_game(&game_dir, ctx.state_dir.as_ref(), &catalog, id).await?;
|
|
update_and_announce_games_with_policy(
|
|
ctx,
|
|
tx_notify_ui,
|
|
scan,
|
|
LocalLibraryEventPolicy::OnChange,
|
|
Some(id),
|
|
)
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum LocalLibraryEventPolicy {
|
|
OnChange,
|
|
ForceSnapshot,
|
|
}
|
|
|
|
async fn active_operation_ids(ctx: &Ctx) -> HashSet<String> {
|
|
ctx.active_operations.read().await.keys().cloned().collect()
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
/// Connects to a peer directly, bypassing mDNS discovery.
|
|
pub async fn handle_connect_peer_command(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
addr: SocketAddr,
|
|
) {
|
|
log::info!("Direct connect command received for {addr}");
|
|
let handshake_ctx = HandshakeCtx::from_ctx(ctx, tx_notify_ui);
|
|
|
|
ctx.task_tracker.spawn(async move {
|
|
if let Err(err) = perform_handshake_with_peer(handshake_ctx, addr, None).await {
|
|
log::warn!("Failed direct connect to {addr}: {err}");
|
|
}
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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,
|
|
) {
|
|
update_and_announce_games_with_policy(
|
|
ctx,
|
|
tx_notify_ui,
|
|
scan,
|
|
LocalLibraryEventPolicy::OnChange,
|
|
None,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn update_and_announce_games_with_policy(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
scan: LocalLibraryScan,
|
|
event_policy: LocalLibraryEventPolicy,
|
|
ending_operation_id: Option<&str>,
|
|
) {
|
|
let LocalLibraryScan {
|
|
mut game_db,
|
|
mut summaries,
|
|
revision,
|
|
} = scan;
|
|
|
|
let mut active_operation_ids = active_operation_ids(ctx).await;
|
|
if let Some(id) = ending_operation_id {
|
|
active_operation_ids.remove(id);
|
|
}
|
|
if !active_operation_ids.is_empty() {
|
|
for id in &active_operation_ids {
|
|
summaries.remove(id);
|
|
}
|
|
game_db = GameDB::from(summaries.values().map(game_from_summary).collect());
|
|
}
|
|
|
|
let delta = {
|
|
let mut library_guard = ctx.local_library.write().await;
|
|
library_guard.update_from_scan(summaries, revision)
|
|
};
|
|
|
|
{
|
|
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 delta.is_some() || event_policy == LocalLibraryEventPolicy::ForceSnapshot {
|
|
events::send(
|
|
tx_notify_ui,
|
|
PeerEvent::LocalLibraryChanged {
|
|
games: all_games.clone(),
|
|
},
|
|
);
|
|
} else {
|
|
log::debug!("Skipping unchanged local library event");
|
|
}
|
|
|
|
let Some(delta) = delta else {
|
|
return;
|
|
};
|
|
|
|
let peer_targets = {
|
|
let db = ctx.peer_game_db.read().await;
|
|
db.peer_identities()
|
|
.into_iter()
|
|
.map(|(_peer_id, addr)| addr)
|
|
.collect::<Vec<_>>()
|
|
};
|
|
|
|
for peer_addr in peer_targets {
|
|
let delta = delta.clone();
|
|
let peer_id = ctx.peer_id.as_ref().clone();
|
|
ctx.task_tracker.spawn(async move {
|
|
if let Err(e) = send_library_delta(peer_addr, &peer_id, delta).await {
|
|
log::warn!("Failed to send library delta to {peer_addr}: {e}");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{
|
|
collections::HashMap,
|
|
net::SocketAddr,
|
|
path::{Path, PathBuf},
|
|
sync::{Arc, Mutex},
|
|
time::Duration,
|
|
};
|
|
|
|
use lanspread_db::db::GameCatalog;
|
|
use lanspread_proto::{Availability, GameSummary};
|
|
use tokio::sync::mpsc;
|
|
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
|
|
|
use super::*;
|
|
use crate::{
|
|
ActiveOperation,
|
|
ActiveOperationKind,
|
|
UnpackFuture,
|
|
Unpacker,
|
|
test_support::TempDir,
|
|
};
|
|
|
|
struct FakeUnpacker;
|
|
|
|
impl Unpacker for FakeUnpacker {
|
|
fn unpack<'a>(&'a self, _archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
|
Box::pin(async move {
|
|
tokio::fs::write(dest.join("payload.txt"), b"installed").await?;
|
|
Ok(())
|
|
})
|
|
}
|
|
}
|
|
|
|
fn write_file(path: &Path, bytes: &[u8]) {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent).expect("parent dir should be created");
|
|
}
|
|
std::fs::write(path, bytes).expect("file should be written");
|
|
}
|
|
|
|
fn test_ctx(game_dir: PathBuf) -> Ctx {
|
|
Ctx::new(
|
|
Arc::new(RwLock::new(PeerGameDB::new())),
|
|
"peer".to_string(),
|
|
game_dir.clone(),
|
|
game_dir.join(".test-state"),
|
|
Arc::new(FakeUnpacker),
|
|
CancellationToken::new(),
|
|
TaskTracker::new(),
|
|
Arc::new(RwLock::new(GameCatalog::from_ids(["game".to_string()]))),
|
|
Arc::new(RwLock::new(HashMap::new())),
|
|
Arc::new(crate::NoopStreamInstallProvider),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn cancelled_download_error_does_not_emit_failed_event() {
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
|
|
let emitted = send_download_failed_unless_cancelled(&tx, "game", true);
|
|
|
|
assert!(!emitted);
|
|
assert!(rx.try_recv().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn uncancelled_download_error_emits_failed_event() {
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
|
|
let emitted = send_download_failed_unless_cancelled(&tx, "game", false);
|
|
|
|
assert!(emitted);
|
|
assert!(matches!(
|
|
rx.try_recv(),
|
|
Ok(PeerEvent::DownloadGameFilesFailed { id }) if id == "game"
|
|
));
|
|
}
|
|
|
|
async fn recv_event(rx: &mut mpsc::UnboundedReceiver<PeerEvent>) -> PeerEvent {
|
|
tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
|
.await
|
|
.expect("event should arrive")
|
|
.expect("event channel should remain open")
|
|
}
|
|
|
|
async fn assert_no_event(rx: &mut mpsc::UnboundedReceiver<PeerEvent>) {
|
|
assert!(
|
|
tokio::time::timeout(Duration::from_millis(50), rx.recv())
|
|
.await
|
|
.is_err(),
|
|
"event channel should stay quiet"
|
|
);
|
|
}
|
|
|
|
fn addr(port: u16) -> SocketAddr {
|
|
SocketAddr::from(([127, 0, 0, 1], port))
|
|
}
|
|
|
|
fn summary(id: &str, version: &str, availability: Availability) -> GameSummary {
|
|
GameSummary {
|
|
id: id.to_string(),
|
|
name: id.to_string(),
|
|
size: 42,
|
|
downloaded: availability == Availability::Ready,
|
|
installed: true,
|
|
eti_version: Some(version.to_string()),
|
|
manifest_hash: 7,
|
|
availability,
|
|
}
|
|
}
|
|
|
|
fn file_desc(game_id: &str, relative_path: &str, size: u64) -> GameFileDescription {
|
|
GameFileDescription {
|
|
game_id: game_id.to_string(),
|
|
relative_path: relative_path.to_string(),
|
|
is_dir: false,
|
|
size,
|
|
}
|
|
}
|
|
|
|
fn assert_local_update(event: PeerEvent, installed: bool, downloaded: bool) {
|
|
let _ = local_update_game(event, installed, downloaded);
|
|
}
|
|
|
|
fn local_update_game(
|
|
event: PeerEvent,
|
|
installed: bool,
|
|
downloaded: bool,
|
|
) -> lanspread_db::db::Game {
|
|
let PeerEvent::LocalLibraryChanged { games } = event else {
|
|
panic!("expected LocalLibraryChanged");
|
|
};
|
|
let game = games
|
|
.into_iter()
|
|
.find(|game| game.id == "game")
|
|
.expect("game should be announced");
|
|
assert_eq!(game.installed, installed);
|
|
assert_eq!(game.downloaded, downloaded);
|
|
game
|
|
}
|
|
|
|
fn assert_active_update(event: PeerEvent, expected: Vec<ActiveOperation>) {
|
|
let PeerEvent::ActiveOperationsChanged { active_operations } = event else {
|
|
panic!("expected ActiveOperationsChanged");
|
|
};
|
|
assert_eq!(active_operations, expected);
|
|
}
|
|
|
|
fn active_update(id: &str, operation: ActiveOperationKind) -> Vec<ActiveOperation> {
|
|
vec![ActiveOperation {
|
|
id: id.to_string(),
|
|
operation,
|
|
}]
|
|
}
|
|
|
|
#[test]
|
|
fn update_source_selects_expected_ready_peer_manifest() {
|
|
let old_addr = addr(12_000);
|
|
let new_addr = addr(12_001);
|
|
let local_only_addr = addr(12_002);
|
|
let mut db = PeerGameDB::new();
|
|
db.upsert_peer("old".to_string(), old_addr);
|
|
db.upsert_peer("new".to_string(), new_addr);
|
|
db.upsert_peer("local-only".to_string(), local_only_addr);
|
|
db.update_peer_games(
|
|
&"old".to_string(),
|
|
vec![summary("game", "20240101", Availability::Ready)],
|
|
);
|
|
db.update_peer_games(
|
|
&"new".to_string(),
|
|
vec![summary("game", "20250101", Availability::Ready)],
|
|
);
|
|
db.update_peer_games(
|
|
&"local-only".to_string(),
|
|
vec![summary("game", "20990101", Availability::LocalOnly)],
|
|
);
|
|
|
|
assert_eq!(
|
|
GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101")),
|
|
vec![new_addr]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_fetch_emits_fresh_manifest_from_expected_peer() {
|
|
let old_addr = addr(12_010);
|
|
let new_addr = addr(12_011);
|
|
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
|
{
|
|
let mut db = peer_game_db.write().await;
|
|
db.upsert_peer("old".to_string(), old_addr);
|
|
db.upsert_peer("new".to_string(), new_addr);
|
|
db.update_peer_games(
|
|
&"old".to_string(),
|
|
vec![summary("game", "20240101", Availability::Ready)],
|
|
);
|
|
db.update_peer_games(
|
|
&"new".to_string(),
|
|
vec![summary("game", "20250101", Availability::Ready)],
|
|
);
|
|
}
|
|
let peers = {
|
|
let db = peer_game_db.read().await;
|
|
GameDetailSource::LatestPeersOnly.select_peers(&db, "game", Some("20250101"))
|
|
};
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let fetched_peers = Arc::new(Mutex::new(Vec::new()));
|
|
|
|
fetch_game_details_from_peers(
|
|
peers,
|
|
"game".to_string(),
|
|
Some("20250101".to_string()),
|
|
peer_game_db.clone(),
|
|
tx,
|
|
{
|
|
let fetched_peers = fetched_peers.clone();
|
|
move |peer_addr, game_id, peer_game_db| {
|
|
let fetched_peers = fetched_peers.clone();
|
|
async move {
|
|
fetched_peers
|
|
.lock()
|
|
.expect("fetched peer list should not be poisoned")
|
|
.push(peer_addr);
|
|
let files = vec![
|
|
file_desc(&game_id, "game/version.ini", 8),
|
|
file_desc(&game_id, "game/new.eti", 11),
|
|
];
|
|
peer_game_db.write().await.update_peer_game_files(
|
|
&"new".to_string(),
|
|
&game_id,
|
|
files.clone(),
|
|
);
|
|
Ok(files)
|
|
}
|
|
}
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
*fetched_peers
|
|
.lock()
|
|
.expect("fetched peer list should not be poisoned"),
|
|
vec![new_addr]
|
|
);
|
|
let PeerEvent::GotGameFiles {
|
|
id,
|
|
file_descriptions,
|
|
} = recv_event(&mut rx).await
|
|
else {
|
|
panic!("expected GotGameFiles");
|
|
};
|
|
assert_eq!(id, "game");
|
|
assert!(
|
|
file_descriptions
|
|
.iter()
|
|
.any(|desc| desc.relative_path == "game/new.eti" && desc.size == 11),
|
|
"expected-version peer manifest should be emitted to the download path"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_peer_detail_fetch_emits_terminal_download_failure() {
|
|
let first_addr = addr(12_020);
|
|
let second_addr = addr(12_021);
|
|
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let fetched_peers = Arc::new(Mutex::new(Vec::new()));
|
|
|
|
fetch_game_details_from_peers(
|
|
vec![first_addr, second_addr],
|
|
"game".to_string(),
|
|
Some("20250101".to_string()),
|
|
peer_game_db,
|
|
tx.clone(),
|
|
{
|
|
let fetched_peers = fetched_peers.clone();
|
|
move |peer_addr, _game_id, _peer_game_db| {
|
|
let fetched_peers = fetched_peers.clone();
|
|
async move {
|
|
fetched_peers
|
|
.lock()
|
|
.expect("fetched peer list should not be poisoned")
|
|
.push(peer_addr);
|
|
Err::<Vec<GameFileDescription>, _>(eyre::eyre!("detail fetch failed"))
|
|
}
|
|
}
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
*fetched_peers
|
|
.lock()
|
|
.expect("fetched peer list should not be poisoned"),
|
|
vec![first_addr, second_addr]
|
|
);
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::DownloadGameFilesFailed { id } if id == "game"
|
|
));
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_request_skips_local_manifest_even_when_download_exists() {
|
|
let temp = TempDir::new("lanspread-handler-expected-peer");
|
|
let root = temp.game_root();
|
|
write_file(&root.join("version.ini"), b"20240101");
|
|
write_file(&root.join("game.eti"), b"old archive");
|
|
|
|
let ctx = test_ctx(temp.path().to_path_buf());
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
|
|
handle_get_game_command(
|
|
&ctx,
|
|
&tx,
|
|
"game".to_string(),
|
|
GameDetailSource::LatestPeersOnly,
|
|
)
|
|
.await;
|
|
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::NoPeersHaveGame { id } if id == "game"
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn local_library_scan_hides_active_game_state() {
|
|
let temp = TempDir::new("lanspread-handler-active-hide");
|
|
let root = temp.game_root();
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("game.eti"), b"archive");
|
|
|
|
let ctx = test_ctx(temp.path().to_path_buf());
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let catalog = ctx.catalog.read().await.clone();
|
|
|
|
// 1. Initial scan: the game is ready and announced
|
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
|
.await
|
|
.expect("scan should succeed");
|
|
update_and_announce_games(&ctx, &tx, scan).await;
|
|
|
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
|
panic!("expected LocalLibraryChanged");
|
|
};
|
|
assert_eq!(games.len(), 1);
|
|
assert_eq!(games[0].id, "game");
|
|
|
|
// 2. Set the game as active/in-progress and scan again
|
|
ctx.active_operations
|
|
.write()
|
|
.await
|
|
.insert("game".to_string(), OperationKind::Installing);
|
|
|
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
|
.await
|
|
.expect("scan should succeed");
|
|
update_and_announce_games(&ctx, &tx, scan).await;
|
|
|
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
|
panic!("expected LocalLibraryChanged");
|
|
};
|
|
assert!(
|
|
games.is_empty(),
|
|
"active game should be hidden/unannounced during operations"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn begin_operation_reports_authoritative_active_operation_snapshot() {
|
|
let temp = TempDir::new("lanspread-handler-active-begin");
|
|
let root = temp.game_root();
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("game.eti"), b"archive");
|
|
|
|
let ctx = test_ctx(temp.path().to_path_buf());
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
|
|
assert_eq!(
|
|
begin_operation(&ctx, &tx, "game", OperationKind::Updating).await,
|
|
BeginOperationResult::Started
|
|
);
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
vec![ActiveOperation {
|
|
id: "game".to_string(),
|
|
operation: ActiveOperationKind::Updating,
|
|
}],
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn begin_operation_timeout_clears_active_operation_snapshot() {
|
|
let temp = TempDir::new("lanspread-handler-active-drain-timeout");
|
|
let root = temp.game_root();
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("game.eti"), b"archive");
|
|
|
|
let ctx = test_ctx(temp.path().to_path_buf());
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let token = CancellationToken::new();
|
|
ctx.active_outbound_transfers
|
|
.write()
|
|
.await
|
|
.insert("game".to_string(), vec![(1, token.clone())]);
|
|
|
|
assert_eq!(
|
|
begin_operation_with_drain_timeout(
|
|
&ctx,
|
|
&tx,
|
|
"game",
|
|
OperationKind::Updating,
|
|
Duration::from_millis(1),
|
|
)
|
|
.await,
|
|
BeginOperationResult::DrainTimedOut
|
|
);
|
|
|
|
assert!(token.is_cancelled());
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
vec![ActiveOperation {
|
|
id: "game".to_string(),
|
|
operation: ActiveOperationKind::Updating,
|
|
}],
|
|
);
|
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
|
assert!(
|
|
!ctx.active_operations.read().await.contains_key("game"),
|
|
"timed-out drain should not leave the operation stuck active"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unchanged_settled_scan_is_not_reemitted() {
|
|
let temp = TempDir::new("lanspread-handler-settled-unchanged");
|
|
let root = temp.game_root();
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("game.eti"), b"archive");
|
|
|
|
let ctx = test_ctx(temp.path().to_path_buf());
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let catalog = ctx.catalog.read().await.clone();
|
|
|
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
|
.await
|
|
.expect("first scan should succeed");
|
|
update_and_announce_games(&ctx, &tx, scan).await;
|
|
assert_local_update(recv_event(&mut rx).await, false, true);
|
|
|
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
|
.await
|
|
.expect("second scan should succeed");
|
|
update_and_announce_games(&ctx, &tx, scan).await;
|
|
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unchanged_operation_refresh_still_reports_settled_snapshot() {
|
|
let temp = TempDir::new("lanspread-handler-operation-unchanged");
|
|
let root = temp.game_root();
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("game.eti"), b"archive");
|
|
write_file(&root.join("local").join("old.txt"), b"old");
|
|
|
|
let ctx = test_ctx(temp.path().to_path_buf());
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let catalog = ctx.catalog.read().await.clone();
|
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
|
.await
|
|
.expect("initial scan should succeed");
|
|
update_and_announce_games(&ctx, &tx, scan).await;
|
|
assert_local_update(recv_event(&mut rx).await, true, true);
|
|
|
|
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
|
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
active_update("game", ActiveOperationKind::Updating),
|
|
);
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::InstallGameBegin {
|
|
id,
|
|
operation: InstallOperation::Updating
|
|
} if id == "game"
|
|
));
|
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::InstallGameFinished { id } if id == "game"
|
|
));
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_refreshes_settled_state_before_operation_clear() {
|
|
let temp = TempDir::new("lanspread-handler-install");
|
|
let root = temp.game_root();
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("game.eti"), b"archive");
|
|
|
|
let ctx = test_ctx(temp.path().to_path_buf());
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
|
|
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
|
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
active_update("game", ActiveOperationKind::Installing),
|
|
);
|
|
match recv_event(&mut rx).await {
|
|
PeerEvent::InstallGameBegin { id, operation } => {
|
|
assert_eq!(id, "game");
|
|
assert_eq!(operation, InstallOperation::Installing);
|
|
}
|
|
_ => panic!("expected InstallGameBegin"),
|
|
}
|
|
assert_local_update(recv_event(&mut rx).await, true, true);
|
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::InstallGameFinished { id } if id == "game"
|
|
));
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn download_handoff_waits_for_readers_and_auto_installs() {
|
|
let temp = TempDir::new("lanspread-handler-download-handoff");
|
|
let root = temp.game_root();
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("game.eti"), b"archive");
|
|
|
|
let ctx = test_ctx(temp.path().to_path_buf());
|
|
ctx.active_operations
|
|
.write()
|
|
.await
|
|
.insert("game".to_string(), OperationKind::Downloading);
|
|
ctx.active_downloads
|
|
.write()
|
|
.await
|
|
.insert("game".to_string(), CancellationToken::new());
|
|
let (prepare_tx, _prepare_rx) = mpsc::unbounded_channel();
|
|
let prepared = prepare_install_operation(&ctx, &prepare_tx, "game")
|
|
.await
|
|
.expect("downloaded game should be installable");
|
|
let read_guard = ctx.active_operations.read().await;
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
|
|
let install_task = tokio::spawn({
|
|
let ctx = ctx.clone();
|
|
let tx = tx.clone();
|
|
async move {
|
|
assert!(
|
|
transition_download_to_install(&ctx, &tx, "game", prepared.operation_kind)
|
|
.await
|
|
);
|
|
clear_active_download(&ctx, "game").await;
|
|
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared).await;
|
|
}
|
|
});
|
|
|
|
tokio::task::yield_now().await;
|
|
assert_eq!(read_guard.get("game"), Some(&OperationKind::Downloading));
|
|
drop(read_guard);
|
|
install_task.await.expect("handoff task should finish");
|
|
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
active_update("game", ActiveOperationKind::Installing),
|
|
);
|
|
match recv_event(&mut rx).await {
|
|
PeerEvent::InstallGameBegin { id, operation } => {
|
|
assert_eq!(id, "game");
|
|
assert_eq!(operation, InstallOperation::Installing);
|
|
}
|
|
_ => panic!("expected InstallGameBegin"),
|
|
}
|
|
assert_local_update(recv_event(&mut rx).await, true, true);
|
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::InstallGameFinished { id } if id == "game"
|
|
));
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
assert!(ctx.active_downloads.read().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancel_download_command_only_cancels_active_token() {
|
|
let temp = TempDir::new("lanspread-handler-cancel-download");
|
|
let ctx = test_ctx(temp.path().to_path_buf());
|
|
let cancel = CancellationToken::new();
|
|
ctx.active_operations
|
|
.write()
|
|
.await
|
|
.insert("game".to_string(), OperationKind::Downloading);
|
|
ctx.active_downloads
|
|
.write()
|
|
.await
|
|
.insert("game".to_string(), cancel.clone());
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
|
|
handle_cancel_download_command(&ctx, &tx, "game".to_string()).await;
|
|
|
|
assert!(cancel.is_cancelled());
|
|
assert_eq!(
|
|
ctx.active_operations.read().await.get("game"),
|
|
Some(&OperationKind::Downloading),
|
|
"the running transfer owns operation cleanup after cancellation"
|
|
);
|
|
assert_no_event(&mut rx).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_refreshes_settled_state_before_operation_clear() {
|
|
let temp = TempDir::new("lanspread-handler-update");
|
|
let root = temp.game_root();
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("game.eti"), b"archive");
|
|
write_file(&root.join("local").join("old.txt"), b"old");
|
|
|
|
let ctx = test_ctx(temp.path().to_path_buf());
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
|
|
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
|
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
active_update("game", ActiveOperationKind::Updating),
|
|
);
|
|
match recv_event(&mut rx).await {
|
|
PeerEvent::InstallGameBegin { id, operation } => {
|
|
assert_eq!(id, "game");
|
|
assert_eq!(operation, InstallOperation::Updating);
|
|
}
|
|
_ => panic!("expected InstallGameBegin"),
|
|
}
|
|
assert_local_update(recv_event(&mut rx).await, true, true);
|
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::InstallGameFinished { id } if id == "game"
|
|
));
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_update_uninstall_sequence_reports_new_version_and_settled_state() {
|
|
let temp = TempDir::new("lanspread-handler-sequence");
|
|
let root = temp.game_root();
|
|
write_file(&root.join("version.ini"), b"20240101");
|
|
write_file(&root.join("game.eti"), b"old archive");
|
|
|
|
let ctx = test_ctx(temp.path().to_path_buf());
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
|
|
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
active_update("game", ActiveOperationKind::Installing),
|
|
);
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::InstallGameBegin {
|
|
id,
|
|
operation: InstallOperation::Installing
|
|
} if id == "game"
|
|
));
|
|
let game = local_update_game(recv_event(&mut rx).await, true, true);
|
|
assert_eq!(game.local_version.as_deref(), Some("20240101"));
|
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::InstallGameFinished { id } if id == "game"
|
|
));
|
|
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("game.eti"), b"new archive");
|
|
|
|
run_install_operation(&ctx, &tx, "game".to_string()).await;
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
active_update("game", ActiveOperationKind::Updating),
|
|
);
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::InstallGameBegin {
|
|
id,
|
|
operation: InstallOperation::Updating
|
|
} if id == "game"
|
|
));
|
|
let game = local_update_game(recv_event(&mut rx).await, true, true);
|
|
assert_eq!(game.local_version.as_deref(), Some("20250101"));
|
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::InstallGameFinished { id } if id == "game"
|
|
));
|
|
|
|
run_uninstall_operation(&ctx, &tx, "game".to_string()).await;
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
active_update("game", ActiveOperationKind::Uninstalling),
|
|
);
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::UninstallGameBegin { id } if id == "game"
|
|
));
|
|
let game = local_update_game(recv_event(&mut rx).await, false, true);
|
|
assert_eq!(game.local_version.as_deref(), Some("20250101"));
|
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::UninstallGameFinished { id } if id == "game"
|
|
));
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn uninstall_refreshes_settled_state_before_operation_clear() {
|
|
let temp = TempDir::new("lanspread-handler-uninstall");
|
|
let root = temp.game_root();
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("game.eti"), b"archive");
|
|
write_file(&root.join("local").join("old.txt"), b"old");
|
|
|
|
let ctx = test_ctx(temp.path().to_path_buf());
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
|
|
run_uninstall_operation(&ctx, &tx, "game".to_string()).await;
|
|
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
active_update("game", ActiveOperationKind::Uninstalling),
|
|
);
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::UninstallGameBegin { id } if id == "game"
|
|
));
|
|
assert_local_update(recv_event(&mut rx).await, false, true);
|
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::UninstallGameFinished { id } if id == "game"
|
|
));
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remove_downloaded_refreshes_settled_state_before_operation_clear() {
|
|
let temp = TempDir::new("lanspread-handler-remove-downloaded");
|
|
let root = temp.game_root();
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("game.eti"), b"archive");
|
|
|
|
let ctx = test_ctx(temp.path().to_path_buf());
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let catalog = ctx.catalog.read().await.clone();
|
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
|
.await
|
|
.expect("initial scan should succeed");
|
|
update_and_announce_games(&ctx, &tx, scan).await;
|
|
assert_local_update(recv_event(&mut rx).await, false, true);
|
|
|
|
run_remove_downloaded_operation(&ctx, &tx, "game".to_string()).await;
|
|
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
active_update("game", ActiveOperationKind::RemovingDownload),
|
|
);
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::RemoveDownloadedGameBegin { id } if id == "game"
|
|
));
|
|
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
|
|
panic!("expected LocalLibraryChanged");
|
|
};
|
|
assert!(games.is_empty());
|
|
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
|
assert!(matches!(
|
|
recv_event(&mut rx).await,
|
|
PeerEvent::RemoveDownloadedGameFinished { id } if id == "game"
|
|
));
|
|
assert!(!root.exists());
|
|
assert!(ctx.active_operations.read().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn path_changing_set_game_dir_is_rejected_while_operations_are_active() {
|
|
let current = TempDir::new("lanspread-handler-current-dir");
|
|
let next = TempDir::new("lanspread-handler-next-dir");
|
|
let ctx = test_ctx(current.path().to_path_buf());
|
|
ctx.active_operations
|
|
.write()
|
|
.await
|
|
.insert("game".to_string(), OperationKind::Downloading);
|
|
let (tx, _rx) = mpsc::unbounded_channel();
|
|
|
|
handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await;
|
|
|
|
assert_eq!(*ctx.game_dir.read().await, current.path());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn same_path_set_game_dir_refreshes_without_recovery() {
|
|
let temp = TempDir::new("lanspread-handler-same-dir");
|
|
write_file(&temp.game_root().join(".version.ini.tmp"), b"tmp");
|
|
let ctx = test_ctx(temp.path().to_path_buf());
|
|
let (tx, _rx) = mpsc::unbounded_channel();
|
|
|
|
handle_set_game_dir_command(&ctx, &tx, temp.path().to_path_buf()).await;
|
|
ctx.task_tracker.close();
|
|
ctx.task_tracker.wait().await;
|
|
|
|
assert!(temp.game_root().join(".version.ini.tmp").is_file());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn path_changing_set_game_dir_runs_recovery() {
|
|
let current = TempDir::new("lanspread-handler-old-dir");
|
|
let next = TempDir::new("lanspread-handler-new-dir");
|
|
write_file(&next.game_root().join(".version.ini.tmp"), b"tmp");
|
|
let ctx = test_ctx(current.path().to_path_buf());
|
|
let (tx, _rx) = mpsc::unbounded_channel();
|
|
|
|
handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await;
|
|
ctx.task_tracker.close();
|
|
ctx.task_tracker.wait().await;
|
|
|
|
assert!(!next.game_root().join(".version.ini.tmp").exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn path_changing_set_game_dir_emits_equivalent_snapshot() {
|
|
let current = TempDir::new("lanspread-handler-old-equivalent-dir");
|
|
let next = TempDir::new("lanspread-handler-new-equivalent-dir");
|
|
for root in [current.game_root(), next.game_root()] {
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("game.eti"), b"archive");
|
|
}
|
|
|
|
let ctx = test_ctx(current.path().to_path_buf());
|
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
let catalog = ctx.catalog.read().await.clone();
|
|
let scan = scan_local_library(current.path(), ctx.state_dir.as_ref(), &catalog)
|
|
.await
|
|
.expect("initial scan should succeed");
|
|
update_and_announce_games(&ctx, &tx, scan).await;
|
|
assert_local_update(recv_event(&mut rx).await, false, true);
|
|
|
|
handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await;
|
|
ctx.task_tracker.close();
|
|
ctx.task_tracker.wait().await;
|
|
|
|
assert_local_update(recv_event(&mut rx).await, false, true);
|
|
}
|
|
}
|