Files
lanspread/crates/lanspread-db/src/db.rs
T
ddidderr 6c8a2bb9f0 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
2026-05-15 18:18:55 +02:00

266 lines
6.6 KiB
Rust

#![allow(clippy::missing_errors_doc)]
#![allow(clippy::doc_markdown)]
use std::{collections::HashMap, fmt, path::Path};
use serde::{Deserialize, Serialize};
/// Read version from version.ini file
/// # Errors
/// Returns error if file cannot be read or parsed
pub fn read_version_from_ini(game_dir: &Path) -> eyre::Result<Option<String>> {
let version_file = game_dir.join("version.ini");
if !version_file.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&version_file)?;
let version = content.trim().to_string();
// Validate format (YYYYMMDD)
if version.len() == 8 && version.chars().all(|c| c.is_ascii_digit()) {
Ok(Some(version))
} else {
tracing::warn!(
"Invalid version format in {}: {}",
version_file.display(),
version
);
Ok(None)
}
}
/// A game
#[derive(Clone, Serialize, Deserialize)]
pub struct Game {
/// example: aoe2
pub id: String,
/// example: Age of Empires 2
pub name: String,
/// example: Dieses Paket enthält die original AoE 2 Version,...
pub description: String,
/// example: 1999
pub release_year: String,
/// Microsoft
pub publisher: String,
/// example: 8
pub max_players: u32,
/// example: 3.5
pub version: String,
/// example: Echtzeit-Strategie
pub genre: String,
/// size in bytes: example: 3455063152
pub size: u64,
/// indicates that the ETI bundle exists locally
#[serde(default)]
pub downloaded: bool,
/// only relevant for client (yeah... I know)
#[serde(default)]
pub installed: bool,
/// ETI game version from version.ini (YYYYMMDD format) (server)
pub eti_game_version: Option<String>,
/// Local game version from version.ini (YYYYMMDD format)
pub local_version: Option<String>,
/// Number of peers that have this game available
pub peer_count: u32,
}
impl fmt::Debug for Game {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}: {} ({} MB)",
self.id,
self.name,
self.size / 1024 / 1024,
)
}
}
impl fmt::Display for Game {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.name)
}
}
impl PartialEq for Game {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for Game {}
impl PartialOrd for Game {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Game {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.name.cmp(&other.name)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameDB {
pub games: HashMap<String, Game>,
}
impl GameDB {
#[must_use]
pub fn empty() -> Self {
GameDB {
games: HashMap::new(),
}
}
#[must_use]
pub fn from(games: Vec<Game>) -> Self {
let mut db = GameDB::empty();
for game in games {
db.games.insert(game.id.clone(), game);
}
db
}
#[must_use]
pub fn get_game_by_id<S>(&self, id: S) -> Option<&Game>
where
S: AsRef<str>,
{
self.games.get(id.as_ref())
}
#[must_use]
pub fn get_mut_game_by_id<S>(&mut self, id: S) -> Option<&mut Game>
where
S: AsRef<str>,
{
self.games.get_mut(id.as_ref())
}
#[must_use]
pub fn get_game_by_name(&self, name: &str) -> Option<&Game> {
self.games.values().find(|game| game.name == name)
}
#[must_use]
pub fn all_games(&self) -> Vec<&Game> {
let mut games: Vec<_> = self.games.values().collect();
games.sort_by(|a, b| a.name.cmp(&b.name));
games
}
pub fn set_all_uninstalled(&mut self) {
for game in self.games.values_mut() {
game.downloaded = false;
game.installed = false;
game.local_version = None;
}
}
}
impl Default for GameDB {
fn default() -> Self {
Self::empty()
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct GameFileDescription {
pub game_id: String,
pub relative_path: String,
pub is_dir: bool,
pub size: u64,
}
impl GameFileDescription {
#[must_use]
pub fn is_version_ini(&self) -> bool {
let expected = format!("{}/version.ini", self.game_id);
self.relative_path.replace('\\', "/") == expected
}
#[must_use]
pub fn file_size(&self) -> u64 {
if self.is_dir { 0 } else { self.size }
}
}
impl fmt::Debug for GameFileDescription {
#[allow(clippy::cast_precision_loss)]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}: [{}] path:{} size:{}",
self.game_id,
if self.is_dir { 'D' } else { 'F' },
self.relative_path,
self.size,
)
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{Game, GameFileDescription};
#[test]
fn installed_defaults_to_false_when_missing() {
let raw = json!({
"id": "aoe2",
"name": "Age of Empires II",
"description": "desc",
"release_year": "1999",
"publisher": "Microsoft",
"max_players": 8,
"version": "1.0",
"genre": "RTS",
"size": 123_456,
"thumbnail": null,
"downloaded": true,
"eti_game_version": "20240101",
"local_version": null,
"peer_count": 2
})
.to_string();
let game: Game = serde_json::from_str(&raw).expect("game should deserialize");
assert!(
!game.installed,
"missing installed flag should default to false"
);
}
#[test]
fn version_ini_predicate_matches_only_game_root_sentinel() {
let root = GameFileDescription {
game_id: "aoe2".to_string(),
relative_path: "aoe2/version.ini".to_string(),
is_dir: false,
size: 8,
};
assert!(root.is_version_ini());
let nested = GameFileDescription {
game_id: "aoe2".to_string(),
relative_path: "aoe2/local/version.ini".to_string(),
is_dir: false,
size: 8,
};
assert!(!nested.is_version_ini());
let other_game = GameFileDescription {
game_id: "aoe2".to_string(),
relative_path: "other/version.ini".to_string(),
is_dir: false,
size: 8,
};
assert!(!other_game.is_version_ini());
}
}