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
@@ -9,7 +9,7 @@ use std::{
use eyre::bail;
use lanspread_compat::eti::get_games;
use lanspread_db::db::{Game, GameDB, GameFileDescription};
use lanspread_db::db::{Availability, Game, GameDB, GameFileDescription};
use lanspread_peer::{
ActiveOperation,
ActiveOperationKind,
@@ -114,10 +114,17 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
let peer_ctrl = peer_ctrl_arc.read().await.clone();
let games_folder = state.inner().games_folder.read().await.clone();
let game_path = PathBuf::from(games_folder).join(&id);
let downloaded = game_path.join("version.ini").is_file();
let installed = local_install_is_present(&game_path);
let Some((downloaded, installed)) = state
.inner()
.games
.read()
.await
.get_game_by_id(&id)
.map(|game| (game.downloaded, game.installed))
else {
log::warn!("Ignoring install request for unknown game: {id}");
return Ok(false);
};
let handled = if let Some(peer_ctrl) = peer_ctrl {
let command = if !downloaded {
@@ -351,177 +358,72 @@ async fn run_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri:
Ok(())
}
fn eti_package_exists(game_path: &Path, game_id: &str) -> bool {
game_path.is_dir() && game_path.join(format!("{game_id}.eti")).is_file()
}
#[cfg(target_os = "windows")]
fn local_install_is_present(game_path: &Path) -> bool {
game_path.join("local").is_dir()
}
fn update_game_installation_state(game: &mut Game, games_root: &Path) {
let game_path = games_root.join(&game.id);
if !game_path.is_dir() {
return;
}
let downloaded = game_path.join("version.ini").is_file();
game.set_downloaded(downloaded);
let installed = local_install_is_present(&game_path);
game.installed = installed;
// Size stays anchored to bundled game.db; skip expensive recalculation.
if downloaded {
match lanspread_db::db::read_version_from_ini(&game_path) {
Ok(version) => {
game.local_version = version;
if let Some(ref version) = game.local_version {
log::debug!("Read local version for game {}: {}", game.id, version);
}
}
Err(e) => {
log::warn!("Failed to read local version.ini for game {}: {e}", game.id);
game.local_version = None;
}
}
} else {
game.local_version = None;
}
if installed {
log::debug!("Set {game} to installed");
}
if eti_package_exists(&game_path, &game.id) && !downloaded {
log::debug!(
"Game {} has archives but no version.ini sentinel; treating as not downloaded",
game.id
);
}
fn clear_local_game_state(game: &mut Game) {
game.set_downloaded(false);
game.installed = false;
game.local_version = None;
}
/// Left in place for potential re-enablement. Currently not invoked to avoid expensive IO.
#[allow(dead_code)]
fn calculate_directory_size_sync(dir: &Path) -> eyre::Result<u64> {
let mut total_size = 0u64;
for entry in walkdir::WalkDir::new(dir) {
let entry = entry?;
let path = entry.path();
if path.is_file() {
let metadata = std::fs::metadata(path)?;
total_size += metadata.len();
}
}
Ok(total_size)
fn has_local_game_state(game: &Game) -> bool {
game.downloaded
|| game.installed
|| game.local_version.is_some()
|| game.availability != Availability::LocalOnly
}
/// Used for peer-majority calculations but currently disabled.
#[allow(dead_code)]
fn calculate_size_from_file_descriptions(
file_descriptions: &[lanspread_db::db::GameFileDescription],
) -> u64 {
file_descriptions
fn apply_peer_local_state(existing: &mut Game, local_game: &Game) {
existing.set_downloaded(local_game.downloaded);
existing.installed = local_game.installed;
existing.local_version.clone_from(&local_game.local_version);
existing.availability = local_game.normalized_availability();
}
fn apply_peer_local_games(game_db: &mut GameDB, local_games: &[Game]) {
let local_game_ids = local_games
.iter()
.filter(|desc| !desc.is_dir)
.map(|desc| desc.size)
.sum()
}
.map(|game| game.id.clone())
.collect::<HashSet<_>>();
/// Future hook for reintroducing peer-driven size updates.
#[allow(dead_code)]
async fn update_game_sizes_from_peers(
games: &mut std::collections::HashMap<String, Game>,
peer_game_db: &Arc<RwLock<PeerGameDB>>,
) {
log::debug!("Updating game sizes from peer data where local files are not available");
for local_game in local_games {
if let Some(existing_game) = game_db.get_mut_game_by_id(&local_game.id) {
apply_peer_local_state(existing_game, local_game);
log::debug!("Updated local game status for: {}", local_game.id);
}
}
let peer_db = peer_game_db.read().await;
for game in games.values_mut() {
if !game.downloaded && !game.installed {
let peer_files_for_game = peer_db.aggregated_game_files(&game.id);
if peer_files_for_game.is_empty() {
if let Some(peer_size) = peer_db.majority_game_size(&game.id) {
if peer_size > 0 {
game.size = peer_size;
log::debug!(
"Updated size for game {} from peer totals: {} bytes",
game.id,
peer_size
);
} else {
log::debug!(
"Peer-reported size for game {} is 0; keeping previous value",
game.id
);
}
} else {
log::debug!("No peer size data available for game {}", game.id);
}
} else {
let peer_size = calculate_size_from_file_descriptions(&peer_files_for_game);
if peer_size > 0 {
game.size = peer_size;
log::debug!(
"Updated size for game {} from peer files: {} bytes ({} files)",
game.id,
peer_size,
peer_files_for_game.len()
);
} else {
log::debug!(
"Peer files for game {} exist but calculated size is 0",
game.id
);
}
}
for game in game_db.games.values_mut() {
if !local_game_ids.contains(&game.id) && has_local_game_state(game) {
log::info!(
"Game {} missing from peer local snapshot; marking as unavailable locally",
game.id
);
clear_local_game_state(game);
}
}
}
async fn refresh_games_list(app_handle: &AppHandle) {
fn clear_all_local_game_states(game_db: &mut GameDB) {
for game in game_db.games.values_mut() {
clear_local_game_state(game);
}
}
async fn emit_games_list(app_handle: &AppHandle) {
let state = app_handle.state::<LanSpreadState>();
let games_folder_lock = state.games_folder.clone();
let games_db_lock = state.games.clone();
let games_folder = games_folder_lock.read().await.clone();
let path = if games_folder.is_empty() {
log::debug!("Games folder not set; emitting current game list without rescan");
None
} else {
Some(PathBuf::from(&games_folder))
};
let mut game_db = games_db_lock.write().await;
let game_db = games_db_lock.read().await;
if game_db.games.is_empty() {
log::debug!("Game database empty during refresh; skipping emit");
log::debug!("Game database empty; skipping emit");
return;
}
if let Some(ref path) = path {
if path.exists() {
game_db.set_all_uninstalled();
for game in game_db.games.values_mut() {
update_game_installation_state(game, path);
}
} else {
log::error!(
"game dir {} does not exist; keeping last known installation state",
path.display()
);
}
}
let games_to_emit = game_db
.all_games()
.into_iter()
@@ -611,10 +513,16 @@ async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> ta
return Ok(());
}
let path_changed = current_path != path;
*state.games_folder.write().await = path;
ensure_bundled_game_db_loaded(&app_handle).await;
refresh_games_list(&app_handle).await;
if path_changed {
let mut game_db = state.games.write().await;
clear_all_local_game_states(&mut game_db);
}
emit_games_list(&app_handle).await;
ensure_peer_started(&app_handle, &games_folder).await;
Ok(())
@@ -661,46 +569,18 @@ async fn update_game_db(games: Vec<Game>, app: AppHandle) {
}
}
refresh_games_list(&app).await;
emit_games_list(&app).await;
}
async fn update_local_games_in_db(local_games: Vec<Game>, app: AppHandle) {
let state = app.state::<LanSpreadState>();
// Collect local game IDs first to avoid move issues
let local_game_ids: HashSet<String> = local_games.iter().map(|g| g.id.clone()).collect();
{
let mut game_db = state.games.write().await;
// Update installation status for games that exist locally
for local_game in &local_games {
if let Some(existing_game) = game_db.get_mut_game_by_id(&local_game.id) {
existing_game.set_downloaded(local_game.downloaded);
existing_game.installed = local_game.installed;
existing_game
.local_version
.clone_from(&local_game.local_version);
log::debug!("Updated local game status for: {}", local_game.id);
}
}
// For games in the main DB that are not in the local list,
// mark them as not downloaded/installed (they were deleted)
for game in game_db.games.values_mut() {
if !local_game_ids.contains(&game.id) && (game.downloaded || game.installed) {
log::info!(
"Game {} no longer exists locally, marking as uninstalled",
game.id
);
game.set_downloaded(false);
game.installed = false;
game.local_version = None;
}
}
apply_peer_local_games(&mut game_db, &local_games);
}
refresh_games_list(&app).await;
emit_games_list(&app).await;
}
fn add_final_slash(path: &str) -> String {
@@ -857,6 +737,12 @@ fn spawn_peer_event_loop(app_handle: AppHandle, mut rx_peer_event: UnboundedRece
#[allow(clippy::too_many_lines)]
async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
match event {
PeerEvent::LocalPeerReady { peer_id, addr } => {
log::info!("Local peer ready: {peer_id} at {addr}");
if let Err(e) = app_handle.emit("peer-local-ready", Some((peer_id, addr.to_string()))) {
log::error!("Failed to emit peer-local-ready event: {e}");
}
}
PeerEvent::ListGames(games) => {
log::info!("PeerEvent::ListGames received");
update_game_db(games, app_handle.clone()).await;
@@ -1118,6 +1004,26 @@ async fn handle_download_finished(app_handle: &AppHandle, id: String) {
mod tests {
use super::*;
fn game_fixture(id: &str, name: &str) -> Game {
Game {
id: id.to_string(),
name: name.to_string(),
description: format!("{name} description"),
release_year: "2000".to_string(),
publisher: "publisher".to_string(),
max_players: 4,
version: "1.0".to_string(),
genre: "genre".to_string(),
size: 123,
downloaded: false,
installed: false,
availability: Availability::LocalOnly,
eti_game_version: None,
local_version: None,
peer_count: 0,
}
}
#[test]
fn active_operation_reconciliation_replaces_stale_ui_history() {
let mut active_operations = HashMap::from([
@@ -1152,6 +1058,36 @@ mod tests {
);
}
#[test]
fn local_snapshot_without_finish_clears_stale_ui_operation() {
let mut active_operations =
HashMap::from([("game".to_string(), UiOperationKind::Downloading)]);
reconcile_active_operations(&mut active_operations, &[]);
assert!(
active_operations.is_empty(),
"an authoritative snapshot without the game should clear a missed finish event"
);
}
#[test]
fn local_snapshot_without_begin_restores_active_ui_operation() {
let mut active_operations = HashMap::new();
let snapshot = vec![ActiveOperation {
id: "game".to_string(),
operation: ActiveOperationKind::Installing,
}];
reconcile_active_operations(&mut active_operations, &snapshot);
assert_eq!(
active_operations.get("game"),
Some(&UiOperationKind::Installing),
"an authoritative snapshot should recover a missed begin event"
);
}
#[test]
fn active_operation_payload_is_sorted_for_stable_ui_updates() {
let active_operations = HashMap::from([
@@ -1175,6 +1111,48 @@ mod tests {
]
);
}
#[test]
fn peer_local_snapshot_replaces_local_state_without_overwriting_catalog_metadata() {
let mut alpha = game_fixture("alpha", "Catalog Alpha");
alpha.size = 999;
alpha.peer_count = 3;
let mut beta = game_fixture("beta", "Catalog Beta");
beta.set_downloaded(true);
beta.installed = true;
beta.local_version = Some("20240101".to_string());
let mut game_db = GameDB::from(vec![alpha, beta]);
let mut local_alpha = game_fixture("alpha", "Peer Alpha");
local_alpha.size = 42;
local_alpha.set_downloaded(true);
local_alpha.local_version = Some("20240202".to_string());
let mut unknown = game_fixture("unknown", "Unknown");
unknown.set_downloaded(true);
unknown.installed = true;
apply_peer_local_games(&mut game_db, &[local_alpha, unknown]);
let alpha = game_db.get_game_by_id("alpha").expect("alpha remains");
assert_eq!(alpha.name, "Catalog Alpha");
assert_eq!(alpha.size, 999);
assert_eq!(alpha.peer_count, 3);
assert!(alpha.downloaded);
assert!(!alpha.installed);
assert_eq!(alpha.availability, Availability::Ready);
assert_eq!(alpha.local_version.as_deref(), Some("20240202"));
let beta = game_db.get_game_by_id("beta").expect("beta remains");
assert!(!beta.downloaded);
assert!(!beta.installed);
assert_eq!(beta.availability, Availability::LocalOnly);
assert_eq!(beta.local_version, None);
assert!(game_db.get_game_by_id("unknown").is_none());
}
}
#[allow(clippy::missing_panics_doc)]
@@ -51,7 +51,6 @@ interface Game {
enum GameAvailability {
Ready = 'Ready',
Downloading = 'Downloading',
LocalOnly = 'LocalOnly',
}