fix(peer): settle current-protocol local state cleanup

The follow-up backlog had drifted into three settled peer/runtime issues: the
legacy game-list fallback contradicted the one-wire-version policy, the Tauri
shell still re-derived local install state from disk after peer snapshots, and
`Availability::Downloading` existed even though active operations are already
reported through a separate operation table.

Remove the legacy `AnnounceGames` request and fallback service. Discovery now
ignores peers that do not advertise the current protocol and a peer id, and
library changes are sent through the current delta path only. This keeps the
runtime aligned with the documented current-build-only interoperability model.

Make peer `LocalGamesUpdated` snapshots authoritative for local fields in the
Tauri database. The GUI-side catalog still owns static metadata such as names,
sizes, and descriptions, but downloaded, installed, local version, and
availability now come from the peer runtime instead of a second whole-library
filesystem scan. Snapshot reconciliation also pins the missing-begin and
missing-finish lifecycle cases in tests.

Collapse availability back to the settled `Ready` and `LocalOnly` states.
Aggregation now counts only `Ready` peers as download sources, and the frontend
no longer carries a dead `Downloading` enum value.

The core peer also exposes the small non-GUI hooks needed by scripted callers:
startup options for state and mDNS, a local-ready event, direct connection, peer
snapshots, and an explicit post-download install policy. Those hooks reuse the
same current protocol path and do not add compatibility shims.

Test Plan:
- `git diff --check`
- `just fmt`
- `just clippy`
- `just test`

