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:
@@ -13,7 +13,7 @@ use crate::{
|
||||
context::PeerCtx,
|
||||
error::PeerError,
|
||||
events,
|
||||
local_games::get_game_file_descriptions,
|
||||
local_games::{get_game_file_descriptions, is_local_dir_name, local_download_available},
|
||||
peer::{send_game_file_chunk, send_game_file_data},
|
||||
remote_peer::{ensure_peer_id_for_addr, update_peer_from_game_list},
|
||||
services::handshake::{
|
||||
@@ -155,10 +155,10 @@ async fn handle_list_games(ctx: &PeerCtx, framed_tx: ResponseWriter) -> Response
|
||||
let games = if snapshot.is_empty() {
|
||||
snapshot
|
||||
} else {
|
||||
let downloading = ctx.downloading_games.read().await;
|
||||
let active_operations = ctx.active_operations.read().await;
|
||||
snapshot
|
||||
.into_iter()
|
||||
.filter(|game| !downloading.contains(&game.id))
|
||||
.filter(|game| !active_operations.contains_key(&game.id))
|
||||
.collect()
|
||||
};
|
||||
|
||||
@@ -268,22 +268,8 @@ async fn handle_get_game(ctx: &PeerCtx, id: String, framed_tx: ResponseWriter) -
|
||||
}
|
||||
|
||||
async fn get_game_response(ctx: &PeerCtx, id: String) -> Response {
|
||||
let downloading = ctx.downloading_games.read().await.contains(&id);
|
||||
if downloading {
|
||||
log::info!("Declining to serve GetGame for {id} because download is in progress");
|
||||
return Response::GameNotFound(id);
|
||||
}
|
||||
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
|
||||
let has_game = {
|
||||
let db_guard = ctx.local_game_db.read().await;
|
||||
db_guard
|
||||
.as_ref()
|
||||
.is_some_and(|db| db.get_game_by_id(&id).is_some())
|
||||
};
|
||||
|
||||
if !has_game {
|
||||
if !can_serve_game(ctx, &game_dir, &id).await {
|
||||
return Response::GameNotFound(id);
|
||||
}
|
||||
|
||||
@@ -304,6 +290,22 @@ async fn get_game_response(ctx: &PeerCtx, id: String) -> Response {
|
||||
}
|
||||
}
|
||||
|
||||
async fn can_serve_game(ctx: &PeerCtx, game_dir: &std::path::Path, game_id: &str) -> bool {
|
||||
let active_operations = ctx.active_operations.read().await;
|
||||
let catalog = ctx.catalog.read().await;
|
||||
local_download_available(game_dir, game_id, &active_operations, &catalog).await
|
||||
}
|
||||
|
||||
fn path_points_inside_local(game_id: &str, relative_path: &str) -> bool {
|
||||
let normalised = relative_path.replace('\\', "/");
|
||||
let mut parts = normalised.split('/').filter(|part| !part.is_empty());
|
||||
match (parts.next(), parts.next()) {
|
||||
(Some(first), _) if is_local_dir_name(first) => true,
|
||||
(Some(first), Some(second)) if first == game_id && is_local_dir_name(second) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_file_data_request(
|
||||
ctx: &PeerCtx,
|
||||
desc: GameFileDescription,
|
||||
@@ -314,9 +316,19 @@ async fn handle_file_data_request(
|
||||
desc.relative_path
|
||||
);
|
||||
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
|
||||
let mut tx = framed_tx.into_inner();
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
if path_points_inside_local(&desc.game_id, &desc.relative_path)
|
||||
|| !can_serve_game(ctx, &game_dir, &desc.game_id).await
|
||||
{
|
||||
log::info!(
|
||||
"Declining GetGameFileData for {} because the game is not currently transferable",
|
||||
desc.relative_path
|
||||
);
|
||||
let _ = tx.close().await;
|
||||
return FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
}
|
||||
|
||||
send_game_file_data(&desc, &mut tx, &game_dir).await;
|
||||
FramedWrite::new(tx, LengthDelimitedCodec::new())
|
||||
}
|
||||
@@ -333,9 +345,18 @@ async fn handle_file_chunk_request(
|
||||
"Received GetGameFileChunk request for {relative_path} (offset {offset}, length {length})"
|
||||
);
|
||||
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
|
||||
let mut tx = framed_tx.into_inner();
|
||||
let game_dir = ctx.game_dir.read().await.clone();
|
||||
if path_points_inside_local(&game_id, &relative_path)
|
||||
|| !can_serve_game(ctx, &game_dir, &game_id).await
|
||||
{
|
||||
log::info!(
|
||||
"Declining GetGameFileChunk for {relative_path} because the game is not currently transferable"
|
||||
);
|
||||
let _ = tx.close().await;
|
||||
return FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
}
|
||||
|
||||
send_game_file_chunk(&game_id, &relative_path, offset, length, &mut tx, &game_dir).await;
|
||||
FramedWrite::new(tx, LengthDelimitedCodec::new())
|
||||
}
|
||||
@@ -365,3 +386,17 @@ async fn handle_announce_games(ctx: &PeerCtx, remote_addr: Option<SocketAddr>, g
|
||||
events::send(&ctx.tx_notify_ui, PeerEvent::ListGames(aggregated_games));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::path_points_inside_local;
|
||||
|
||||
#[test]
|
||||
fn local_relative_paths_are_never_transferable() {
|
||||
assert!(path_points_inside_local("game", "game/local/save.dat"));
|
||||
assert!(path_points_inside_local("game", "local/save.dat"));
|
||||
assert!(path_points_inside_local("game", "game\\local\\save.dat"));
|
||||
assert!(!path_points_inside_local("game", "game/version.ini"));
|
||||
assert!(!path_points_inside_local("game", "game/archive.eti"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user