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:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user