738095235f
Updating or removing a local game rewrites its on-disk files. Peers that were mid-download of that game would keep streaming bytes from files that are being deleted or replaced, handing them a corrupt or stale copy. There was also no authoritative notion of which game version a peer should serve or accept, so a peer could serve whatever happened to be on disk and downloaders could aggregate files from peers running mismatched versions. This introduces a reader-writer coordination scheme between outbound file transfers (readers) and local mutation operations (writers), and gates both serving and downloading on an authoritative game catalog version. Reader-writer coordination: - Track active outbound transfers per game in a shared `OutboundTransfers` map of (id, CancellationToken), threaded through `Ctx`/`PeerCtx` and registered by a `TransferGuard` in the stream service. The guard is registered *before* the serve-eligibility check to close a TOCTOU window where a writer could miss an in-flight reader. - `stream_file_bytes` now honors a cancellation token at every await point (file read, network send, stream close) via `tokio::select!`, so a transfer aborts promptly instead of hanging on a stalled receiver. - `begin_operation` marks a game active first, then cancels its outbound transfers and waits for the count to reach zero before any Updating/RemovingDownload work touches the filesystem. - Active games are now hidden from library snapshots entirely while an operation is in flight, instead of freezing their last announced state, so peers stop discovering a game that is being mutated. Authoritative version catalog: - Replace the `HashSet<String>` catalog with `GameCatalog`, mapping each game id to its expected version (from the bundled game.db / ETI data). - Serving requires the local `version.ini` to match the catalog version (`local_download_matches_catalog`); peer selection, file aggregation, and majority size validation all filter on the expected version (`peers_with_expected_version`, `aggregated_game_files`, and friends). User-visible changes: - The GUI shows confirmation dialogs before Update and Remove, and surfaces a sharing-status indicator on game cards and the detail modal. - A new `OutboundTransferCountChanged` event lets the UI reflect live outbound transfer activity. Test Plan: - just test - just frontend-test - just clippy
977 lines
31 KiB
Rust
977 lines
31 KiB
Rust
//! Local game scanning and database management.
|
|
|
|
use std::{
|
|
collections::{HashMap, HashSet},
|
|
hash::{Hash, Hasher},
|
|
io::ErrorKind,
|
|
path::{Path, PathBuf},
|
|
sync::LazyLock,
|
|
time::{SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use lanspread_db::db::{Game, GameCatalog, GameDB, GameFileDescription};
|
|
use lanspread_proto::{Availability, GameSummary};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::{io::AsyncWriteExt, sync::Mutex};
|
|
|
|
use crate::{context::OperationKind, error::PeerError};
|
|
|
|
// =============================================================================
|
|
// Local directory helpers
|
|
// =============================================================================
|
|
|
|
#[cfg(target_os = "windows")]
|
|
pub fn is_local_dir_name(name: &str) -> bool {
|
|
name.eq_ignore_ascii_case("local")
|
|
}
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
pub fn is_local_dir_name(name: &str) -> bool {
|
|
name == "local"
|
|
}
|
|
|
|
/// Checks if `local/` is a committed install directory.
|
|
pub async fn local_dir_is_directory(path: &Path) -> bool {
|
|
let local_dir = path.join("local");
|
|
tokio::fs::metadata(&local_dir)
|
|
.await
|
|
.is_ok_and(|metadata| metadata.is_dir())
|
|
}
|
|
|
|
/// Checks if the root-level `version.ini` sentinel exists as a regular file.
|
|
pub async fn version_ini_is_regular_file(game_path: &Path) -> bool {
|
|
let version_path = game_path.join("version.ini");
|
|
tokio::fs::metadata(&version_path)
|
|
.await
|
|
.is_ok_and(|metadata| metadata.is_file())
|
|
}
|
|
|
|
/// Checks if a game is available for download locally.
|
|
pub async fn local_download_available(
|
|
game_dir: &Path,
|
|
game_id: &str,
|
|
active_operations: &HashMap<String, OperationKind>,
|
|
catalog: &GameCatalog,
|
|
) -> bool {
|
|
if !catalog.contains(game_id) {
|
|
log::debug!("Not serving game {game_id} locally because it is not in the catalog");
|
|
return false;
|
|
}
|
|
|
|
if active_operations.contains_key(game_id) {
|
|
log::debug!("Not serving game {game_id} locally because an operation is active");
|
|
return false;
|
|
}
|
|
|
|
let game_path = game_dir.join(game_id);
|
|
version_ini_is_regular_file(game_path.as_path()).await
|
|
}
|
|
|
|
/// Checks if a local game may be served to peers under the authoritative catalog version.
|
|
pub async fn local_download_matches_catalog(
|
|
game_dir: &Path,
|
|
game_id: &str,
|
|
active_operations: &HashMap<String, OperationKind>,
|
|
catalog: &GameCatalog,
|
|
) -> bool {
|
|
if !local_download_available(game_dir, game_id, active_operations, catalog).await {
|
|
return false;
|
|
}
|
|
|
|
let Some(expected_version) = catalog.expected_version(game_id) else {
|
|
return true;
|
|
};
|
|
|
|
let game_path = game_dir.join(game_id);
|
|
match lanspread_db::db::read_version_from_ini(&game_path) {
|
|
Ok(Some(local_version)) if local_version == expected_version => true,
|
|
Ok(Some(local_version)) => {
|
|
log::debug!(
|
|
"Not serving game {game_id}: local version.ini {local_version} does not match catalog {expected_version}"
|
|
);
|
|
false
|
|
}
|
|
Ok(None) => false,
|
|
Err(err) => {
|
|
log::warn!(
|
|
"Not serving game {game_id}: failed to read local version.ini for catalog comparison: {err}"
|
|
);
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Local library index and scanning
|
|
// =============================================================================
|
|
|
|
const LEGACY_LIBRARY_INDEX_DIR: &str = ".lanspread";
|
|
const LIBRARY_INDEX_FILE: &str = "library_index.json";
|
|
const INTENT_LOG_FILE: &str = ".lanspread.json";
|
|
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
|
|
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
|
|
|
|
static LIBRARY_INDEX_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct LibraryIndex {
|
|
revision: u64,
|
|
games: HashMap<String, GameIndexEntry>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct GameIndexEntry {
|
|
summary: GameSummary,
|
|
fingerprint: GameFingerprint,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
struct GameFingerprint {
|
|
eti_files: Vec<EtiFingerprint>,
|
|
version_mtime: Option<u64>,
|
|
#[serde(default)]
|
|
version_contents: Option<String>,
|
|
local_dir_present: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
struct EtiFingerprint {
|
|
name: String,
|
|
size: u64,
|
|
mtime: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct LocalLibraryScan {
|
|
pub game_db: GameDB,
|
|
pub summaries: HashMap<String, GameSummary>,
|
|
pub revision: u64,
|
|
}
|
|
|
|
pub(crate) fn legacy_library_index_path(game_dir: &Path) -> PathBuf {
|
|
game_dir
|
|
.join(LEGACY_LIBRARY_INDEX_DIR)
|
|
.join(LIBRARY_INDEX_FILE)
|
|
}
|
|
|
|
fn library_index_path(state_dir: &Path) -> PathBuf {
|
|
crate::state_paths::local_library_index_path(state_dir)
|
|
}
|
|
|
|
fn library_index_tmp_path(path: &Path) -> PathBuf {
|
|
let Some(file_name) = path.file_name() else {
|
|
return path.with_extension("tmp");
|
|
};
|
|
|
|
let mut tmp_name = file_name.to_os_string();
|
|
tmp_name.push(".tmp");
|
|
path.with_file_name(tmp_name)
|
|
}
|
|
|
|
async fn sweep_stale_library_index_tmp(path: &Path) {
|
|
let tmp_path = library_index_tmp_path(path);
|
|
match tokio::fs::remove_file(&tmp_path).await {
|
|
Ok(()) => log::debug!(
|
|
"Removed stale library index temp file {}",
|
|
tmp_path.display()
|
|
),
|
|
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
|
Err(err) => log::warn!(
|
|
"Failed to remove stale library index temp file {}: {err}",
|
|
tmp_path.display()
|
|
),
|
|
}
|
|
}
|
|
|
|
async fn load_library_index(path: &Path) -> LibraryIndex {
|
|
sweep_stale_library_index_tmp(path).await;
|
|
|
|
let data = match tokio::fs::read_to_string(path).await {
|
|
Ok(data) => data,
|
|
Err(err) => {
|
|
if err.kind() != ErrorKind::NotFound {
|
|
log::warn!("Failed to read library index {}: {err}", path.display());
|
|
}
|
|
return LibraryIndex {
|
|
revision: 0,
|
|
games: HashMap::new(),
|
|
};
|
|
}
|
|
};
|
|
|
|
match serde_json::from_str(&data) {
|
|
Ok(index) => index,
|
|
Err(err) => {
|
|
log::warn!("Failed to parse library index {}: {err}", path.display());
|
|
LibraryIndex {
|
|
revision: 0,
|
|
games: HashMap::new(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn save_library_index(path: &Path, index: &LibraryIndex) -> eyre::Result<()> {
|
|
if let Some(parent) = path.parent() {
|
|
tokio::fs::create_dir_all(parent).await?;
|
|
}
|
|
let data = serde_json::to_vec_pretty(index)?;
|
|
let tmp_path = library_index_tmp_path(path);
|
|
|
|
let mut file = tokio::fs::File::create(&tmp_path).await?;
|
|
file.write_all(&data).await?;
|
|
file.sync_all().await?;
|
|
drop(file);
|
|
|
|
tokio::fs::rename(&tmp_path, path).await?;
|
|
sync_parent_dir(path)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::File::open(parent)?.sync_all()?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
fn system_time_to_secs(time: SystemTime) -> u64 {
|
|
time.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs()
|
|
}
|
|
|
|
fn is_root_eti_name(name: &str) -> bool {
|
|
Path::new(name)
|
|
.extension()
|
|
.is_some_and(|extension| extension == "eti")
|
|
}
|
|
|
|
async fn root_eti_fingerprints(game_path: &Path) -> eyre::Result<Vec<EtiFingerprint>> {
|
|
let mut entries = match tokio::fs::read_dir(game_path).await {
|
|
Ok(entries) => entries,
|
|
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
|
|
Err(err) => return Err(err.into()),
|
|
};
|
|
|
|
let mut eti_files = Vec::new();
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
let file_type = entry.file_type().await?;
|
|
if !file_type.is_file() {
|
|
continue;
|
|
}
|
|
|
|
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
|
|
continue;
|
|
};
|
|
if !is_root_eti_name(&name) {
|
|
continue;
|
|
}
|
|
|
|
let metadata = entry.metadata().await?;
|
|
eti_files.push(EtiFingerprint {
|
|
name,
|
|
size: metadata.len(),
|
|
mtime: metadata.modified().ok().map(system_time_to_secs),
|
|
});
|
|
}
|
|
|
|
eti_files.sort_by(|a, b| a.name.cmp(&b.name));
|
|
Ok(eti_files)
|
|
}
|
|
|
|
async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result<GameFingerprint> {
|
|
let eti_files = root_eti_fingerprints(game_path).await?;
|
|
|
|
let version_path = game_path.join("version.ini");
|
|
let (version_mtime, version_contents) = match tokio::fs::metadata(&version_path).await {
|
|
Ok(metadata) if metadata.is_file() => {
|
|
let contents = match tokio::fs::read_to_string(&version_path).await {
|
|
Ok(contents) => Some(contents.trim().to_string()),
|
|
Err(err) => {
|
|
log::warn!(
|
|
"Failed to read {} for fingerprinting: {err}",
|
|
version_path.display()
|
|
);
|
|
None
|
|
}
|
|
};
|
|
(metadata.modified().ok().map(system_time_to_secs), contents)
|
|
}
|
|
Err(_) | Ok(_) => (None, None),
|
|
};
|
|
|
|
let local_dir_present = local_dir_is_directory(game_path).await;
|
|
|
|
Ok(GameFingerprint {
|
|
eti_files,
|
|
version_mtime,
|
|
version_contents,
|
|
local_dir_present,
|
|
})
|
|
}
|
|
|
|
pub fn is_ignored_game_root_name(name: &str) -> bool {
|
|
name == LEGACY_LIBRARY_INDEX_DIR
|
|
}
|
|
|
|
fn is_reserved_transient_name(name: &str) -> bool {
|
|
name.starts_with(".local.")
|
|
|| name == VERSION_TMP_FILE
|
|
|| name == VERSION_DISCARDED_FILE
|
|
|| name == INTENT_LOG_FILE
|
|
|| name == LEGACY_LIBRARY_INDEX_DIR
|
|
}
|
|
|
|
fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool {
|
|
if entry.depth() != 1 {
|
|
return false;
|
|
}
|
|
|
|
if entry.file_type().is_dir() && entry.file_name().to_str().is_some_and(is_local_dir_name) {
|
|
return true;
|
|
}
|
|
|
|
if let Some(name) = entry.file_name().to_str() {
|
|
if is_reserved_transient_name(name) {
|
|
return true;
|
|
}
|
|
if entry.file_type().is_dir() && name == ".sync" {
|
|
return true;
|
|
}
|
|
if entry.file_type().is_file() && name == ".softlan_game_installed" {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
async fn scan_game_descriptions(
|
|
game_id: &str,
|
|
game_dir: &Path,
|
|
) -> Result<Vec<GameFileDescription>, PeerError> {
|
|
let base_dir = game_dir;
|
|
let game_path = base_dir.join(game_id);
|
|
|
|
if !game_path.exists() {
|
|
return Err(PeerError::Other(eyre::eyre!(
|
|
"Game directory does not exist: {}",
|
|
game_path.display()
|
|
)));
|
|
}
|
|
|
|
let mut file_descriptions = Vec::new();
|
|
|
|
for entry in walkdir::WalkDir::new(&game_path)
|
|
.into_iter()
|
|
.filter_entry(|entry| !should_skip_root_entry(entry))
|
|
.filter_map(std::result::Result::ok)
|
|
{
|
|
let relative_path = match entry.path().strip_prefix(base_dir) {
|
|
Ok(path) => path.to_string_lossy().to_string(),
|
|
Err(e) => {
|
|
log::error!(
|
|
"Failed to get relative path for {}: {}",
|
|
entry.path().display(),
|
|
e
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let is_dir = entry.file_type().is_dir();
|
|
let size = if is_dir {
|
|
0
|
|
} else {
|
|
match tokio::fs::metadata(entry.path()).await {
|
|
Ok(metadata) => metadata.len(),
|
|
Err(e) => {
|
|
log::error!("Failed to read metadata for {relative_path}: {e}");
|
|
return Err(PeerError::FileSizeDetermination {
|
|
path: relative_path.clone(),
|
|
source: e,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
let file_desc = GameFileDescription {
|
|
game_id: game_id.to_string(),
|
|
relative_path,
|
|
is_dir,
|
|
size,
|
|
};
|
|
|
|
file_descriptions.push(file_desc);
|
|
}
|
|
|
|
Ok(file_descriptions)
|
|
}
|
|
|
|
fn manifest_hash(file_descriptions: &[GameFileDescription]) -> u64 {
|
|
let mut entries: Vec<_> = file_descriptions
|
|
.iter()
|
|
.filter(|desc| !desc.is_dir)
|
|
.map(|desc| (&desc.relative_path, desc.size, desc.is_dir))
|
|
.collect();
|
|
entries.sort_by(|a, b| a.0.cmp(b.0).then(a.1.cmp(&b.1)));
|
|
|
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
for (path, size, is_dir) in entries {
|
|
path.hash(&mut hasher);
|
|
size.hash(&mut hasher);
|
|
is_dir.hash(&mut hasher);
|
|
}
|
|
hasher.finish()
|
|
}
|
|
|
|
async fn build_game_summary(game_dir: &Path, game_id: &str) -> Result<GameSummary, PeerError> {
|
|
let game_path = game_dir.join(game_id);
|
|
let downloaded = version_ini_is_regular_file(&game_path).await;
|
|
let installed = local_dir_is_directory(&game_path).await;
|
|
|
|
let eti_version = if downloaded {
|
|
match lanspread_db::db::read_version_from_ini(&game_path) {
|
|
Ok(version) => version,
|
|
Err(e) => {
|
|
log::warn!("Failed to read version.ini for downloaded game {game_id}: {e}");
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let file_descriptions = scan_game_descriptions(game_id, game_dir).await?;
|
|
let total_size = file_descriptions
|
|
.iter()
|
|
.filter(|desc| !desc.is_dir)
|
|
.map(|desc| desc.size)
|
|
.sum();
|
|
let manifest_hash = manifest_hash(&file_descriptions);
|
|
let availability = if downloaded {
|
|
Availability::Ready
|
|
} else {
|
|
Availability::LocalOnly
|
|
};
|
|
|
|
Ok(GameSummary {
|
|
id: game_id.to_string(),
|
|
name: game_id.to_string(),
|
|
size: total_size,
|
|
downloaded,
|
|
installed,
|
|
eti_version,
|
|
manifest_hash,
|
|
availability,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn game_from_summary(summary: &GameSummary) -> Game {
|
|
Game {
|
|
id: summary.id.clone(),
|
|
name: summary.name.clone(),
|
|
description: String::new(),
|
|
release_year: String::new(),
|
|
publisher: String::new(),
|
|
max_players: 1,
|
|
version: "1.0".to_string(),
|
|
genre: String::new(),
|
|
size: summary.size,
|
|
downloaded: summary.downloaded,
|
|
installed: summary.installed,
|
|
availability: summary.availability.clone(),
|
|
eti_game_version: summary.eti_version.clone(),
|
|
local_version: summary.eti_version.clone(),
|
|
peer_count: 0,
|
|
}
|
|
}
|
|
|
|
struct IndexUpdate {
|
|
summary: Option<GameSummary>,
|
|
changed: bool,
|
|
}
|
|
|
|
async fn update_index_for_game(
|
|
game_root: &Path,
|
|
game_id: &str,
|
|
catalog: &GameCatalog,
|
|
index: &mut LibraryIndex,
|
|
) -> eyre::Result<IndexUpdate> {
|
|
if !catalog.contains(game_id) {
|
|
return Ok(IndexUpdate {
|
|
summary: None,
|
|
changed: index.games.remove(game_id).is_some(),
|
|
});
|
|
}
|
|
|
|
let game_path = game_root.join(game_id);
|
|
let fingerprint = fingerprint_game_dir(&game_path).await?;
|
|
|
|
if fingerprint.version_mtime.is_none()
|
|
&& !fingerprint.local_dir_present
|
|
&& fingerprint.eti_files.is_empty()
|
|
{
|
|
return Ok(IndexUpdate {
|
|
summary: None,
|
|
changed: index.games.remove(game_id).is_some(),
|
|
});
|
|
}
|
|
|
|
let mut changed = false;
|
|
let summary = match index.games.get(game_id) {
|
|
Some(entry) if entry.fingerprint == fingerprint => entry.summary.clone(),
|
|
_ => {
|
|
changed = true;
|
|
build_game_summary(game_root, game_id).await?
|
|
}
|
|
};
|
|
|
|
if index
|
|
.games
|
|
.get(game_id)
|
|
.is_some_and(|entry| entry.summary.manifest_hash != summary.manifest_hash)
|
|
{
|
|
changed = true;
|
|
}
|
|
|
|
index.games.insert(
|
|
game_id.to_string(),
|
|
GameIndexEntry {
|
|
summary: summary.clone(),
|
|
fingerprint,
|
|
},
|
|
);
|
|
|
|
Ok(IndexUpdate {
|
|
summary: Some(summary),
|
|
changed,
|
|
})
|
|
}
|
|
|
|
fn empty_scan() -> LocalLibraryScan {
|
|
LocalLibraryScan {
|
|
game_db: GameDB::empty(),
|
|
summaries: HashMap::new(),
|
|
revision: 0,
|
|
}
|
|
}
|
|
|
|
fn scan_from_index(index: &LibraryIndex) -> LocalLibraryScan {
|
|
let summaries = index
|
|
.games
|
|
.iter()
|
|
.map(|(id, entry)| (id.clone(), entry.summary.clone()))
|
|
.collect::<HashMap<_, _>>();
|
|
let games = index
|
|
.games
|
|
.values()
|
|
.map(|entry| game_from_summary(&entry.summary))
|
|
.collect::<Vec<_>>();
|
|
|
|
LocalLibraryScan {
|
|
game_db: GameDB::from(games),
|
|
summaries,
|
|
revision: index.revision,
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Game database loading
|
|
// =============================================================================
|
|
|
|
/// Scans the local game directory and returns summaries plus a game database.
|
|
pub async fn scan_local_library(
|
|
game_dir: impl AsRef<Path>,
|
|
state_dir: impl AsRef<Path>,
|
|
catalog: &GameCatalog,
|
|
) -> eyre::Result<LocalLibraryScan> {
|
|
let game_path = game_dir.as_ref();
|
|
let state_path = state_dir.as_ref();
|
|
|
|
let metadata = match tokio::fs::metadata(game_path).await {
|
|
Ok(metadata) => metadata,
|
|
Err(err) => {
|
|
if err.kind() == ErrorKind::NotFound {
|
|
log::warn!(
|
|
"Local game directory {} missing; reporting empty game database",
|
|
game_path.display()
|
|
);
|
|
return Ok(empty_scan());
|
|
}
|
|
return Err(err.into());
|
|
}
|
|
};
|
|
|
|
if !metadata.is_dir() {
|
|
log::warn!(
|
|
"Configured game directory {} is not a directory; reporting empty game database",
|
|
game_path.display()
|
|
);
|
|
return Ok(empty_scan());
|
|
}
|
|
|
|
let _index_guard = LIBRARY_INDEX_LOCK.lock().await;
|
|
let index_path = library_index_path(state_path);
|
|
let mut index = load_library_index(&index_path).await;
|
|
let mut seen_ids = HashSet::new();
|
|
let mut summaries = HashMap::new();
|
|
let mut games = Vec::new();
|
|
let mut changed = false;
|
|
|
|
let mut entries = tokio::fs::read_dir(game_path).await?;
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
let path = entry.path();
|
|
if !path.is_dir() {
|
|
continue;
|
|
}
|
|
|
|
let Some(game_id) = path.file_name().and_then(|n| n.to_str()) else {
|
|
continue;
|
|
};
|
|
if is_ignored_game_root_name(game_id) {
|
|
continue;
|
|
}
|
|
|
|
let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?;
|
|
changed |= update.changed;
|
|
|
|
let Some(summary) = update.summary else {
|
|
continue;
|
|
};
|
|
|
|
seen_ids.insert(game_id.to_string());
|
|
summaries.insert(game_id.to_string(), summary.clone());
|
|
games.push(game_from_summary(&summary));
|
|
}
|
|
|
|
let before = index.games.len();
|
|
index.games.retain(|game_id, _| seen_ids.contains(game_id));
|
|
if index.games.len() != before {
|
|
changed = true;
|
|
}
|
|
|
|
if changed {
|
|
index.revision = index.revision.saturating_add(1);
|
|
if let Err(err) = save_library_index(&index_path, &index).await {
|
|
log::warn!(
|
|
"Failed to persist library index {}: {err}",
|
|
index_path.display()
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(LocalLibraryScan {
|
|
game_db: GameDB::from(games),
|
|
summaries,
|
|
revision: index.revision,
|
|
})
|
|
}
|
|
|
|
/// Rescans a single game root through the cached index and returns full library state.
|
|
pub async fn rescan_local_game(
|
|
game_dir: impl AsRef<Path>,
|
|
state_dir: impl AsRef<Path>,
|
|
catalog: &GameCatalog,
|
|
game_id: &str,
|
|
) -> eyre::Result<LocalLibraryScan> {
|
|
let game_path = game_dir.as_ref();
|
|
let state_path = state_dir.as_ref();
|
|
let _index_guard = LIBRARY_INDEX_LOCK.lock().await;
|
|
let index_path = library_index_path(state_path);
|
|
let mut index = load_library_index(&index_path).await;
|
|
|
|
let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?;
|
|
if update.changed {
|
|
index.revision = index.revision.saturating_add(1);
|
|
if let Err(err) = save_library_index(&index_path, &index).await {
|
|
log::warn!(
|
|
"Failed to persist library index {}: {err}",
|
|
index_path.display()
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(scan_from_index(&index))
|
|
}
|
|
|
|
// =============================================================================
|
|
// Game file descriptions
|
|
// =============================================================================
|
|
|
|
/// Gets file descriptions for a game from the local filesystem.
|
|
pub async fn get_game_file_descriptions(
|
|
game_id: &str,
|
|
game_dir: impl AsRef<Path>,
|
|
) -> Result<Vec<GameFileDescription>, PeerError> {
|
|
scan_game_descriptions(game_id, game_dir.as_ref()).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{collections::HashMap, path::Path};
|
|
|
|
use lanspread_proto::Availability;
|
|
|
|
use super::*;
|
|
use crate::{context::OperationKind, test_support::TempDir};
|
|
|
|
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_library_index(revision: u64, id: &str, manifest_hash: u64) -> LibraryIndex {
|
|
LibraryIndex {
|
|
revision,
|
|
games: HashMap::from([(
|
|
id.to_string(),
|
|
GameIndexEntry {
|
|
summary: GameSummary {
|
|
id: id.to_string(),
|
|
name: id.to_string(),
|
|
size: manifest_hash,
|
|
downloaded: true,
|
|
installed: false,
|
|
eti_version: Some("20250101".to_string()),
|
|
manifest_hash,
|
|
availability: Availability::Ready,
|
|
},
|
|
fingerprint: GameFingerprint {
|
|
eti_files: Vec::new(),
|
|
version_mtime: Some(manifest_hash),
|
|
version_contents: Some("20250101".to_string()),
|
|
local_dir_present: false,
|
|
},
|
|
},
|
|
)]),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_library_index_is_atomic_on_replace() {
|
|
let temp = TempDir::new("lanspread-local-games");
|
|
let index_path = library_index_path(temp.path());
|
|
let tmp_path = library_index_tmp_path(&index_path);
|
|
|
|
let first = test_library_index(1, "game-a", 11);
|
|
save_library_index(&index_path, &first)
|
|
.await
|
|
.expect("first index write should succeed");
|
|
assert!(!tmp_path.exists());
|
|
|
|
let second = test_library_index(2, "game-b", 22);
|
|
save_library_index(&index_path, &second)
|
|
.await
|
|
.expect("replacement index write should succeed");
|
|
|
|
assert!(!tmp_path.exists());
|
|
let loaded = load_library_index(&index_path).await;
|
|
assert_eq!(loaded.revision, 2);
|
|
assert!(!loaded.games.contains_key("game-a"));
|
|
let game_b = loaded
|
|
.games
|
|
.get("game-b")
|
|
.expect("replacement index should be persisted");
|
|
assert_eq!(game_b.summary.manifest_hash, 22);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_library_index_sweeps_stale_tmp() {
|
|
let temp = TempDir::new("lanspread-local-games");
|
|
let index_path = library_index_path(temp.path());
|
|
let tmp_path = library_index_tmp_path(&index_path);
|
|
let canonical = test_library_index(7, "game", 77);
|
|
let data = serde_json::to_vec_pretty(&canonical).expect("index should serialize");
|
|
|
|
write_file(&index_path, &data);
|
|
write_file(&tmp_path, b"{ not json");
|
|
|
|
let loaded = load_library_index(&index_path).await;
|
|
|
|
assert_eq!(loaded.revision, 7);
|
|
assert!(loaded.games.contains_key("game"));
|
|
assert!(!tmp_path.exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn scan_uses_version_ini_and_local_dir_as_independent_state() {
|
|
let temp = TempDir::new("lanspread-local-games");
|
|
let state = TempDir::new("lanspread-local-games-state");
|
|
let catalog = GameCatalog::from_ids([
|
|
"ready".to_string(),
|
|
"local-only".to_string(),
|
|
"eti-only".to_string(),
|
|
]);
|
|
|
|
write_file(&temp.path().join("ready").join("version.ini"), b"20250101");
|
|
std::fs::create_dir_all(temp.path().join("local-only").join("local"))
|
|
.expect("local dir should be created");
|
|
write_file(
|
|
&temp.path().join("eti-only").join("eti-only.eti"),
|
|
b"archive",
|
|
);
|
|
write_file(
|
|
&temp.path().join("non-catalog").join("version.ini"),
|
|
b"20250101",
|
|
);
|
|
|
|
let scan = scan_local_library(temp.path(), state.path(), &catalog)
|
|
.await
|
|
.expect("scan should succeed");
|
|
|
|
let ready = scan
|
|
.summaries
|
|
.get("ready")
|
|
.expect("catalog game with sentinel should be indexed");
|
|
assert!(ready.downloaded);
|
|
assert!(!ready.installed);
|
|
assert_eq!(ready.eti_version.as_deref(), Some("20250101"));
|
|
assert_eq!(ready.availability, Availability::Ready);
|
|
|
|
let local_only = scan
|
|
.summaries
|
|
.get("local-only")
|
|
.expect("local-only install should be indexed");
|
|
assert!(!local_only.downloaded);
|
|
assert!(local_only.installed);
|
|
assert_eq!(local_only.availability, Availability::LocalOnly);
|
|
|
|
let eti_only = scan
|
|
.summaries
|
|
.get("eti-only")
|
|
.expect("eti-only root should be retained as local state");
|
|
assert!(!eti_only.downloaded);
|
|
assert!(!eti_only.installed);
|
|
assert_eq!(eti_only.availability, Availability::LocalOnly);
|
|
|
|
assert!(!scan.summaries.contains_key("non-catalog"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rescan_promotes_installed_only_game_to_ready_when_sentinel_appears() {
|
|
let temp = TempDir::new("lanspread-local-games");
|
|
let state = TempDir::new("lanspread-local-games-state");
|
|
let catalog = GameCatalog::from_ids(["game".to_string()]);
|
|
std::fs::create_dir_all(temp.path().join("game").join("local"))
|
|
.expect("local install dir should be created");
|
|
|
|
let first_scan = scan_local_library(temp.path(), state.path(), &catalog)
|
|
.await
|
|
.expect("initial scan should succeed");
|
|
let local_only = first_scan
|
|
.summaries
|
|
.get("game")
|
|
.expect("installed-only game should be indexed");
|
|
assert!(!local_only.downloaded);
|
|
assert!(local_only.installed);
|
|
assert_eq!(local_only.availability, Availability::LocalOnly);
|
|
|
|
write_file(&temp.path().join("game").join("version.ini"), b"20250101");
|
|
|
|
let rescan = rescan_local_game(temp.path(), state.path(), &catalog, "game")
|
|
.await
|
|
.expect("rescan should succeed");
|
|
let ready = rescan
|
|
.summaries
|
|
.get("game")
|
|
.expect("ready game should remain indexed");
|
|
assert!(ready.downloaded);
|
|
assert!(ready.installed);
|
|
assert_eq!(ready.eti_version.as_deref(), Some("20250101"));
|
|
assert_eq!(ready.availability, Availability::Ready);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn concurrent_rescans_preserve_both_index_updates() {
|
|
let temp = TempDir::new("lanspread-local-games-concurrent");
|
|
let state = TempDir::new("lanspread-local-games-state");
|
|
let catalog = GameCatalog::from_ids(["game-a".to_string(), "game-b".to_string()]);
|
|
write_file(&temp.path().join("game-a").join("version.ini"), b"20250101");
|
|
write_file(&temp.path().join("game-b").join("version.ini"), b"20250101");
|
|
|
|
let initial = scan_local_library(temp.path(), state.path(), &catalog)
|
|
.await
|
|
.expect("initial scan should succeed");
|
|
assert_eq!(initial.revision, 1);
|
|
|
|
write_file(&temp.path().join("game-a").join("game-a.eti"), b"archive-a");
|
|
write_file(&temp.path().join("game-b").join("game-b.eti"), b"archive-b");
|
|
|
|
let (scan_a, scan_b) = tokio::join!(
|
|
rescan_local_game(temp.path(), state.path(), &catalog, "game-a"),
|
|
rescan_local_game(temp.path(), state.path(), &catalog, "game-b")
|
|
);
|
|
scan_a.expect("game-a rescan should succeed");
|
|
scan_b.expect("game-b rescan should succeed");
|
|
|
|
let index = load_library_index(&library_index_path(state.path())).await;
|
|
assert_eq!(index.revision, 3);
|
|
let game_a = index
|
|
.games
|
|
.get("game-a")
|
|
.expect("game-a update should remain in index");
|
|
let game_b = index
|
|
.games
|
|
.get("game-b")
|
|
.expect("game-b update should remain in index");
|
|
assert!(
|
|
game_a.summary.size > 8,
|
|
"game-a rescan should persist the new archive"
|
|
);
|
|
assert!(
|
|
game_b.summary.size > 8,
|
|
"game-b rescan should persist the new archive"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn local_download_available_gates_on_catalog_operation_and_sentinel() {
|
|
let temp = TempDir::new("lanspread-local-games");
|
|
let game_root = temp.path().join("game");
|
|
write_file(&game_root.join("version.ini"), b"20250101");
|
|
|
|
let catalog = GameCatalog::from_ids(["game".to_string()]);
|
|
let no_operations = HashMap::new();
|
|
assert!(local_download_available(temp.path(), "game", &no_operations, &catalog).await);
|
|
|
|
let active_operations = HashMap::from([("game".to_string(), OperationKind::Downloading)]);
|
|
assert!(!local_download_available(temp.path(), "game", &active_operations, &catalog).await);
|
|
|
|
assert!(
|
|
!local_download_available(temp.path(), "game", &no_operations, &GameCatalog::empty())
|
|
.await
|
|
);
|
|
assert!(!local_download_available(temp.path(), "missing", &no_operations, &catalog).await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn local_download_matches_catalog_requires_expected_version() {
|
|
let temp = TempDir::new("lanspread-local-games");
|
|
let game_root = temp.path().join("game");
|
|
write_file(&game_root.join("version.ini"), b"20260101");
|
|
|
|
let mut catalog = GameCatalog::empty();
|
|
catalog.insert("game".to_string(), Some("20250101".to_string()));
|
|
let no_operations = HashMap::new();
|
|
|
|
assert!(
|
|
!local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await
|
|
);
|
|
|
|
catalog.insert("game".to_string(), Some("20260101".to_string()));
|
|
assert!(
|
|
local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await
|
|
);
|
|
}
|
|
}
|