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, pub manifest_hash: Option, } impl InstallIntent { pub fn new(id: &str, state: InstallIntentState, eti_version: Option) -> 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) -> 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::(&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); } }