Files
lanspread/crates/lanspread-peer/src/migration.rs
T
ddidderr e8e7d7a93e 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
2026-05-21 17:04:00 +02:00

613 lines
20 KiB
Rust

use std::{
io::ErrorKind,
path::{Path, PathBuf},
time::Instant,
};
use futures::{StreamExt as _, stream};
use tokio::io::AsyncWriteExt as _;
use crate::{
install::intent::{
InstallIntent,
LEGACY_INTENT_FILE,
LEGACY_INTENT_TMP_FILE,
intent_path,
write_intent,
},
local_games::{is_ignored_game_root_name, legacy_library_index_path},
state_paths::{local_library_index_path, setup_done_path},
};
const LEGACY_LIBRARY_INDEX_DIR: &str = ".lanspread";
const LEGACY_FIRST_START_DONE_FILE: &str = ".softlan_first_start_done";
const LEGACY_SOFTLAN_INSTALL_MARKER: &str = ".softlan_game_installed";
const MIGRATION_CONCURRENCY: usize = 16;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize)]
pub struct MigrationReport {
pub games_checked: usize,
pub library_index_migrated: bool,
pub install_intents_migrated: usize,
pub setup_markers_migrated: usize,
pub legacy_files_deleted: usize,
pub unknown_softlan_files: usize,
pub failures: usize,
}
impl MigrationReport {
fn merge(&mut self, other: Self) {
self.games_checked += other.games_checked;
self.library_index_migrated |= other.library_index_migrated;
self.install_intents_migrated += other.install_intents_migrated;
self.setup_markers_migrated += other.setup_markers_migrated;
self.legacy_files_deleted += other.legacy_files_deleted;
self.unknown_softlan_files += other.unknown_softlan_files;
self.failures += other.failures;
}
}
/// Migrates legacy app-owned files out of the configured game directory.
///
/// This is intentionally separate from normal operation: callers should run it
/// before starting the peer runtime for a game directory.
pub async fn migrate_legacy_state(game_dir: &Path, state_dir: &Path) -> MigrationReport {
let started = Instant::now();
let mut report = MigrationReport::default();
report.merge(migrate_library_index(game_dir, state_dir).await);
let game_roots = match collect_game_roots(game_dir).await {
Ok(game_roots) => game_roots,
Err(err) => {
if err.kind() != ErrorKind::NotFound {
log::warn!(
"Failed to enumerate game roots for legacy state migration in {}: {err}",
game_dir.display()
);
report.failures += 1;
}
log_migration_report(&report, started);
return report;
}
};
let game_reports = stream::iter(game_roots)
.map(|(id, root)| async move { migrate_game_root(state_dir, id, root).await })
.buffer_unordered(MIGRATION_CONCURRENCY)
.collect::<Vec<_>>()
.await;
for game_report in game_reports {
report.merge(game_report);
}
log_migration_report(&report, started);
report
}
async fn collect_game_roots(game_dir: &Path) -> std::io::Result<Vec<(String, PathBuf)>> {
let mut roots = Vec::new();
let mut entries = tokio::fs::read_dir(game_dir).await?;
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 is_ignored_game_root_name(&id) {
continue;
}
roots.push((id, entry.path()));
}
Ok(roots)
}
async fn migrate_library_index(game_dir: &Path, state_dir: &Path) -> MigrationReport {
let mut report = MigrationReport::default();
let legacy_path = legacy_library_index_path(game_dir);
let target_path = local_library_index_path(state_dir);
match migrate_raw_file(&legacy_path, &target_path).await {
Ok(MigrationOutcome::Migrated) => {
report.library_index_migrated = true;
report.legacy_files_deleted += 1;
}
Ok(MigrationOutcome::TargetAlreadyExists) => {
report.legacy_files_deleted += 1;
}
Ok(MigrationOutcome::SourceMissing) => {}
Err(err) => {
log::warn!(
"Failed to migrate legacy library index {} to {}: {err}",
legacy_path.display(),
target_path.display()
);
report.failures += 1;
}
}
report.merge(delete_if_exists(&library_index_tmp_path(&legacy_path)).await);
report.merge(remove_empty_legacy_library_dir(game_dir).await);
report
}
async fn migrate_game_root(state_dir: &Path, id: String, root: PathBuf) -> MigrationReport {
let mut report = MigrationReport {
games_checked: 1,
..MigrationReport::default()
};
report.merge(migrate_install_intent(state_dir, &id, &root).await);
report.merge(delete_if_exists(&root.join(LEGACY_INTENT_TMP_FILE)).await);
report.merge(migrate_setup_marker(state_dir, &id, &root).await);
report.merge(delete_if_exists(&root.join(LEGACY_SOFTLAN_INSTALL_MARKER)).await);
report.merge(note_unknown_softlan_files(&root).await);
report
}
async fn migrate_install_intent(state_dir: &Path, id: &str, root: &Path) -> MigrationReport {
let mut report = MigrationReport::default();
let legacy_path = root.join(LEGACY_INTENT_FILE);
let target_path = intent_path(state_dir, id);
match path_exists(&legacy_path).await {
Ok(false) => return report,
Ok(true) => {}
Err(err) => {
log::warn!(
"Failed to inspect legacy install intent {}: {err}",
legacy_path.display()
);
report.failures += 1;
return report;
}
}
match path_exists(&target_path).await {
Ok(true) => {
report.merge(delete_file(&legacy_path).await);
return report;
}
Ok(false) => {}
Err(err) => {
log::warn!(
"Failed to inspect app-state install intent {}: {err}",
target_path.display()
);
report.failures += 1;
return report;
}
}
let data = match tokio::fs::read_to_string(&legacy_path).await {
Ok(data) => data,
Err(err) => {
log::warn!(
"Failed to read legacy install intent {}: {err}",
legacy_path.display()
);
report.failures += 1;
return report;
}
};
let intent = match serde_json::from_str::<InstallIntent>(&data) {
Ok(intent) if intent.is_current_for(id) => intent,
Ok(intent) => {
log::warn!(
"Leaving legacy install intent {} in place because it belongs to id {} schema {}",
legacy_path.display(),
intent.id,
intent.schema_version
);
report.failures += 1;
return report;
}
Err(err) => {
log::warn!(
"Leaving corrupt legacy install intent {} in place: {err}",
legacy_path.display()
);
report.failures += 1;
return report;
}
};
if let Err(err) = write_intent(state_dir, id, &intent).await {
log::warn!(
"Failed to write migrated install intent {}: {err}",
target_path.display()
);
report.failures += 1;
return report;
}
report.install_intents_migrated += 1;
report.merge(delete_file(&legacy_path).await);
report
}
async fn migrate_setup_marker(state_dir: &Path, id: &str, root: &Path) -> MigrationReport {
let mut report = MigrationReport::default();
let legacy_path = root.join("local").join(LEGACY_FIRST_START_DONE_FILE);
let target_path = setup_done_path(state_dir, id);
match migrate_empty_marker(&legacy_path, &target_path).await {
Ok(MigrationOutcome::Migrated) => {
report.setup_markers_migrated += 1;
report.legacy_files_deleted += 1;
}
Ok(MigrationOutcome::TargetAlreadyExists) => {
report.legacy_files_deleted += 1;
}
Ok(MigrationOutcome::SourceMissing) => {}
Err(err) => {
log::warn!(
"Failed to migrate legacy setup marker {} to {}: {err}",
legacy_path.display(),
target_path.display()
);
report.failures += 1;
}
}
report
}
async fn note_unknown_softlan_files(root: &Path) -> MigrationReport {
let mut report = MigrationReport::default();
report.unknown_softlan_files += count_unknown_softlan_files(root).await;
report.unknown_softlan_files += count_unknown_softlan_files(&root.join("local")).await;
report
}
async fn count_unknown_softlan_files(dir: &Path) -> usize {
let mut count = 0;
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => return 0,
Err(err) => {
log::warn!(
"Failed to inspect {} for legacy .softlan files: {err}",
dir.display()
);
return 0;
}
};
while let Ok(Some(entry)) = entries.next_entry().await {
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
continue;
};
if !name.starts_with(".softlan_")
|| name == LEGACY_SOFTLAN_INSTALL_MARKER
|| name == LEGACY_FIRST_START_DONE_FILE
{
continue;
}
count += 1;
log::info!(
"Leaving unknown legacy .softlan file in place: {}",
entry.path().display()
);
}
count
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum MigrationOutcome {
SourceMissing,
TargetAlreadyExists,
Migrated,
}
async fn migrate_raw_file(
legacy_path: &Path,
target_path: &Path,
) -> std::io::Result<MigrationOutcome> {
if !path_exists(legacy_path).await? {
return Ok(MigrationOutcome::SourceMissing);
}
if path_exists(target_path).await? {
remove_file_if_exists(legacy_path).await?;
return Ok(MigrationOutcome::TargetAlreadyExists);
}
let data = tokio::fs::read(legacy_path).await?;
write_bytes_atomically(target_path, &data).await?;
remove_file_if_exists(legacy_path).await?;
Ok(MigrationOutcome::Migrated)
}
async fn migrate_empty_marker(
legacy_path: &Path,
target_path: &Path,
) -> std::io::Result<MigrationOutcome> {
if !path_exists(legacy_path).await? {
return Ok(MigrationOutcome::SourceMissing);
}
if path_exists(target_path).await? {
remove_file_if_exists(legacy_path).await?;
return Ok(MigrationOutcome::TargetAlreadyExists);
}
if let Some(parent) = target_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::File::create(target_path)
.await?
.sync_all()
.await?;
remove_file_if_exists(legacy_path).await?;
Ok(MigrationOutcome::Migrated)
}
async fn write_bytes_atomically(path: &Path, data: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp_path = library_index_tmp_path(path);
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)
}
fn library_index_tmp_path(path: &Path) -> PathBuf {
let Some(file_name) = path.file_name() else {
return path.with_extension("tmp");
};
let mut tmp_name = file_name.to_os_string();
tmp_name.push(".tmp");
path.with_file_name(tmp_name)
}
async fn path_exists(path: &Path) -> std::io::Result<bool> {
match tokio::fs::metadata(path).await {
Ok(_) => Ok(true),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
Err(err) => Err(err),
}
}
async fn delete_if_exists(path: &Path) -> MigrationReport {
match remove_file_if_exists(path).await {
Ok(true) => MigrationReport {
legacy_files_deleted: 1,
..MigrationReport::default()
},
Ok(false) => MigrationReport::default(),
Err(err) => {
log::warn!("Failed to delete legacy file {}: {err}", path.display());
MigrationReport {
failures: 1,
..MigrationReport::default()
}
}
}
}
async fn delete_file(path: &Path) -> MigrationReport {
match remove_file_if_exists(path).await {
Ok(true) => MigrationReport {
legacy_files_deleted: 1,
..MigrationReport::default()
},
Ok(false) => MigrationReport::default(),
Err(err) => {
log::warn!("Failed to delete legacy file {}: {err}", path.display());
MigrationReport {
failures: 1,
..MigrationReport::default()
}
}
}
}
async fn remove_file_if_exists(path: &Path) -> std::io::Result<bool> {
if !path_exists(path).await? {
return Ok(false);
}
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(true),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
Err(err) => Err(err),
}
}
async fn remove_empty_legacy_library_dir(game_dir: &Path) -> MigrationReport {
let path = game_dir.join(LEGACY_LIBRARY_INDEX_DIR);
let exists = match path_exists(&path).await {
Ok(exists) => exists,
Err(err) => {
log::warn!(
"Failed to inspect legacy library index directory {}: {err}",
path.display()
);
return MigrationReport {
failures: 1,
..MigrationReport::default()
};
}
};
if !exists {
return MigrationReport::default();
}
match tokio::fs::remove_dir(&path).await {
Ok(()) => MigrationReport {
legacy_files_deleted: 1,
..MigrationReport::default()
},
Err(err)
if err.kind() == ErrorKind::NotFound || err.kind() == ErrorKind::DirectoryNotEmpty =>
{
MigrationReport::default()
}
Err(err) => {
log::warn!(
"Failed to remove empty legacy library index directory {}: {err}",
path.display()
);
MigrationReport {
failures: 1,
..MigrationReport::default()
}
}
}
}
fn log_migration_report(report: &MigrationReport, started: Instant) {
log::info!(
"Legacy state migration finished in {:?}: games_checked={}, library_index_migrated={}, \
install_intents_migrated={}, setup_markers_migrated={}, legacy_files_deleted={}, \
unknown_softlan_files={}, failures={}",
started.elapsed(),
report.games_checked,
report.library_index_migrated,
report.install_intents_migrated,
report.setup_markers_migrated,
report.legacy_files_deleted,
report.unknown_softlan_files,
report.failures
);
}
#[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 super::*;
use crate::{
install::intent::{InstallIntentState, read_intent},
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 migrates_legacy_library_index_to_app_state() {
let games = TempDir::new("lanspread-migration-games");
let state = TempDir::new("lanspread-migration-state");
let legacy_path = legacy_library_index_path(games.path());
let target_path = local_library_index_path(state.path());
let legacy_tmp_path = library_index_tmp_path(&legacy_path);
write_file(&legacy_path, br#"{"revision":7,"games":{}}"#);
write_file(&legacy_tmp_path, b"tmp");
let report = migrate_legacy_state(games.path(), state.path()).await;
assert!(report.library_index_migrated);
assert_eq!(
std::fs::read_to_string(&target_path).expect("index should migrate"),
r#"{"revision":7,"games":{}}"#
);
assert!(!legacy_path.exists());
assert!(!legacy_tmp_path.exists());
assert!(!games.path().join(LEGACY_LIBRARY_INDEX_DIR).exists());
}
#[tokio::test]
async fn migrates_per_game_intent_and_setup_marker() {
let games = TempDir::new("lanspread-migration-games");
let state = TempDir::new("lanspread-migration-state");
let root = games.path().join("game");
let intent = InstallIntent::new(
"game",
InstallIntentState::Updating,
Some("20250101".to_string()),
);
let legacy_intent = root.join(LEGACY_INTENT_FILE);
let legacy_tmp = root.join(LEGACY_INTENT_TMP_FILE);
let legacy_setup = root.join("local").join(LEGACY_FIRST_START_DONE_FILE);
let legacy_marker = root.join(LEGACY_SOFTLAN_INSTALL_MARKER);
write_file(
&legacy_intent,
&serde_json::to_vec_pretty(&intent).expect("intent should serialize"),
);
write_file(&legacy_tmp, b"tmp");
write_file(&legacy_setup, b"");
write_file(&legacy_marker, b"");
let report = migrate_legacy_state(games.path(), state.path()).await;
assert_eq!(report.install_intents_migrated, 1);
assert_eq!(report.setup_markers_migrated, 1);
let migrated_intent = read_intent(state.path(), "game").await;
assert_eq!(migrated_intent.state, InstallIntentState::Updating);
assert_eq!(migrated_intent.eti_version.as_deref(), Some("20250101"));
assert!(setup_done_path(state.path(), "game").is_file());
assert!(!legacy_intent.exists());
assert!(!legacy_tmp.exists());
assert!(!legacy_setup.exists());
assert!(!legacy_marker.exists());
}
#[tokio::test]
async fn app_state_wins_over_legacy_per_game_state() {
let games = TempDir::new("lanspread-migration-games");
let state = TempDir::new("lanspread-migration-state");
let root = games.path().join("game");
let app_intent = InstallIntent::none("game", Some("app".to_string()));
let legacy_intent = InstallIntent::new(
"game",
InstallIntentState::Installing,
Some("legacy".to_string()),
);
let legacy_intent_path = root.join(LEGACY_INTENT_FILE);
let legacy_setup = root.join("local").join(LEGACY_FIRST_START_DONE_FILE);
write_intent(state.path(), "game", &app_intent)
.await
.expect("app-state intent should be written");
write_file(
&legacy_intent_path,
&serde_json::to_vec_pretty(&legacy_intent).expect("intent should serialize"),
);
write_file(&setup_done_path(state.path(), "game"), b"");
write_file(&legacy_setup, b"");
let report = migrate_legacy_state(games.path(), state.path()).await;
assert_eq!(report.install_intents_migrated, 0);
assert_eq!(report.setup_markers_migrated, 0);
let intent = read_intent(state.path(), "game").await;
assert_eq!(intent.state, InstallIntentState::None);
assert_eq!(intent.eti_version.as_deref(), Some("app"));
assert!(!legacy_intent_path.exists());
assert!(!legacy_setup.exists());
}
}