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
197 lines
5.4 KiB
Rust
197 lines
5.4 KiB
Rust
use std::{
|
|
path::{Path, PathBuf},
|
|
time::{SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::io::AsyncWriteExt;
|
|
|
|
const INTENT_SCHEMA_VERSION: u32 = 1;
|
|
const INTENT_FILE: &str = ".lanspread.json";
|
|
const INTENT_TMP_FILE: &str = ".lanspread.json.tmp";
|
|
|
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
pub enum InstallIntentState {
|
|
None,
|
|
Installing,
|
|
Updating,
|
|
Uninstalling,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
pub struct InstallIntent {
|
|
pub schema_version: u32,
|
|
pub id: String,
|
|
pub recorded_at: u64,
|
|
pub state: InstallIntentState,
|
|
pub eti_version: Option<String>,
|
|
pub manifest_hash: Option<u64>,
|
|
}
|
|
|
|
impl InstallIntent {
|
|
pub fn new(id: &str, state: InstallIntentState, eti_version: Option<String>) -> Self {
|
|
Self {
|
|
schema_version: INTENT_SCHEMA_VERSION,
|
|
id: id.to_string(),
|
|
recorded_at: now_unix_secs(),
|
|
state,
|
|
eti_version,
|
|
manifest_hash: None,
|
|
}
|
|
}
|
|
|
|
pub fn none(id: &str, eti_version: Option<String>) -> Self {
|
|
Self::new(id, InstallIntentState::None, eti_version)
|
|
}
|
|
}
|
|
|
|
pub fn intent_path(game_root: &Path) -> PathBuf {
|
|
game_root.join(INTENT_FILE)
|
|
}
|
|
|
|
pub fn intent_tmp_path(game_root: &Path) -> PathBuf {
|
|
game_root.join(INTENT_TMP_FILE)
|
|
}
|
|
|
|
pub async fn read_intent(game_root: &Path, id: &str) -> InstallIntent {
|
|
let path = intent_path(game_root);
|
|
let data = match tokio::fs::read_to_string(&path).await {
|
|
Ok(data) => data,
|
|
Err(err) => {
|
|
if err.kind() != std::io::ErrorKind::NotFound {
|
|
log::warn!("Failed to read install intent {}: {err}", path.display());
|
|
}
|
|
return InstallIntent::none(id, None);
|
|
}
|
|
};
|
|
|
|
match serde_json::from_str::<InstallIntent>(&data) {
|
|
Ok(intent) if intent.schema_version == INTENT_SCHEMA_VERSION && intent.id == id => intent,
|
|
Ok(intent) => {
|
|
log::warn!(
|
|
"Ignoring install intent {} with schema {} for id {}",
|
|
path.display(),
|
|
intent.schema_version,
|
|
intent.id
|
|
);
|
|
InstallIntent::none(id, None)
|
|
}
|
|
Err(err) => {
|
|
log::warn!("Ignoring corrupt install intent {}: {err}", path.display());
|
|
InstallIntent::none(id, None)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn write_intent(game_root: &Path, intent: &InstallIntent) -> eyre::Result<()> {
|
|
tokio::fs::create_dir_all(game_root).await?;
|
|
let path = intent_path(game_root);
|
|
let tmp_path = intent_tmp_path(game_root);
|
|
let data = serde_json::to_vec_pretty(intent)?;
|
|
|
|
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(())
|
|
}
|
|
|
|
fn now_unix_secs() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs()
|
|
}
|
|
|
|
#[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(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use super::*;
|
|
|
|
struct TempDir(PathBuf);
|
|
|
|
impl TempDir {
|
|
fn new() -> Self {
|
|
let mut path = std::env::temp_dir();
|
|
path.push(format!(
|
|
"lanspread-intent-{}-{}",
|
|
std::process::id(),
|
|
now_unix_secs()
|
|
));
|
|
path.push(format!("{:?}", std::thread::current().id()).replace(['(', ')'], ""));
|
|
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);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tmp_write_without_rename_leaves_previous_intent_intact() {
|
|
let temp = TempDir::new();
|
|
let previous = InstallIntent::new(
|
|
"game",
|
|
InstallIntentState::Updating,
|
|
Some("20240101".to_string()),
|
|
);
|
|
write_intent(temp.path(), &previous)
|
|
.await
|
|
.expect("previous intent should be written");
|
|
|
|
tokio::fs::write(
|
|
intent_tmp_path(temp.path()),
|
|
serde_json::to_vec(&InstallIntent::new(
|
|
"game",
|
|
InstallIntentState::Installing,
|
|
Some("20250101".to_string()),
|
|
))
|
|
.expect("intent should serialize"),
|
|
)
|
|
.await
|
|
.expect("tmp intent should be written");
|
|
|
|
let recovered = read_intent(temp.path(), "game").await;
|
|
assert_eq!(recovered.state, InstallIntentState::Updating);
|
|
assert_eq!(recovered.eti_version.as_deref(), Some("20240101"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn schema_mismatch_is_treated_as_missing() {
|
|
let temp = TempDir::new();
|
|
tokio::fs::write(
|
|
intent_path(temp.path()),
|
|
r#"{"schema_version":2,"id":"game","recorded_at":0,"state":"Updating"}"#,
|
|
)
|
|
.await
|
|
.expect("intent should be written");
|
|
|
|
let recovered = read_intent(temp.path(), "game").await;
|
|
assert_eq!(recovered.state, InstallIntentState::None);
|
|
}
|
|
}
|