Refs: BACKLOG.md, FINDINGS.md, IMPL_DECISIONS.md
This commit is contained in:
2026-05-16 18:32:24 +02:00
parent 6242d64583
commit e711cf3454
23 changed files with 531 additions and 723 deletions
+104 -38
View File
@@ -8,7 +8,8 @@ use std::{
sync::Arc,
};
use lanspread_db::db::{GameDB, GameFileDescription};
use lanspread_db::db::{Game, GameDB, GameFileDescription};
use lanspread_proto::GameSummary;
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use crate::{
@@ -19,7 +20,6 @@ use crate::{
context::{Ctx, OperationGuard, OperationKind},
download::download_game_files,
events,
identity::FEATURE_LIBRARY_DELTA,
install,
local_games::{
LocalLibraryScan,
@@ -31,9 +31,10 @@ use crate::{
scan_local_library,
version_ini_is_regular_file,
},
network::{announce_games_to_peer, request_game_details_from_peer, send_library_delta},
network::{request_game_details_from_peer, request_game_list_from_peer, send_library_delta},
peer_db::PeerGameDB,
remote_peer::ensure_peer_id_for_addr,
services::perform_handshake_with_peer,
};
// =============================================================================
@@ -197,6 +198,7 @@ pub async fn handle_download_game_files_command(
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
file_descriptions: Vec<GameFileDescription>,
install_after_download: bool,
) {
log::info!("Got PeerCommand::DownloadGameFiles");
let games_folder = { ctx.game_dir.read().await.clone() };
@@ -262,7 +264,9 @@ pub async fn handle_download_game_files_command(
{
log::error!("Failed to send DownloadGameFilesFinished event: {e}");
}
spawn_install_operation(ctx, tx_notify_ui, id.clone());
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}");
}
@@ -319,19 +323,32 @@ pub async fn handle_download_game_files_command(
return;
};
if transition_download_to_install(&ctx_clone, &download_id, prepared.operation_kind)
.await
{
clear_active_download(&ctx_clone, &download_id).await;
run_started_install_operation(
if install_after_download {
if transition_download_to_install(
&ctx_clone,
&tx_notify_ui_clone,
download_id,
prepared,
&download_id,
prepared.operation_kind,
)
.await;
.await
{
clear_active_download(&ctx_clone, &download_id).await;
run_started_install_operation(
&ctx_clone,
&tx_notify_ui_clone,
download_id,
prepared,
)
.await;
} else {
clear_active_download(&ctx_clone, &download_id).await;
}
} else {
clear_active_download(&ctx_clone, &download_id).await;
end_download_operation(&ctx_clone, &download_id).await;
if let Err(err) =
refresh_local_game(&ctx_clone, &tx_notify_ui_clone, &download_id).await
{
log::error!("Failed to refresh local library after download: {err}");
}
}
download_state_guard.disarm();
}
@@ -681,6 +698,69 @@ pub async fn handle_get_peer_count_command(ctx: &Ctx, tx_notify_ui: &UnboundedSe
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 peer_id = ctx.peer_id.clone();
let local_library = ctx.local_library.clone();
let peer_game_db = ctx.peer_game_db.clone();
let tx_notify_ui = tx_notify_ui.clone();
ctx.task_tracker.spawn(async move {
if let Err(err) = perform_handshake_with_peer(
peer_id,
local_library,
peer_game_db.clone(),
tx_notify_ui.clone(),
addr,
None,
)
.await
{
log::warn!("Failed direct connect to {addr}: {err}");
return;
}
if let Err(err) = refresh_direct_peer_games(&peer_game_db, &tx_notify_ui, addr).await {
log::warn!("Failed to refresh direct peer games from {addr}: {err}");
}
});
}
async fn refresh_direct_peer_games(
peer_game_db: &Arc<RwLock<PeerGameDB>>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
addr: SocketAddr,
) -> eyre::Result<()> {
let games = request_game_list_from_peer(addr).await?;
let summaries = games.into_iter().map(game_to_summary).collect::<Vec<_>>();
let peer_id = ensure_peer_id_for_addr(peer_game_db, addr).await;
{
let mut db = peer_game_db.write().await;
db.update_peer_games(&peer_id, summaries);
}
events::emit_peer_game_list(peer_game_db, tx_notify_ui).await;
Ok(())
}
fn game_to_summary(game: Game) -> GameSummary {
let availability = game.normalized_availability();
GameSummary {
id: game.id,
name: game.name,
size: game.size,
downloaded: game.downloaded,
installed: game.installed,
eti_version: game.eti_game_version,
manifest_hash: 0,
availability,
}
}
// =============================================================================
// Game announcement helpers
// =============================================================================
@@ -737,32 +817,17 @@ pub async fn update_and_announce_games(
let db = ctx.peer_game_db.read().await;
db.peer_identities()
.into_iter()
.map(|(peer_id, addr)| {
let features = db.peer_features(&peer_id);
(peer_id, addr, features)
})
.map(|(_peer_id, addr)| addr)
.collect::<Vec<_>>()
};
for (_peer_id, peer_addr, features) in peer_targets {
if features
.iter()
.any(|feature| feature == FEATURE_LIBRARY_DELTA)
{
let delta = delta.clone();
ctx.task_tracker.spawn(async move {
if let Err(e) = send_library_delta(peer_addr, delta).await {
log::warn!("Failed to send library delta to {peer_addr}: {e}");
}
});
} else {
let games_clone = all_games.clone();
ctx.task_tracker.spawn(async move {
if let Err(e) = announce_games_to_peer(peer_addr, games_clone).await {
log::warn!("Failed to announce games to {peer_addr}: {e}");
}
});
}
for peer_addr in peer_targets {
let delta = delta.clone();
ctx.task_tracker.spawn(async move {
if let Err(e) = send_library_delta(peer_addr, delta).await {
log::warn!("Failed to send library delta to {peer_addr}: {e}");
}
});
}
}
@@ -832,6 +897,7 @@ mod tests {
CancellationToken::new(),
TaskTracker::new(),
Arc::new(RwLock::new(HashSet::from(["game".to_string()]))),
true,
)
}
@@ -851,7 +917,7 @@ mod tests {
id: id.to_string(),
name: id.to_string(),
size: 42,
downloaded: availability.is_downloaded(),
downloaded: availability == Availability::Ready,
installed: true,
eti_version: Some(version.to_string()),
manifest_hash: 7,