b5d20c1e72
The follow-up review found a few stale lifecycle edges around local game transactions. Recovery could sweep active roots, post-operation refreshes still re-ran full startup recovery, and the UI kept inferring local-only state from downloaded and installed flags instead of the backend availability. This updates the peer lifecycle so startup recovery skips active operations, install/update/uninstall refresh only the affected game after the operation guard is dropped, and path-changing game-directory updates are rejected while operations are active. It also removes the dead UpdateGame command, drops the unused manifest_hash write field while preserving old JSON reads, renames the internal install-finished event, and carries availability through the DB, peer summaries, Tauri refreshes, and the React model. The included follow-up documents record the review source, implementation decisions, and the remaining FOLLOW_UP_2.md work so later commits can stay small instead of reopening the completed plan items. Test Plan: - git diff --check - just fmt - just clippy - just test Follow-up-Plan: FOLLOW_UP_PLAN.md
717 lines
22 KiB
Rust
717 lines
22 KiB
Rust
use std::{
|
|
collections::HashSet,
|
|
io::ErrorKind,
|
|
path::{Path, PathBuf},
|
|
sync::Arc,
|
|
};
|
|
|
|
use eyre::WrapErr;
|
|
|
|
use super::{
|
|
intent::{InstallIntent, InstallIntentState, read_intent, write_intent},
|
|
unpack::Unpacker,
|
|
};
|
|
use crate::local_games::version_ini_is_regular_file;
|
|
|
|
const LOCAL_DIR: &str = "local";
|
|
const INSTALLING_DIR: &str = ".local.installing";
|
|
const BACKUP_DIR: &str = ".local.backup";
|
|
const OWNED_MARKER: &str = ".lanspread_owned";
|
|
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
|
|
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum FsEntryState {
|
|
Present,
|
|
Missing,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
struct InstallFsState {
|
|
local: FsEntryState,
|
|
installing: FsEntryState,
|
|
backup: FsEntryState,
|
|
}
|
|
|
|
pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
|
|
let eti_version = read_downloaded_version(game_root).await;
|
|
write_intent(
|
|
game_root,
|
|
&InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()),
|
|
)
|
|
.await?;
|
|
|
|
let result = install_inner(game_root, id, unpacker).await;
|
|
match result {
|
|
Ok(()) => {
|
|
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
|
Ok(())
|
|
}
|
|
Err(err) => {
|
|
if let Err(cleanup_err) = remove_dir_all_if_exists(&installing_dir(game_root)).await {
|
|
log::warn!(
|
|
"Failed to clean install staging {}: {cleanup_err}",
|
|
installing_dir(game_root).display()
|
|
);
|
|
}
|
|
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
|
Err(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
|
|
let eti_version = read_downloaded_version(game_root).await;
|
|
write_intent(
|
|
game_root,
|
|
&InstallIntent::new(id, InstallIntentState::Updating, eti_version.clone()),
|
|
)
|
|
.await?;
|
|
|
|
let result = update_inner(game_root, id, unpacker).await;
|
|
match result {
|
|
Ok(()) => {
|
|
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
|
if let Err(err) = remove_dir_all_if_exists(&backup_dir(game_root)).await {
|
|
log::warn!(
|
|
"Failed to clean install backup {}: {err}",
|
|
backup_dir(game_root).display()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
Err(err) => {
|
|
let rollback = rollback_update(game_root).await;
|
|
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
|
if let Err(rollback_err) = rollback {
|
|
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
|
|
}
|
|
Err(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
|
|
let eti_version = read_downloaded_version(game_root).await;
|
|
write_intent(
|
|
game_root,
|
|
&InstallIntent::new(id, InstallIntentState::Uninstalling, eti_version.clone()),
|
|
)
|
|
.await?;
|
|
|
|
let result = uninstall_inner(game_root).await;
|
|
match result {
|
|
Ok(()) => {
|
|
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
|
Ok(())
|
|
}
|
|
Err(err) => {
|
|
let rollback = restore_backup(game_root).await;
|
|
if let Err(rollback_err) = rollback {
|
|
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
|
|
}
|
|
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
|
Err(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn recover_on_startup(game_dir: &Path, active_ids: &HashSet<String>) -> eyre::Result<()> {
|
|
recover_download_transients(game_dir).await?;
|
|
|
|
let mut entries = match tokio::fs::read_dir(game_dir).await {
|
|
Ok(entries) => entries,
|
|
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
|
|
Err(err) => return Err(err.into()),
|
|
};
|
|
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
if !entry.file_type().await?.is_dir() {
|
|
continue;
|
|
}
|
|
|
|
let Some(id) = entry.file_name().to_str().map(ToOwned::to_owned) else {
|
|
continue;
|
|
};
|
|
if id == ".lanspread" {
|
|
continue;
|
|
}
|
|
if active_ids.contains(&id) {
|
|
log::debug!("Skipping recovery for active game root {id}");
|
|
continue;
|
|
}
|
|
|
|
recover_game_root(&entry.path(), &id).await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn recover_game_root(game_root: &Path, id: &str) -> eyre::Result<()> {
|
|
recover_download_transients(game_root).await?;
|
|
|
|
let intent = read_intent(game_root, id).await;
|
|
let fs = inspect_install_fs(game_root).await;
|
|
match intent.state {
|
|
InstallIntentState::None => recover_none_intent(game_root).await?,
|
|
InstallIntentState::Installing => recover_installing(game_root, id, intent, fs).await?,
|
|
InstallIntentState::Updating => recover_updating(game_root, id, intent, fs).await?,
|
|
InstallIntentState::Uninstalling => recover_uninstalling(game_root, id, intent, fs).await?,
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn install_inner(
|
|
game_root: &Path,
|
|
id: &str,
|
|
unpacker: Arc<dyn Unpacker>,
|
|
) -> eyre::Result<()> {
|
|
let local = local_dir(game_root);
|
|
if path_is_dir(&local).await {
|
|
eyre::bail!("game {id} is already installed");
|
|
}
|
|
|
|
let staging = installing_dir(game_root);
|
|
prepare_owned_empty_dir(&staging).await?;
|
|
unpack_archives(game_root, &staging, unpacker).await?;
|
|
tokio::fs::rename(&staging, &local)
|
|
.await
|
|
.wrap_err_with(|| format!("failed to promote install for {id}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
|
|
let local = local_dir(game_root);
|
|
let backup = backup_dir(game_root);
|
|
let staging = installing_dir(game_root);
|
|
|
|
if !path_is_dir(&local).await {
|
|
eyre::bail!("game {id} is not installed");
|
|
}
|
|
prepare_backup_slot(&backup).await?;
|
|
tokio::fs::rename(&local, &backup)
|
|
.await
|
|
.wrap_err_with(|| format!("failed to move existing install for {id} to backup"))?;
|
|
drop_owned_marker(&backup).await?;
|
|
|
|
prepare_owned_empty_dir(&staging).await?;
|
|
unpack_archives(game_root, &staging, unpacker).await?;
|
|
tokio::fs::rename(&staging, &local)
|
|
.await
|
|
.wrap_err_with(|| format!("failed to promote update for {id}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn uninstall_inner(game_root: &Path) -> eyre::Result<()> {
|
|
let local = local_dir(game_root);
|
|
let backup = backup_dir(game_root);
|
|
|
|
if !path_is_dir(&local).await {
|
|
return Ok(());
|
|
}
|
|
|
|
prepare_backup_slot(&backup).await?;
|
|
tokio::fs::rename(&local, &backup).await?;
|
|
drop_owned_marker(&backup).await?;
|
|
tokio::fs::remove_dir_all(&backup).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn unpack_archives(
|
|
game_root: &Path,
|
|
staging: &Path,
|
|
unpacker: Arc<dyn Unpacker>,
|
|
) -> eyre::Result<()> {
|
|
let archives = root_eti_archives(game_root).await?;
|
|
if archives.is_empty() {
|
|
eyre::bail!("no .eti archives found in {}", game_root.display());
|
|
}
|
|
|
|
for archive in archives {
|
|
unpacker.unpack(&archive, staging).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
|
|
let mut entries = tokio::fs::read_dir(game_root).await?;
|
|
let mut archives = Vec::new();
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
if !entry.file_type().await?.is_file() {
|
|
continue;
|
|
}
|
|
let path = entry.path();
|
|
if path.extension().is_some_and(|extension| extension == "eti") {
|
|
archives.push(path);
|
|
}
|
|
}
|
|
archives.sort();
|
|
Ok(archives)
|
|
}
|
|
|
|
async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
|
|
sweep_owned_orphan(&installing_dir(game_root)).await?;
|
|
sweep_owned_orphan(&backup_dir(game_root)).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn recover_installing(
|
|
game_root: &Path,
|
|
id: &str,
|
|
intent: InstallIntent,
|
|
fs: InstallFsState,
|
|
) -> eyre::Result<()> {
|
|
if let InstallFsState {
|
|
installing: FsEntryState::Present,
|
|
..
|
|
} = fs
|
|
{
|
|
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
|
|
}
|
|
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
|
|
}
|
|
|
|
async fn recover_updating(
|
|
game_root: &Path,
|
|
id: &str,
|
|
intent: InstallIntent,
|
|
fs: InstallFsState,
|
|
) -> eyre::Result<()> {
|
|
match fs {
|
|
InstallFsState {
|
|
local: FsEntryState::Missing,
|
|
installing: FsEntryState::Present,
|
|
backup: FsEntryState::Present,
|
|
} => {
|
|
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
|
|
restore_backup(game_root).await?;
|
|
}
|
|
InstallFsState {
|
|
local: FsEntryState::Present,
|
|
installing: FsEntryState::Present,
|
|
backup: FsEntryState::Present,
|
|
} => {
|
|
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
|
|
remove_dir_all_if_exists(&backup_dir(game_root)).await?;
|
|
}
|
|
InstallFsState {
|
|
local: FsEntryState::Present,
|
|
installing: FsEntryState::Missing,
|
|
backup: FsEntryState::Present,
|
|
} => remove_dir_all_if_exists(&backup_dir(game_root)).await?,
|
|
_ => {}
|
|
}
|
|
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
|
|
}
|
|
|
|
async fn recover_uninstalling(
|
|
game_root: &Path,
|
|
id: &str,
|
|
intent: InstallIntent,
|
|
fs: InstallFsState,
|
|
) -> eyre::Result<()> {
|
|
match fs {
|
|
InstallFsState {
|
|
local: FsEntryState::Missing,
|
|
installing: FsEntryState::Missing,
|
|
backup: FsEntryState::Present,
|
|
} => remove_dir_all_if_exists(&backup_dir(game_root)).await?,
|
|
InstallFsState {
|
|
local: FsEntryState::Present,
|
|
installing: FsEntryState::Missing,
|
|
backup: FsEntryState::Missing,
|
|
} => uninstall_inner(game_root).await?,
|
|
_ => {}
|
|
}
|
|
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
|
|
}
|
|
|
|
async fn recover_download_transients(root: &Path) -> eyre::Result<()> {
|
|
remove_file_if_exists(&root.join(VERSION_TMP_FILE)).await?;
|
|
remove_file_if_exists(&root.join(VERSION_DISCARDED_FILE)).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn inspect_install_fs(game_root: &Path) -> InstallFsState {
|
|
InstallFsState {
|
|
local: path_is_dir(&local_dir(game_root)).await.into(),
|
|
installing: path_is_dir(&installing_dir(game_root)).await.into(),
|
|
backup: path_is_dir(&backup_dir(game_root)).await.into(),
|
|
}
|
|
}
|
|
|
|
async fn read_downloaded_version(game_root: &Path) -> Option<String> {
|
|
if !version_ini_is_regular_file(game_root).await {
|
|
return None;
|
|
}
|
|
match lanspread_db::db::read_version_from_ini(game_root) {
|
|
Ok(version) => version,
|
|
Err(err) => {
|
|
log::warn!(
|
|
"Failed to read version.ini in {}: {err}",
|
|
game_root.display()
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn prepare_owned_empty_dir(path: &Path) -> eyre::Result<()> {
|
|
if path.exists() {
|
|
if owned_marker(path).is_file() {
|
|
tokio::fs::remove_dir_all(path).await?;
|
|
} else {
|
|
eyre::bail!("refusing to reuse markerless directory {}", path.display());
|
|
}
|
|
}
|
|
tokio::fs::create_dir_all(path).await?;
|
|
drop_owned_marker(path).await
|
|
}
|
|
|
|
async fn prepare_backup_slot(path: &Path) -> eyre::Result<()> {
|
|
if !path.exists() {
|
|
return Ok(());
|
|
}
|
|
if owned_marker(path).is_file() {
|
|
tokio::fs::remove_dir_all(path).await?;
|
|
return Ok(());
|
|
}
|
|
eyre::bail!("refusing to replace markerless backup {}", path.display());
|
|
}
|
|
|
|
async fn drop_owned_marker(path: &Path) -> eyre::Result<()> {
|
|
tokio::fs::write(owned_marker(path), []).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn sweep_owned_orphan(path: &Path) -> eyre::Result<()> {
|
|
if !path.exists() {
|
|
return Ok(());
|
|
}
|
|
if owned_marker(path).is_file() {
|
|
remove_dir_all_if_exists(path).await?;
|
|
} else {
|
|
log::warn!(
|
|
"Leaving markerless reserved directory untouched: {}",
|
|
path.display()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn rollback_update(game_root: &Path) -> eyre::Result<()> {
|
|
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
|
|
restore_backup(game_root).await
|
|
}
|
|
|
|
async fn restore_backup(game_root: &Path) -> eyre::Result<()> {
|
|
let local = local_dir(game_root);
|
|
let backup = backup_dir(game_root);
|
|
if !path_is_dir(&backup).await {
|
|
return Ok(());
|
|
}
|
|
remove_dir_all_if_exists(&local).await?;
|
|
tokio::fs::rename(&backup, &local).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> {
|
|
match tokio::fs::remove_file(path).await {
|
|
Ok(()) => Ok(()),
|
|
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
|
|
Err(err) => Err(err.into()),
|
|
}
|
|
}
|
|
|
|
async fn remove_dir_all_if_exists(path: &Path) -> eyre::Result<()> {
|
|
match tokio::fs::remove_dir_all(path).await {
|
|
Ok(()) => Ok(()),
|
|
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
|
|
Err(err) => Err(err.into()),
|
|
}
|
|
}
|
|
|
|
async fn path_is_dir(path: &Path) -> bool {
|
|
tokio::fs::metadata(path)
|
|
.await
|
|
.is_ok_and(|metadata| metadata.is_dir())
|
|
}
|
|
|
|
fn local_dir(game_root: &Path) -> PathBuf {
|
|
game_root.join(LOCAL_DIR)
|
|
}
|
|
|
|
fn installing_dir(game_root: &Path) -> PathBuf {
|
|
game_root.join(INSTALLING_DIR)
|
|
}
|
|
|
|
fn backup_dir(game_root: &Path) -> PathBuf {
|
|
game_root.join(BACKUP_DIR)
|
|
}
|
|
|
|
fn owned_marker(path: &Path) -> PathBuf {
|
|
path.join(OWNED_MARKER)
|
|
}
|
|
|
|
impl From<bool> for FsEntryState {
|
|
fn from(value: bool) -> Self {
|
|
if value { Self::Present } else { Self::Missing }
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{
|
|
collections::HashSet,
|
|
path::{Path, PathBuf},
|
|
sync::{Arc, Mutex},
|
|
};
|
|
|
|
use super::*;
|
|
use crate::install::unpack::UnpackFuture;
|
|
|
|
#[derive(Default)]
|
|
struct FakeUnpacker {
|
|
fail: bool,
|
|
archives: Mutex<Vec<PathBuf>>,
|
|
}
|
|
|
|
impl FakeUnpacker {
|
|
fn failing() -> Self {
|
|
Self {
|
|
fail: true,
|
|
archives: Mutex::new(Vec::new()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Unpacker for FakeUnpacker {
|
|
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
|
Box::pin(async move {
|
|
self.archives
|
|
.lock()
|
|
.expect("archive list should not be poisoned")
|
|
.push(archive.to_path_buf());
|
|
if self.fail {
|
|
eyre::bail!("forced unpack failure");
|
|
}
|
|
tokio::fs::write(dest.join("payload.txt"), b"installed").await?;
|
|
Ok(())
|
|
})
|
|
}
|
|
}
|
|
|
|
struct TempDir(PathBuf);
|
|
|
|
impl TempDir {
|
|
fn new() -> Self {
|
|
let mut path = std::env::temp_dir();
|
|
path.push(format!(
|
|
"lanspread-install-{}-{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_nanos()
|
|
));
|
|
std::fs::create_dir_all(&path).expect("temp dir should be created");
|
|
Self(path)
|
|
}
|
|
|
|
fn game_root(&self) -> PathBuf {
|
|
self.0.join("game")
|
|
}
|
|
}
|
|
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn write_file(path: &Path, bytes: &[u8]) {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent).expect("parent dir should be created");
|
|
}
|
|
std::fs::write(path, bytes).expect("file should be written");
|
|
}
|
|
|
|
fn successful_unpacker() -> Arc<dyn Unpacker> {
|
|
Arc::new(FakeUnpacker::default())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_success_promotes_staging_and_clears_intent() {
|
|
let temp = TempDir::new();
|
|
let root = temp.game_root();
|
|
write_file(&root.join("game.eti"), b"archive");
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
|
|
install(&root, "game", successful_unpacker())
|
|
.await
|
|
.expect("install should succeed");
|
|
|
|
assert!(root.join("local").join("payload.txt").is_file());
|
|
assert!(!root.join(".local.installing").exists());
|
|
let intent = read_intent(&root, "game").await;
|
|
assert_eq!(intent.state, InstallIntentState::None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_unpacks_multiple_root_eti_archives_in_sorted_order() {
|
|
let temp = TempDir::new();
|
|
let root = temp.game_root();
|
|
write_file(&root.join("b.eti"), b"archive");
|
|
write_file(&root.join("a.eti"), b"archive");
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
let unpacker = Arc::new(FakeUnpacker::default());
|
|
|
|
install(&root, "game", unpacker.clone())
|
|
.await
|
|
.expect("install should succeed");
|
|
|
|
let archives = unpacker
|
|
.archives
|
|
.lock()
|
|
.expect("archive list should not be poisoned")
|
|
.iter()
|
|
.filter_map(|path| path.file_name()?.to_str().map(ToOwned::to_owned))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(archives, vec!["a.eti", "b.eti"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_failure_restores_previous_local() {
|
|
let temp = TempDir::new();
|
|
let root = temp.game_root();
|
|
write_file(&root.join("game.eti"), b"archive");
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("local").join("old.txt"), b"old");
|
|
|
|
let err = update(&root, "game", Arc::new(FakeUnpacker::failing()))
|
|
.await
|
|
.expect_err("update should fail");
|
|
|
|
assert!(err.to_string().contains("forced unpack failure"));
|
|
assert!(root.join("local").join("old.txt").is_file());
|
|
assert!(!root.join(".local.installing").exists());
|
|
assert!(!root.join(".local.backup").exists());
|
|
let intent = read_intent(&root, "game").await;
|
|
assert_eq!(intent.state, InstallIntentState::None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_success_promotes_new_local_and_removes_backup() {
|
|
let temp = TempDir::new();
|
|
let root = temp.game_root();
|
|
write_file(&root.join("game.eti"), b"archive");
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("local").join("old.txt"), b"old");
|
|
|
|
update(&root, "game", successful_unpacker())
|
|
.await
|
|
.expect("update should succeed");
|
|
|
|
assert!(root.join("local").join("payload.txt").is_file());
|
|
assert!(!root.join("local").join("old.txt").exists());
|
|
assert!(!root.join(".local.installing").exists());
|
|
assert!(!root.join(".local.backup").exists());
|
|
let intent = read_intent(&root, "game").await;
|
|
assert_eq!(intent.state, InstallIntentState::None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn uninstall_removes_only_local_install() {
|
|
let temp = TempDir::new();
|
|
let root = temp.game_root();
|
|
write_file(&root.join("game.eti"), b"archive");
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("local").join("payload.txt"), b"installed");
|
|
|
|
uninstall(&root, "game")
|
|
.await
|
|
.expect("uninstall should succeed");
|
|
|
|
assert!(!root.join("local").exists());
|
|
assert!(root.join("game.eti").is_file());
|
|
assert!(root.join("version.ini").is_file());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recovery_restores_backup_for_interrupted_update() {
|
|
let temp = TempDir::new();
|
|
let root = temp.game_root();
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join(".local.backup").join("old.txt"), b"old");
|
|
write_file(&root.join(".local.installing").join("new.txt"), b"new");
|
|
write_file(&root.join(".local.backup").join(OWNED_MARKER), b"");
|
|
write_file(&root.join(".local.installing").join(OWNED_MARKER), b"");
|
|
write_intent(
|
|
&root,
|
|
&InstallIntent::new(
|
|
"game",
|
|
InstallIntentState::Updating,
|
|
Some("20250101".into()),
|
|
),
|
|
)
|
|
.await
|
|
.expect("intent should be written");
|
|
|
|
recover_game_root(&root, "game")
|
|
.await
|
|
.expect("recovery should succeed");
|
|
|
|
assert!(root.join("local").join("old.txt").is_file());
|
|
assert!(!root.join(".local.installing").exists());
|
|
assert!(!root.join(".local.backup").exists());
|
|
assert_eq!(
|
|
read_intent(&root, "game").await.state,
|
|
InstallIntentState::None
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn none_recovery_leaves_markerless_reserved_dirs_untouched() {
|
|
let temp = TempDir::new();
|
|
let root = temp.game_root();
|
|
write_file(&root.join(".local.backup").join("user.txt"), b"user");
|
|
|
|
recover_game_root(&root, "game")
|
|
.await
|
|
.expect("recovery should succeed");
|
|
|
|
assert!(root.join(".local.backup").join("user.txt").is_file());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn download_recovery_sweeps_reserved_version_files() {
|
|
let temp = TempDir::new();
|
|
let root = temp.game_root();
|
|
write_file(&root.join(VERSION_TMP_FILE), b"tmp");
|
|
write_file(&root.join(VERSION_DISCARDED_FILE), b"old");
|
|
|
|
recover_game_root(&root, "game")
|
|
.await
|
|
.expect("recovery should succeed");
|
|
|
|
assert!(!root.join(VERSION_TMP_FILE).exists());
|
|
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn startup_recovery_skips_active_game_roots() {
|
|
let temp = TempDir::new();
|
|
let active_root = temp.0.join("active");
|
|
let inactive_root = temp.0.join("inactive");
|
|
write_file(&active_root.join(VERSION_TMP_FILE), b"tmp");
|
|
write_file(&inactive_root.join(VERSION_TMP_FILE), b"tmp");
|
|
|
|
recover_on_startup(&temp.0, &HashSet::from(["active".to_string()]))
|
|
.await
|
|
.expect("recovery should succeed");
|
|
|
|
assert!(active_root.join(VERSION_TMP_FILE).is_file());
|
|
assert!(!inactive_root.join(VERSION_TMP_FILE).exists());
|
|
}
|
|
}
|