feat: store launcher state outside game dirs
Move launcher-owned metadata from game roots into the configured peer state area. Peer identity, the local library index, install intent logs, and setup markers now live under app/CLI state instead of being written beside games. The Tauri shell passes its app data directory into the peer, and the peer CLI runs the same path through its explicit --state-dir. Add a dedicated pre-start migration phase for legacy files. It migrates the old global library index, per-game install intents, and the old first-start marker into app state, then deletes legacy files only after the replacement write succeeds. Normal scan, install, recovery, and transfer paths no longer read legacy state files. Rename the old first-start meaning to setup_done and only set it after launching game_setup.cmd. Start/setup scripts keep the shared argument shape, while server_start.cmd now uses cmd /k and a visible window so server logs stay open for inspection. While validating the Docker scenario matrix, make download terminal events come from the handler after local state refresh and operation cleanup. This makes download-finished/download-failed safe points for immediate follow-up CLI commands. Also update the multi-peer chunking scenario to use a sparse archive large enough to actually span multiple production chunks. Test Plan: - just fmt - just test - just frontend-test - just build - just clippy - git diff --check - python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py Refs: local app-state migration discussion
This commit is contained in:
@@ -7,8 +7,10 @@ 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";
|
||||
pub(crate) const LEGACY_INTENT_FILE: &str = ".lanspread.json";
|
||||
pub(crate) const LEGACY_INTENT_TMP_FILE: &str = ".lanspread.json.tmp";
|
||||
const INTENT_FILE: &str = "install_intent.json";
|
||||
const INTENT_TMP_FILE: &str = "install_intent.json.tmp";
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub enum InstallIntentState {
|
||||
@@ -41,18 +43,22 @@ impl InstallIntent {
|
||||
pub fn none(id: &str, eti_version: Option<String>) -> Self {
|
||||
Self::new(id, InstallIntentState::None, eti_version)
|
||||
}
|
||||
|
||||
pub fn is_current_for(&self, id: &str) -> bool {
|
||||
self.schema_version == INTENT_SCHEMA_VERSION && self.id == id
|
||||
}
|
||||
}
|
||||
|
||||
pub fn intent_path(game_root: &Path) -> PathBuf {
|
||||
game_root.join(INTENT_FILE)
|
||||
pub fn intent_path(state_dir: &Path, id: &str) -> PathBuf {
|
||||
crate::state_paths::game_state_dir(state_dir, id).join(INTENT_FILE)
|
||||
}
|
||||
|
||||
pub fn intent_tmp_path(game_root: &Path) -> PathBuf {
|
||||
game_root.join(INTENT_TMP_FILE)
|
||||
pub fn intent_tmp_path(state_dir: &Path, id: &str) -> PathBuf {
|
||||
crate::state_paths::game_state_dir(state_dir, id).join(INTENT_TMP_FILE)
|
||||
}
|
||||
|
||||
pub async fn read_intent(game_root: &Path, id: &str) -> InstallIntent {
|
||||
let path = intent_path(game_root);
|
||||
pub async fn read_intent(state_dir: &Path, id: &str) -> InstallIntent {
|
||||
let path = intent_path(state_dir, id);
|
||||
let data = match tokio::fs::read_to_string(&path).await {
|
||||
Ok(data) => data,
|
||||
Err(err) => {
|
||||
@@ -64,7 +70,7 @@ pub async fn read_intent(game_root: &Path, id: &str) -> InstallIntent {
|
||||
};
|
||||
|
||||
match serde_json::from_str::<InstallIntent>(&data) {
|
||||
Ok(intent) if intent.schema_version == INTENT_SCHEMA_VERSION && intent.id == id => intent,
|
||||
Ok(intent) if intent.is_current_for(id) => intent,
|
||||
Ok(intent) => {
|
||||
log::warn!(
|
||||
"Ignoring install intent {} with schema {} for id {}",
|
||||
@@ -81,10 +87,11 @@ pub async fn read_intent(game_root: &Path, id: &str) -> InstallIntent {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
pub async fn write_intent(state_dir: &Path, id: &str, intent: &InstallIntent) -> eyre::Result<()> {
|
||||
let game_state_dir = crate::state_paths::game_state_dir(state_dir, id);
|
||||
tokio::fs::create_dir_all(&game_state_dir).await?;
|
||||
let path = intent_path(state_dir, id);
|
||||
let tmp_path = intent_tmp_path(state_dir, id);
|
||||
let data = serde_json::to_vec_pretty(intent)?;
|
||||
|
||||
let mut file = tokio::fs::File::create(&tmp_path).await?;
|
||||
@@ -122,6 +129,18 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::TempDir;
|
||||
|
||||
async fn write_raw_intent(state_dir: &Path, id: &str, bytes: impl AsRef<[u8]>) {
|
||||
let path = intent_path(state_dir, id);
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.expect("intent parent should be created");
|
||||
}
|
||||
tokio::fs::write(path, bytes)
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tmp_write_without_rename_leaves_previous_intent_intact() {
|
||||
let temp = TempDir::new("lanspread-intent");
|
||||
@@ -130,12 +149,12 @@ mod tests {
|
||||
InstallIntentState::Updating,
|
||||
Some("20240101".to_string()),
|
||||
);
|
||||
write_intent(temp.path(), &previous)
|
||||
write_intent(temp.path(), "game", &previous)
|
||||
.await
|
||||
.expect("previous intent should be written");
|
||||
|
||||
tokio::fs::write(
|
||||
intent_tmp_path(temp.path()),
|
||||
intent_tmp_path(temp.path(), "game"),
|
||||
serde_json::to_vec(&InstallIntent::new(
|
||||
"game",
|
||||
InstallIntentState::Installing,
|
||||
@@ -154,12 +173,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn schema_mismatch_is_treated_as_missing() {
|
||||
let temp = TempDir::new("lanspread-intent");
|
||||
tokio::fs::write(
|
||||
intent_path(temp.path()),
|
||||
write_raw_intent(
|
||||
temp.path(),
|
||||
"game",
|
||||
r#"{"schema_version":2,"id":"game","recorded_at":0,"state":"Updating"}"#,
|
||||
)
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
.await;
|
||||
|
||||
let recovered = read_intent(temp.path(), "game").await;
|
||||
assert_eq!(recovered.state, InstallIntentState::None);
|
||||
@@ -168,12 +187,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn mismatched_id_is_treated_as_missing() {
|
||||
let temp = TempDir::new("lanspread-intent");
|
||||
tokio::fs::write(
|
||||
intent_path(temp.path()),
|
||||
write_raw_intent(
|
||||
temp.path(),
|
||||
"game",
|
||||
r#"{"schema_version":1,"id":"other","recorded_at":0,"state":"Updating"}"#,
|
||||
)
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
.await;
|
||||
|
||||
let recovered = read_intent(temp.path(), "game").await;
|
||||
assert_eq!(recovered.state, InstallIntentState::None);
|
||||
@@ -182,9 +201,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn corrupt_intent_is_treated_as_missing() {
|
||||
let temp = TempDir::new("lanspread-intent");
|
||||
tokio::fs::write(intent_path(temp.path()), b"not json")
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
write_raw_intent(temp.path(), "game", b"not json").await;
|
||||
|
||||
let recovered = read_intent(temp.path(), "game").await;
|
||||
assert_eq!(recovered.state, InstallIntentState::None);
|
||||
@@ -193,21 +210,21 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn old_manifest_hash_field_is_ignored_and_new_writes_omit_it() {
|
||||
let temp = TempDir::new("lanspread-intent");
|
||||
tokio::fs::write(
|
||||
intent_path(temp.path()),
|
||||
write_raw_intent(
|
||||
temp.path(),
|
||||
"game",
|
||||
r#"{"schema_version":1,"id":"game","recorded_at":0,"state":"Updating","eti_version":"20240101","manifest_hash":42}"#,
|
||||
)
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
.await;
|
||||
|
||||
let recovered = read_intent(temp.path(), "game").await;
|
||||
assert_eq!(recovered.state, InstallIntentState::Updating);
|
||||
assert_eq!(recovered.eti_version.as_deref(), Some("20240101"));
|
||||
|
||||
write_intent(temp.path(), &InstallIntent::none("game", None))
|
||||
write_intent(temp.path(), "game", &InstallIntent::none("game", None))
|
||||
.await
|
||||
.expect("intent should be written");
|
||||
let written = tokio::fs::read_to_string(intent_path(temp.path()))
|
||||
let written = tokio::fs::read_to_string(intent_path(temp.path(), "game"))
|
||||
.await
|
||||
.expect("intent should be readable");
|
||||
assert!(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
mod intent;
|
||||
pub(crate) mod intent;
|
||||
mod remove;
|
||||
mod transaction;
|
||||
pub mod unpack;
|
||||
|
||||
@@ -33,10 +33,16 @@ struct InstallFsState {
|
||||
backup: FsEntryState,
|
||||
}
|
||||
|
||||
pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
|
||||
pub async fn install(
|
||||
game_root: &Path,
|
||||
state_dir: &Path,
|
||||
id: &str,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
) -> eyre::Result<()> {
|
||||
let eti_version = read_downloaded_version(game_root).await;
|
||||
write_intent(
|
||||
game_root,
|
||||
state_dir,
|
||||
id,
|
||||
&InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()),
|
||||
)
|
||||
.await?;
|
||||
@@ -44,7 +50,7 @@ pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
|
||||
let result = install_inner(game_root, id, unpacker).await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -54,16 +60,22 @@ pub async fn install(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
|
||||
installing_dir(game_root).display()
|
||||
);
|
||||
}
|
||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
|
||||
pub async fn update(
|
||||
game_root: &Path,
|
||||
state_dir: &Path,
|
||||
id: &str,
|
||||
unpacker: Arc<dyn Unpacker>,
|
||||
) -> eyre::Result<()> {
|
||||
let eti_version = read_downloaded_version(game_root).await;
|
||||
write_intent(
|
||||
game_root,
|
||||
state_dir,
|
||||
id,
|
||||
&InstallIntent::new(id, InstallIntentState::Updating, eti_version.clone()),
|
||||
)
|
||||
.await?;
|
||||
@@ -71,7 +83,7 @@ pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
|
||||
let result = update_inner(game_root, id, unpacker).await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
||||
write_intent(state_dir, id, &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}",
|
||||
@@ -82,7 +94,7 @@ pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
|
||||
}
|
||||
Err(err) => {
|
||||
let rollback = rollback_update(game_root).await;
|
||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||
if let Err(rollback_err) = rollback {
|
||||
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
|
||||
}
|
||||
@@ -91,10 +103,11 @@ pub async fn update(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) ->
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
|
||||
pub async fn uninstall(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Result<()> {
|
||||
let eti_version = read_downloaded_version(game_root).await;
|
||||
write_intent(
|
||||
game_root,
|
||||
state_dir,
|
||||
id,
|
||||
&InstallIntent::new(id, InstallIntentState::Uninstalling, eti_version.clone()),
|
||||
)
|
||||
.await?;
|
||||
@@ -102,7 +115,7 @@ pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
|
||||
let result = uninstall_inner(game_root).await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
write_intent(game_root, &InstallIntent::none(id, eti_version)).await?;
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -110,13 +123,17 @@ pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
|
||||
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?;
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn recover_on_startup(game_dir: &Path, active_ids: &HashSet<String>) -> eyre::Result<()> {
|
||||
pub async fn recover_on_startup(
|
||||
game_dir: &Path,
|
||||
state_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 {
|
||||
@@ -141,22 +158,28 @@ pub async fn recover_on_startup(game_dir: &Path, active_ids: &HashSet<String>) -
|
||||
continue;
|
||||
}
|
||||
|
||||
recover_game_root(&entry.path(), &id).await?;
|
||||
recover_game_root(&entry.path(), state_dir, &id).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn recover_game_root(game_root: &Path, id: &str) -> eyre::Result<()> {
|
||||
pub async fn recover_game_root(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Result<()> {
|
||||
recover_download_transients(game_root).await?;
|
||||
|
||||
let intent = read_intent(game_root, id).await;
|
||||
let intent = read_intent(state_dir, 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?,
|
||||
InstallIntentState::Installing => {
|
||||
recover_installing(game_root, state_dir, id, intent, fs).await?;
|
||||
}
|
||||
InstallIntentState::Updating => {
|
||||
recover_updating(game_root, state_dir, id, intent, fs).await?;
|
||||
}
|
||||
InstallIntentState::Uninstalling => {
|
||||
recover_uninstalling(game_root, state_dir, id, intent, fs).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -257,6 +280,7 @@ async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
|
||||
|
||||
async fn recover_installing(
|
||||
game_root: &Path,
|
||||
state_dir: &Path,
|
||||
id: &str,
|
||||
intent: InstallIntent,
|
||||
fs: InstallFsState,
|
||||
@@ -268,11 +292,12 @@ async fn recover_installing(
|
||||
{
|
||||
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
|
||||
}
|
||||
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
|
||||
}
|
||||
|
||||
async fn recover_updating(
|
||||
game_root: &Path,
|
||||
state_dir: &Path,
|
||||
id: &str,
|
||||
intent: InstallIntent,
|
||||
fs: InstallFsState,
|
||||
@@ -301,11 +326,12 @@ async fn recover_updating(
|
||||
} => remove_dir_all_if_exists(&backup_dir(game_root)).await?,
|
||||
_ => {}
|
||||
}
|
||||
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
|
||||
}
|
||||
|
||||
async fn recover_uninstalling(
|
||||
game_root: &Path,
|
||||
state_dir: &Path,
|
||||
id: &str,
|
||||
intent: InstallIntent,
|
||||
fs: InstallFsState,
|
||||
@@ -323,7 +349,7 @@ async fn recover_uninstalling(
|
||||
} => uninstall_inner(game_root).await?,
|
||||
_ => {}
|
||||
}
|
||||
write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await
|
||||
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
|
||||
}
|
||||
|
||||
async fn recover_download_transients(root: &Path) -> eyre::Result<()> {
|
||||
@@ -416,6 +442,10 @@ async fn restore_backup(game_root: &Path) -> eyre::Result<()> {
|
||||
}
|
||||
|
||||
async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> {
|
||||
if !path_exists(path).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match tokio::fs::remove_file(path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
|
||||
@@ -437,6 +467,10 @@ async fn path_is_dir(path: &Path) -> bool {
|
||||
.is_ok_and(|metadata| metadata.is_dir())
|
||||
}
|
||||
|
||||
async fn path_exists(path: &Path) -> bool {
|
||||
tokio::fs::metadata(path).await.is_ok()
|
||||
}
|
||||
|
||||
fn local_dir(game_root: &Path) -> PathBuf {
|
||||
game_root.join(LOCAL_DIR)
|
||||
}
|
||||
@@ -530,33 +564,39 @@ mod tests {
|
||||
Arc::new(FakeUnpacker::default())
|
||||
}
|
||||
|
||||
fn test_state() -> TempDir {
|
||||
TempDir::new("lanspread-install-state")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_success_promotes_staging_and_clears_intent() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
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())
|
||||
install(&root, state.path(), "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;
|
||||
let intent = read_intent(state.path(), "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("lanspread-install");
|
||||
let state = test_state();
|
||||
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())
|
||||
install(&root, state.path(), "game", unpacker.clone())
|
||||
.await
|
||||
.expect("install should succeed");
|
||||
|
||||
@@ -573,34 +613,46 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn update_failure_restores_previous_local() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
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");
|
||||
let err = update(
|
||||
&root,
|
||||
state.path(),
|
||||
"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;
|
||||
let intent = read_intent(state.path(), "game").await;
|
||||
assert_eq!(intent.state, InstallIntentState::None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_commit_rename_failure_restores_previous_local() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
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::commit_conflict()))
|
||||
.await
|
||||
.expect_err("update should fail at commit rename");
|
||||
let err = update(
|
||||
&root,
|
||||
state.path(),
|
||||
"game",
|
||||
Arc::new(FakeUnpacker::commit_conflict()),
|
||||
)
|
||||
.await
|
||||
.expect_err("update should fail at commit rename");
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("failed to promote update"),
|
||||
@@ -614,19 +666,20 @@ mod tests {
|
||||
assert!(!root.join("local").join("conflict.txt").exists());
|
||||
assert!(!root.join(".local.installing").exists());
|
||||
assert!(!root.join(".local.backup").exists());
|
||||
let intent = read_intent(&root, "game").await;
|
||||
let intent = read_intent(state.path(), "game").await;
|
||||
assert_eq!(intent.state, InstallIntentState::None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_success_promotes_new_local_and_removes_backup() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
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())
|
||||
update(&root, state.path(), "game", successful_unpacker())
|
||||
.await
|
||||
.expect("update should succeed");
|
||||
|
||||
@@ -634,19 +687,20 @@ mod tests {
|
||||
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;
|
||||
let intent = read_intent(state.path(), "game").await;
|
||||
assert_eq!(intent.state, InstallIntentState::None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninstall_removes_only_local_install() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
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")
|
||||
uninstall(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("uninstall should succeed");
|
||||
|
||||
@@ -661,6 +715,7 @@ mod tests {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
let root = temp.game_root();
|
||||
let locked_dir = root.join("local").join("locked");
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
@@ -669,7 +724,7 @@ mod tests {
|
||||
std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o500))
|
||||
.expect("locked dir permissions should be set");
|
||||
|
||||
let _err = uninstall(&root, "game")
|
||||
let _err = uninstall(&root, state.path(), "game")
|
||||
.await
|
||||
.expect_err("uninstall should fail while deleting backup");
|
||||
|
||||
@@ -697,7 +752,7 @@ mod tests {
|
||||
b"locked"
|
||||
);
|
||||
assert!(!root.join(".local.backup").exists());
|
||||
let intent = read_intent(&root, "game").await;
|
||||
let intent = read_intent(state.path(), "game").await;
|
||||
assert_eq!(intent.state, InstallIntentState::None);
|
||||
}
|
||||
|
||||
@@ -844,23 +899,25 @@ mod tests {
|
||||
},
|
||||
];
|
||||
|
||||
let state = test_state();
|
||||
for case in cases {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let root = temp.game_root();
|
||||
seed_recovery_case(&root, &case);
|
||||
write_intent(
|
||||
&root,
|
||||
state.path(),
|
||||
"game",
|
||||
&InstallIntent::new("game", case.intent_state.clone(), Some("20250101".into())),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("{} intent should be written: {err}", case.name));
|
||||
|
||||
recover_game_root(&root, "game")
|
||||
recover_game_root(&root, state.path(), "game")
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("{} recovery should succeed: {err}", case.name));
|
||||
|
||||
assert_recovered_case(&root, &case);
|
||||
let intent = read_intent(&root, "game").await;
|
||||
let intent = read_intent(state.path(), "game").await;
|
||||
assert_eq!(intent.state, InstallIntentState::None, "{}", case.name);
|
||||
assert_eq!(
|
||||
intent.eti_version.as_deref(),
|
||||
@@ -874,10 +931,11 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn none_recovery_leaves_markerless_reserved_dirs_untouched() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join(".local.backup").join("user.txt"), b"user");
|
||||
|
||||
recover_game_root(&root, "game")
|
||||
recover_game_root(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("recovery should succeed");
|
||||
|
||||
@@ -887,11 +945,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn download_recovery_sweeps_reserved_version_files() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
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")
|
||||
recover_game_root(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("recovery should succeed");
|
||||
|
||||
@@ -902,14 +961,19 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn startup_recovery_skips_active_game_roots() {
|
||||
let temp = TempDir::new("lanspread-install");
|
||||
let state = test_state();
|
||||
let active_root = temp.path().join("active");
|
||||
let inactive_root = temp.path().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.path(), &HashSet::from(["active".to_string()]))
|
||||
.await
|
||||
.expect("recovery should succeed");
|
||||
recover_on_startup(
|
||||
temp.path(),
|
||||
state.path(),
|
||||
&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());
|
||||
|
||||
Reference in New Issue
Block a user