09709cc008
Some games ship a SmartSteamEmu.ini somewhere under their installed
local/ tree with a `PersonaName = ...` line that must carry the player's
configured username. They also ship account_name.txt and language.txt
files that the launcher already overwrote with the username/language.
Previously that account_name.txt/language.txt overwrite happened inside
the install transaction, so it only applied to freshly (re)installed
games — games already installed by an older build never got fixed up,
and the SmartSteamEmu.ini PersonaName line was not handled at all.
This moves all per-user setting application out of install and into a
single one-shot step performed the first time a game is played, gated by
a new per-game marker `games/<id>/launch_settings_applied` under the
state dir. On first play we search the whole local/ tree and stamp:
- the username into the first account_name.txt,
- the language into the first language.txt,
- the username into the first SmartSteamEmu.ini PersonaName line,
preserving that line's existing line ending (\n or \r\n) and its
surrounding whitespace, leaving sibling lines untouched.
The marker only records that we *tried*: it is written unconditionally
after the first play, so a game with none of these files is still marked
done and never rescanned. Because already-installed games have no marker
yet, they are fixed up on their next play rather than only on reinstall.
To keep the marker honest across version changes, the install and update
transactions now clear it on success, so a freshly extracted local/ is
re-stamped on the next play.
Behavior changes from the user's perspective:
- The first time you press Play after this change, your username/
language are (re)applied to an existing install, including games you
installed before this feature existed.
- SmartSteamEmu.ini's PersonaName now reflects the launcher username.
Plumbing: account_name/language are removed from PeerCommand::InstallGame
/DownloadGameFiles[WithOptions] and the whole install handler chain, and
the Tauri pending_install_settings bookkeeping is gone — the launcher now
computes the values at play time in run_game and calls
lanspread_peer::apply_launch_settings_once. The headless harness gains a
`play` command exposing the same step for scripted testing.
Test Plan
- just test: new lanspread_peer::launch_settings unit tests cover the
PersonaName rewrite, \n/\r\n preservation, first-match search, the
unconditional marker, and the no-op-once-applied path; a transaction
test covers the install marker reset. Whole workspace is green.
- just clippy clean; the change adds no new clippy warnings (incl.
--tests).
- S38 (new in PEER_CLI_SCENARIOS.md): host run of lanspread-peer-cli
against the new fixture-persona/css RAR .eti (with --unrar) installs
css, then `play css` stamps the deeply-buried CRLF PersonaName line,
account_name.txt, and language.txt and creates the marker; a second
`play` is a no-op even after the values are reset externally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1863 lines
63 KiB
Rust
1863 lines
63 KiB
Rust
//! Command handlers for peer commands.
|
|
|
|
use std::{
|
|
collections::{HashSet, hash_map::Entry},
|
|
future::Future,
|
|
net::SocketAddr,
|
|
path::{Path, PathBuf},
|
|
sync::Arc,
|
|
};
|
|
|
|
use lanspread_db::db::{GameDB, GameFileDescription};
|
|
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
|
|
|
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_available,
|
|
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},
|
|
};
|
|
|
|
// =============================================================================
|
|
// 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 active_operations = ctx.active_operations.read().await;
|
|
let catalog = ctx.catalog.read().await;
|
|
if !local_download_available(&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 peers = {
|
|
let peer_game_db = ctx.peer_game_db.read().await;
|
|
source.select_peers(&peer_game_db, &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();
|
|
ctx.task_tracker.spawn(fetch_game_details_from_peers(
|
|
peers,
|
|
id,
|
|
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) -> Vec<SocketAddr> {
|
|
match self {
|
|
Self::LocalOrPeers => peer_game_db.peers_with_game(id),
|
|
Self::LatestPeersOnly => peer_game_db.peers_with_latest_version(id),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
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) };
|
|
|
|
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() };
|
|
|
|
// 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"
|
|
);
|
|
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_available(&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;
|
|
}
|
|
|
|
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
|
|
log::warn!("Operation for {id} already in progress; ignoring new download request");
|
|
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);
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
|
|
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;
|
|
};
|
|
|
|
if !begin_operation(ctx, tx_notify_ui, &id, prepared.operation_kind).await {
|
|
log::warn!("Operation for {id} already in progress; ignoring install command");
|
|
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;
|
|
}
|
|
|
|
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::Uninstalling).await {
|
|
log::warn!("Operation for {id} already in progress; ignoring uninstall command");
|
|
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;
|
|
}
|
|
|
|
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::RemovingDownload).await {
|
|
log::warn!("Operation for {id} already in progress; ignoring downloaded-file removal");
|
|
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() },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn begin_operation(
|
|
ctx: &Ctx,
|
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
|
id: &str,
|
|
operation: OperationKind,
|
|
) -> bool {
|
|
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 {
|
|
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
|
|
}
|
|
|
|
started
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/// 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() {
|
|
let previous = ctx.local_library.read().await.games.clone();
|
|
for id in &active_operation_ids {
|
|
if let Some(summary) = previous.get(id.as_str()) {
|
|
summaries.insert(id.clone(), summary.clone());
|
|
} else {
|
|
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::HashSet,
|
|
net::SocketAddr,
|
|
path::{Path, PathBuf},
|
|
sync::{Arc, Mutex},
|
|
time::Duration,
|
|
};
|
|
|
|
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(HashSet::from(["game".to_string()]))),
|
|
)
|
|
}
|
|
|
|
#[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_latest_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"),
|
|
vec![new_addr]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_fetch_emits_fresh_manifest_from_latest_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")
|
|
};
|
|
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(), 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),
|
|
"latest 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(),
|
|
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-latest-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_freezes_active_game_state() {
|
|
let temp = TempDir::new("lanspread-handler-active-freeze");
|
|
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::Installing);
|
|
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("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 keep its previous announced state"
|
|
);
|
|
}
|
|
|
|
#[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!(begin_operation(&ctx, &tx, "game", OperationKind::Updating).await);
|
|
assert_active_update(
|
|
recv_event(&mut rx).await,
|
|
vec![ActiveOperation {
|
|
id: "game".to_string(),
|
|
operation: ActiveOperationKind::Updating,
|
|
}],
|
|
);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|