feat(peer): add transactional local game operations
Implement the peer-owned state model from PLAN.md. A root-level version.ini is now the download completion sentinel, local/ as a directory is the install predicate, and exact root-level version.ini detection prevents nested files from becoming sentinels by accident. Add the peer operation table that gates downloads, installs, updates, and uninstalls by game ID. Serving paths now reject non-catalog games, active operations, missing sentinels, and any request that points under local/. Remote aggregation treats LocalOnly peers as non-downloadable so they do not contribute peer counts, candidate source selection, or latest-version checks. Move install-side filesystem mutation into lanspread-peer::install. The new module writes atomic .lanspread.json intents, uses .local.installing and .local.backup with .lanspread_owned markers, and performs startup recovery from recorded intent plus filesystem state. Downloads now buffer version.ini chunks in memory and commit the sentinel last through .version.ini.tmp. Replace the fixed 15-second monitor with notify-backed non-recursive watches, per-ID rescan gating, and a 300-second fallback scan. The optimized rescan path updates one cached library-index entry and active operation IDs preserve their previous summary during scans. Test Plan: - just fmt - just clippy - just test - just build Refs: PLAN.md
This commit is contained in:
@@ -12,7 +12,7 @@ use lanspread_db::db::{Game, GameDB, GameFileDescription};
|
||||
use lanspread_proto::{Availability, GameSummary};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::PeerError;
|
||||
use crate::{context::OperationKind, error::PeerError};
|
||||
|
||||
// =============================================================================
|
||||
// Local directory helpers
|
||||
@@ -28,51 +28,41 @@ pub fn is_local_dir_name(name: &str) -> bool {
|
||||
name == "local"
|
||||
}
|
||||
|
||||
/// Checks if a local directory has any content.
|
||||
pub async fn local_dir_has_content(path: &Path) -> bool {
|
||||
/// Checks if `local/` is a committed install directory.
|
||||
pub async fn local_dir_is_directory(path: &Path) -> bool {
|
||||
let local_dir = path.join("local");
|
||||
if tokio::fs::metadata(&local_dir).await.is_err() {
|
||||
return false;
|
||||
}
|
||||
tokio::fs::metadata(&local_dir)
|
||||
.await
|
||||
.is_ok_and(|metadata| metadata.is_dir())
|
||||
}
|
||||
|
||||
let mut entries = match tokio::fs::read_dir(&local_dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read local dir {}: {e}", local_dir.display());
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
match entries.next_entry().await {
|
||||
Ok(Some(_)) => true,
|
||||
Ok(None) => false,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to iterate local dir {}: {e}", local_dir.display());
|
||||
false
|
||||
}
|
||||
}
|
||||
/// 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,
|
||||
downloading_games: &HashSet<String>,
|
||||
active_operations: &HashMap<String, OperationKind>,
|
||||
catalog: &HashSet<String>,
|
||||
) -> bool {
|
||||
if downloading_games.contains(game_id) {
|
||||
log::debug!("Not serving game {game_id} locally because it is still downloading");
|
||||
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);
|
||||
let eti_path = game_path.join(format!("{game_id}.eti"));
|
||||
|
||||
if tokio::fs::metadata(&eti_path).await.is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only treat as pending install if the local installation directory is empty/missing
|
||||
!local_dir_has_content(game_path.as_path()).await
|
||||
version_ini_is_regular_file(game_path.as_path()).await
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -81,6 +71,9 @@ pub async fn local_download_available(
|
||||
|
||||
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";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct LibraryIndex {
|
||||
@@ -96,12 +89,18 @@ struct GameIndexEntry {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct GameFingerprint {
|
||||
eti_size: Option<u64>,
|
||||
eti_mtime: Option<u64>,
|
||||
eti_files: Vec<EtiFingerprint>,
|
||||
version_mtime: Option<u64>,
|
||||
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,
|
||||
@@ -154,32 +153,75 @@ fn system_time_to_secs(time: SystemTime) -> u64 {
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
async fn fingerprint_game_dir(game_path: &Path, game_id: &str) -> eyre::Result<GameFingerprint> {
|
||||
let eti_path = game_path.join(format!("{game_id}.eti"));
|
||||
let (eti_size, eti_mtime) = match tokio::fs::metadata(&eti_path).await {
|
||||
Ok(metadata) => (
|
||||
Some(metadata.len()),
|
||||
metadata.modified().ok().map(system_time_to_secs),
|
||||
),
|
||||
Err(_) => (None, None),
|
||||
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 = match tokio::fs::metadata(&version_path).await {
|
||||
Ok(metadata) => metadata.modified().ok().map(system_time_to_secs),
|
||||
Err(_) => None,
|
||||
Ok(metadata) if metadata.is_file() => metadata.modified().ok().map(system_time_to_secs),
|
||||
Err(_) | Ok(_) => None,
|
||||
};
|
||||
|
||||
let local_dir_present = local_dir_has_content(game_path).await;
|
||||
let local_dir_present = local_dir_is_directory(game_path).await;
|
||||
|
||||
Ok(GameFingerprint {
|
||||
eti_size,
|
||||
eti_mtime,
|
||||
eti_files,
|
||||
version_mtime,
|
||||
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;
|
||||
@@ -190,6 +232,9 @@ fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -282,20 +327,14 @@ fn manifest_hash(file_descriptions: &[GameFileDescription]) -> u64 {
|
||||
|
||||
async fn build_game_summary(game_dir: &Path, game_id: &str) -> Result<GameSummary, PeerError> {
|
||||
let game_path = game_dir.join(game_id);
|
||||
let eti_path = game_path.join(format!("{game_id}.eti"));
|
||||
let downloaded = tokio::fs::metadata(&eti_path).await.is_ok();
|
||||
if !downloaded {
|
||||
return Err(PeerError::Other(eyre::eyre!(
|
||||
"Game is not downloaded: {game_id}"
|
||||
)));
|
||||
}
|
||||
let downloaded = version_ini_is_regular_file(&game_path).await;
|
||||
let installed = local_dir_is_directory(&game_path).await;
|
||||
|
||||
let installed = local_dir_has_content(&game_path).await;
|
||||
let eti_version = if installed {
|
||||
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 installed game {game_id}: {e}");
|
||||
log::warn!("Failed to read version.ini for downloaded game {game_id}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -310,6 +349,11 @@ async fn build_game_summary(game_dir: &Path, game_id: &str) -> Result<GameSummar
|
||||
.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(),
|
||||
@@ -319,11 +363,11 @@ async fn build_game_summary(game_dir: &Path, game_id: &str) -> Result<GameSummar
|
||||
installed,
|
||||
eti_version,
|
||||
manifest_hash,
|
||||
availability: Availability::Ready,
|
||||
availability,
|
||||
})
|
||||
}
|
||||
|
||||
fn game_from_summary(summary: &GameSummary) -> Game {
|
||||
pub(crate) fn game_from_summary(summary: &GameSummary) -> Game {
|
||||
Game {
|
||||
id: summary.id.clone(),
|
||||
name: summary.name.clone(),
|
||||
@@ -350,12 +394,23 @@ struct IndexUpdate {
|
||||
async fn update_index_for_game(
|
||||
game_root: &Path,
|
||||
game_id: &str,
|
||||
catalog: &HashSet<String>,
|
||||
index: &mut LibraryIndex,
|
||||
) -> eyre::Result<IndexUpdate> {
|
||||
let game_path = game_root.join(game_id);
|
||||
let fingerprint = fingerprint_game_dir(&game_path, game_id).await?;
|
||||
if !catalog.contains(game_id) {
|
||||
return Ok(IndexUpdate {
|
||||
summary: None,
|
||||
changed: index.games.remove(game_id).is_some(),
|
||||
});
|
||||
}
|
||||
|
||||
if fingerprint.eti_size.is_none() {
|
||||
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(),
|
||||
@@ -401,12 +456,34 @@ fn empty_scan() -> LocalLibraryScan {
|
||||
}
|
||||
}
|
||||
|
||||
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>) -> eyre::Result<LocalLibraryScan> {
|
||||
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 {
|
||||
@@ -448,8 +525,11 @@ pub async fn scan_local_library(game_dir: impl AsRef<Path>) -> eyre::Result<Loca
|
||||
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, &mut index).await?;
|
||||
let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?;
|
||||
changed |= update.changed;
|
||||
|
||||
let Some(summary) = update.summary else {
|
||||
@@ -484,6 +564,30 @@ pub async fn scan_local_library(game_dir: impl AsRef<Path>) -> eyre::Result<Loca
|
||||
})
|
||||
}
|
||||
|
||||
/// 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_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
|
||||
// =============================================================================
|
||||
@@ -495,3 +599,123 @@ pub async fn get_game_file_descriptions(
|
||||
) -> 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, PathBuf},
|
||||
};
|
||||
|
||||
use lanspread_proto::Availability;
|
||||
|
||||
use super::*;
|
||||
use crate::context::OperationKind;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"lanspread-local-games-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&path).expect("temp dir should be created");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scan_uses_version_ini_and_local_dir_as_independent_state() {
|
||||
let temp = TempDir::new();
|
||||
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 local_download_available_gates_on_catalog_operation_and_sentinel() {
|
||||
let temp = TempDir::new();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user