e8e7d7a93e
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
220 lines
6.9 KiB
Rust
220 lines
6.9 KiB
Rust
use std::{io::ErrorKind, path::Path};
|
|
|
|
use lanspread_db::db::GameFileDescription;
|
|
use tokio::fs::OpenOptions;
|
|
|
|
use crate::{local_games::is_local_dir_name, path_validation::validate_game_file_path};
|
|
|
|
const SYNC_DIR: &str = ".sync";
|
|
|
|
/// Prepares storage for game files by creating directories and pre-allocating files.
|
|
pub(super) async fn prepare_game_storage(
|
|
games_folder: &Path,
|
|
file_descs: &[GameFileDescription],
|
|
) -> eyre::Result<()> {
|
|
for desc in file_descs {
|
|
if desc.is_version_ini() {
|
|
continue;
|
|
}
|
|
|
|
let validated_path = validate_game_file_path(games_folder, &desc.relative_path)?;
|
|
|
|
if desc.is_dir {
|
|
tokio::fs::create_dir_all(&validated_path).await?;
|
|
} else {
|
|
if let Some(parent) = validated_path.parent() {
|
|
tokio::fs::create_dir_all(parent).await?;
|
|
}
|
|
|
|
let file = OpenOptions::new()
|
|
.create(true)
|
|
.truncate(true)
|
|
.write(true)
|
|
.open(&validated_path)
|
|
.await?;
|
|
|
|
let size = desc.size;
|
|
if let Err(e) = file.set_len(size).await {
|
|
log::warn!(
|
|
"Failed to pre-allocate file {} (size: {}): {}",
|
|
desc.relative_path,
|
|
size,
|
|
e
|
|
);
|
|
} else {
|
|
log::debug!(
|
|
"Pre-allocated file {} with {} bytes",
|
|
desc.relative_path,
|
|
size
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Discards the peer-owned downloaded payload after a cancelled transfer.
|
|
///
|
|
/// Downloads own the root archive/cache files, but not `local/` or install
|
|
/// transaction metadata. Preserving those paths lets a cancelled update settle
|
|
/// as a local-only install instead of deleting user-owned extracted files.
|
|
pub(super) async fn discard_cancelled_download(
|
|
games_folder: &Path,
|
|
game_id: &str,
|
|
) -> eyre::Result<()> {
|
|
let game_root = games_folder.join(game_id);
|
|
let Some(metadata) = symlink_metadata_if_exists(&game_root).await? else {
|
|
return Ok(());
|
|
};
|
|
|
|
if metadata.file_type().is_symlink() {
|
|
eyre::bail!(
|
|
"refusing to discard cancelled download through symlink root {}",
|
|
game_root.display()
|
|
);
|
|
}
|
|
if !metadata.is_dir() {
|
|
eyre::bail!(
|
|
"refusing to discard cancelled download from non-directory root {}",
|
|
game_root.display()
|
|
);
|
|
}
|
|
|
|
let mut entries = tokio::fs::read_dir(&game_root).await?;
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
let name = entry.file_name();
|
|
if name
|
|
.to_str()
|
|
.is_some_and(should_preserve_on_download_discard)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
remove_entry(&entry.path()).await?;
|
|
}
|
|
|
|
remove_dir_if_empty(&game_root).await
|
|
}
|
|
|
|
fn should_preserve_on_download_discard(name: &str) -> bool {
|
|
is_local_dir_name(name) || name.starts_with(".local.") || name == SYNC_DIR
|
|
}
|
|
|
|
async fn remove_entry(path: &Path) -> eyre::Result<()> {
|
|
let Some(metadata) = symlink_metadata_if_exists(path).await? else {
|
|
return Ok(());
|
|
};
|
|
|
|
if metadata.file_type().is_symlink() || metadata.is_file() {
|
|
remove_file_if_exists(path).await
|
|
} else if metadata.is_dir() {
|
|
tokio::fs::remove_dir_all(path).await?;
|
|
Ok(())
|
|
} else {
|
|
remove_file_if_exists(path).await
|
|
}
|
|
}
|
|
|
|
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_if_empty(path: &Path) -> eyre::Result<()> {
|
|
match tokio::fs::remove_dir(path).await {
|
|
Ok(()) => Ok(()),
|
|
Err(err)
|
|
if matches!(
|
|
err.kind(),
|
|
ErrorKind::NotFound | ErrorKind::DirectoryNotEmpty
|
|
) =>
|
|
{
|
|
Ok(())
|
|
}
|
|
Err(err) => Err(err.into()),
|
|
}
|
|
}
|
|
|
|
async fn symlink_metadata_if_exists(path: &Path) -> eyre::Result<Option<std::fs::Metadata>> {
|
|
match tokio::fs::symlink_metadata(path).await {
|
|
Ok(metadata) => Ok(Some(metadata)),
|
|
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
|
|
Err(err) => Err(err.into()),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use lanspread_db::db::GameFileDescription;
|
|
|
|
use super::*;
|
|
use crate::test_support::TempDir;
|
|
|
|
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");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prepare_game_storage_skips_version_ini_sentinel() {
|
|
let temp = TempDir::new("lanspread-download");
|
|
let descs = vec![GameFileDescription {
|
|
game_id: "game".to_string(),
|
|
relative_path: "game/version.ini".to_string(),
|
|
is_dir: false,
|
|
size: 8,
|
|
}];
|
|
|
|
prepare_game_storage(temp.path(), &descs)
|
|
.await
|
|
.expect("storage preparation should succeed");
|
|
|
|
assert!(!temp.path().join("game").join("version.ini").exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discard_cancelled_download_removes_peer_owned_payload() {
|
|
let temp = TempDir::new("lanspread-download-discard");
|
|
let root = temp.game_root();
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join(".version.ini.tmp"), b"tmp");
|
|
write_file(&root.join(".version.ini.discarded"), b"old");
|
|
write_file(&root.join("archive.eti"), b"partial");
|
|
write_file(&root.join("nested").join("payload.bin"), b"partial");
|
|
|
|
discard_cancelled_download(temp.path(), "game")
|
|
.await
|
|
.expect("cancelled payload should be discarded");
|
|
|
|
assert!(!root.exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discard_cancelled_download_preserves_local_install_state() {
|
|
let temp = TempDir::new("lanspread-download-discard-local");
|
|
let root = temp.game_root();
|
|
write_file(&root.join("version.ini"), b"20250101");
|
|
write_file(&root.join("archive.eti"), b"partial");
|
|
write_file(&root.join("local").join("save.dat"), b"user-data");
|
|
write_file(&root.join(".local.backup").join(".lanspread_owned"), b"");
|
|
|
|
discard_cancelled_download(temp.path(), "game")
|
|
.await
|
|
.expect("cancelled payload should be discarded");
|
|
|
|
assert!(!root.join("version.ini").exists());
|
|
assert!(!root.join("archive.eti").exists());
|
|
assert_eq!(
|
|
std::fs::read(root.join("local").join("save.dat"))
|
|
.expect("local install should remain"),
|
|
b"user-data"
|
|
);
|
|
assert!(root.join(".local.backup").is_dir());
|
|
}
|
|
}
|