6c8a2bb9f0
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
266 lines
6.6 KiB
Rust
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());
|
|
}
|
|
}
|