6242d64583
FINDINGS.md identified three merge blockers in the post-plan install/update flow. Updates now use FetchLatestFromPeers so the Tauri update command bypasses local manifest serving and asks peers that advertise the latest version for fresh file metadata. PeerGameDB now aggregates and validates file descriptions from latest-version peers, keeping stale cached metadata for older versions from poisoning chunk planning when filenames stay the same but sizes change. Download-to-install handoff now performs explicit async state transitions. The download task mutates Downloading to Installing or Updating under the active-operation write lock, clears the cancellation token, and then runs the install transaction. OperationGuard remains armed only as crash or abort cleanup and is disarmed after normal explicit cleanup, so final refreshes no longer race a deferred Drop cleanup. Local library index writers now serialize the load/mutate/save window with one async mutex. The index fingerprint also includes the root version.ini contents so a same-length version rewrite in the same mtime second still updates the reported local version. The tradeoff is that local index mutations are serialized in-process instead of moved into a dedicated actor. That keeps the fix small and scoped to the merge blockers while preserving the existing scanner API. Test Plan: - just fmt - just test - just clippy - just build - git diff --check Refs: - FINDINGS.md
912 lines
28 KiB
Rust
912 lines
28 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, 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: &HashSet<String>,
|
|
) -> 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
|
|
}
|
|
|
|
// =============================================================================
|
|
// Local library index and scanning
|
|
// =============================================================================
|
|
|
|
const 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,
|
|
}
|
|
|
|
fn library_index_path(game_dir: &Path) -> PathBuf {
|
|
game_dir.join(LIBRARY_INDEX_DIR).join(LIBRARY_INDEX_FILE)
|
|
}
|
|
|
|
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 == 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 == 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: &HashSet<String>,
|
|
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>,
|
|
catalog: &HashSet<String>,
|
|
) -> eyre::Result<LocalLibraryScan> {
|
|
let game_path = game_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(game_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>,
|
|
catalog: &HashSet<String>,
|
|
game_id: &str,
|
|
) -> eyre::Result<LocalLibraryScan> {
|
|
let game_path = game_dir.as_ref();
|
|
let _index_guard = LIBRARY_INDEX_LOCK.lock().await;
|
|
let index_path = library_index_path(game_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, HashSet},
|
|
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 catalog = HashSet::from([
|
|
"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(), &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 catalog = HashSet::from(["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(), &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(), &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 catalog = HashSet::from(["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(), &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(), &catalog, "game-a"),
|
|
rescan_local_game(temp.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(temp.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 = HashSet::from(["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, &HashSet::new()).await
|
|
);
|
|
assert!(!local_download_available(temp.path(), "missing", &no_operations, &catalog).await);
|
|
}
|
|
}
|