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:
2026-05-21 17:00:15 +02:00
parent 4f34c4a249
commit 9835e77e8d
23 changed files with 1052 additions and 225 deletions
@@ -43,25 +43,14 @@ pub async fn download_game_files(
eyre::bail!("download cancelled for game {game_id}");
}
let (version_desc, transfer_descs) =
extract_version_descriptor(game_id, game_file_descs, &tx_notify_ui)?;
let (version_desc, transfer_descs) = extract_version_descriptor(game_id, game_file_descs)?;
let version_buffer = match VersionIniBuffer::new(&version_desc) {
Ok(buffer) => Arc::new(buffer),
Err(err) => {
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
})?;
return Err(err);
}
Err(err) => return Err(err),
};
let game_root = games_folder.join(game_id);
if let Err(err) = begin_version_ini_transaction(&game_root).await {
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
})?;
return Err(err);
}
begin_version_ini_transaction(&game_root).await?;
if cancel_token.is_cancelled() {
rollback_version_ini_transaction(&game_root).await;
discard_cancelled_download_best_effort(&games_folder, game_id).await;
@@ -73,9 +62,6 @@ pub async fn download_game_files(
discard_cancelled_download_best_effort(&games_folder, game_id).await;
eyre::bail!("download cancelled for game {game_id}");
}
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
})?;
return Err(err);
}
if cancel_token.is_cancelled() {
@@ -111,10 +97,6 @@ pub async fn download_game_files(
rollback_version_ini_transaction(&game_root).await;
if cancel_token.is_cancelled() {
discard_cancelled_download_best_effort(&games_folder, game_id).await;
} else {
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
})?;
}
return Err(err);
}
@@ -127,15 +109,9 @@ pub async fn download_game_files(
if let Err(err) = commit_version_ini_buffer(&game_root, &version_buffer).await {
rollback_version_ini_transaction(&game_root).await;
tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
})?;
return Err(err);
}
log::info!("all files downloaded for game: {game_id}");
tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished {
id: game_id.to_string(),
})?;
Ok(())
}
+4 -12
View File
@@ -1,9 +1,8 @@
use std::{collections::HashMap, net::SocketAddr};
use lanspread_db::db::GameFileDescription;
use tokio::sync::mpsc::UnboundedSender;
use crate::{PeerEvent, config::CHUNK_SIZE};
use crate::config::CHUNK_SIZE;
/// Represents a chunk of a file to be downloaded.
#[derive(Debug, Clone)]
@@ -34,7 +33,6 @@ pub(super) struct ChunkDownloadResult {
pub(super) fn extract_version_descriptor(
game_id: &str,
game_file_descs: Vec<GameFileDescription>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
) -> eyre::Result<(GameFileDescription, Vec<GameFileDescription>)> {
let mut version_descs = Vec::new();
let mut transfer_descs = Vec::new();
@@ -47,9 +45,6 @@ pub(super) fn extract_version_descriptor(
}
if version_descs.len() != 1 {
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed {
id: game_id.to_string(),
});
eyre::bail!(
"expected exactly one root-level version.ini sentinel for {game_id}, found {}",
version_descs.len()
@@ -296,7 +291,6 @@ mod tests {
#[test]
fn version_descriptor_extraction_keeps_nested_decoy_in_transfer_list() {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let nested_decoy = vec![
GameFileDescription {
game_id: "game".to_string(),
@@ -313,26 +307,24 @@ mod tests {
];
let (version, transfer) =
extract_version_descriptor("game", nested_decoy, &tx).expect("only one root sentinel");
extract_version_descriptor("game", nested_decoy).expect("only one root sentinel");
assert_eq!(version.relative_path, "game/version.ini");
assert_eq!(transfer.len(), 2);
}
#[test]
fn version_descriptor_extraction_requires_a_root_version_ini() {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let missing = vec![GameFileDescription {
game_id: "game".to_string(),
relative_path: "game/archive.eti".to_string(),
is_dir: false,
size: 1,
}];
assert!(extract_version_descriptor("game", missing, &tx).is_err());
assert!(extract_version_descriptor("game", missing).is_err());
}
#[test]
fn version_descriptor_extraction_rejects_duplicate_root_version_ini() {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let multiple = vec![
GameFileDescription {
game_id: "game".to_string(),
@@ -347,6 +339,6 @@ mod tests {
size: 8,
},
];
assert!(extract_version_descriptor("game", multiple, &tx).is_err());
assert!(extract_version_descriptor("game", multiple).is_err());
}
}
@@ -5,8 +5,6 @@ use tokio::fs::OpenOptions;
use crate::{local_games::is_local_dir_name, path_validation::validate_game_file_path};
const INTENT_LOG_FILE: &str = ".lanspread.json";
const SOFTLAN_INSTALL_MARKER: &str = ".softlan_game_installed";
const SYNC_DIR: &str = ".sync";
/// Prepares storage for game files by creating directories and pre-allocating files.
@@ -99,11 +97,7 @@ pub(super) async fn discard_cancelled_download(
}
fn should_preserve_on_download_discard(name: &str) -> bool {
is_local_dir_name(name)
|| name.starts_with(".local.")
|| name == INTENT_LOG_FILE
|| name == SOFTLAN_INSTALL_MARKER
|| name == SYNC_DIR
is_local_dir_name(name) || name.starts_with(".local.") || name == SYNC_DIR
}
async fn remove_entry(path: &Path) -> eyre::Result<()> {
@@ -207,7 +201,6 @@ mod tests {
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(".lanspread.json"), b"{\"intent\":\"None\"}");
write_file(&root.join(".local.backup").join(".lanspread_owned"), b"");
discard_cancelled_download(temp.path(), "game")
@@ -221,7 +214,6 @@ mod tests {
.expect("local install should remain"),
b"user-data"
);
assert!(root.join(".lanspread.json").is_file());
assert!(root.join(".local.backup").is_dir());
}
}