diff --git a/IMPL_DECISIONS.md b/IMPL_DECISIONS.md index 6dfc546..42119ea 100644 --- a/IMPL_DECISIONS.md +++ b/IMPL_DECISIONS.md @@ -5,9 +5,10 @@ directly. - Renamed the frontend success event to `game-install-finished`; the old unpack name no longer matched the transactional install/update lifecycle. -- Implemented watcher rescans by reusing the existing `.lanspread/library_index.json` - cache and updating a single game entry in that index. This satisfies the - per-ID optimized rescan requirement without adding a second cache format. +- Implemented watcher rescans by reusing the app-state + `local_library/index.json` cache and updating a single game entry in that + index. This satisfies the per-ID optimized rescan requirement without adding a + second cache format. - Split full startup recovery from ordinary settled refreshes. Startup and real `SetGameDir` changes run recovery plus a scan; install/update/uninstall completion only rescans the affected game after operation tracking has been diff --git a/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py b/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py index 5ff37e5..8c262a6 100644 --- a/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py +++ b/crates/lanspread-peer-cli/scripts/run_extended_scenarios.py @@ -592,27 +592,30 @@ class Runner: return "small bfbc2 and large alienswarm transfers both diffed cleanly against sources" def s14_large_multi_peer_chunking(self) -> str: - alpha = self.peer("s14-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True) + game_id = PERF_GAME_ID + source_dir = self.fixture_root / "s14-alpha" + create_large_sparse_game(source_dir / game_id, size=CHUNK_SIZE + 1024 * 1024) + alpha = self.peer("s14-alpha", games_dir=source_dir) stage = self.peer("s14-stage") connect_many(stage, [alpha]) waiter = LineWaiter(len(stage.output)) - stage.send({"cmd": "download", "game_id": "alienswarm", "install": False}) - stage.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="stage finish", waiter=waiter) - diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", stage.host_games_dir / "alienswarm") + stage.send({"cmd": "download", "game_id": game_id, "install": False}) + stage.wait_for(event_is("download-finished", game_id), timeout=90, description="stage finish", waiter=waiter) + diff_game_dirs(source_dir / game_id, stage.host_games_dir / game_id) client = self.peer("s14-client") connect_many(client, [alpha, stage]) - wait_remote_game(client, "alienswarm", peer_count=2) + wait_remote_game(client, game_id, peer_count=2, version="20260520") waiter = LineWaiter(len(client.output)) - client.send({"cmd": "download", "game_id": "alienswarm", "install": False}) - client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="client finish", waiter=waiter) - diff_game_dirs(FIXTURES / "fixture-alpha" / "alienswarm", client.host_games_dir / "alienswarm") - totals = chunk_totals(client, "alienswarm", "alienswarm/alienswarm.eti") + client.send({"cmd": "download", "game_id": game_id, "install": False}) + client.wait_for(event_is("download-finished", game_id), timeout=90, description="client finish", waiter=waiter) + diff_game_dirs(source_dir / game_id, client.host_games_dir / game_id) + totals = chunk_totals(client, game_id, f"{game_id}/{game_id}.eti") if len(totals) < 2: raise ScenarioError(f"expected chunks from two peers, got {totals}") values = list(totals.values()) if max(values) - min(values) > CHUNK_SIZE: raise ScenarioError(f"chunk totals not balanced within one chunk: {totals}") - return f"alienswarm downloaded from two sources, diff matched, chunk totals={totals}" + return f"{game_id} downloaded from two sources, diff matched, chunk totals={totals}" def s15_three_way_version_skew(self) -> str: specs = [("s15-a", "20250101"), ("s15-b", "20250201"), ("s15-c", "20250301")] diff --git a/crates/lanspread-peer-cli/src/main.rs b/crates/lanspread-peer-cli/src/main.rs index 0f4be2a..67ecb28 100644 --- a/crates/lanspread-peer-cli/src/main.rs +++ b/crates/lanspread-peer-cli/src/main.rs @@ -24,6 +24,7 @@ use lanspread_peer::{ PeerRuntimeHandle, PeerSnapshot, PeerStartOptions, + migrate_legacy_state, start_peer_with_options, }; use lanspread_peer_cli::{ @@ -125,6 +126,7 @@ async fn main() -> eyre::Result<()> { let fixture_seeds = seed_fixtures(&args.games_dir, &args.fixtures)?; let catalog = load_catalog(args.catalog_db.as_deref(), &fixture_seeds).await; + let migration = migrate_legacy_state(&args.games_dir, &args.state_dir).await; let (tx_events, rx_events) = mpsc::unbounded_channel(); let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new())); @@ -162,6 +164,7 @@ async fn main() -> eyre::Result<()> { "name": args.name, "games_dir": args.games_dir, "state_dir": args.state_dir, + "migration": migration, "fixtures": fixture_seeds, }), )); diff --git a/crates/lanspread-peer/ARCHITECTURE.md b/crates/lanspread-peer/ARCHITECTURE.md index 396c6f3..e7e8dda 100644 --- a/crates/lanspread-peer/ARCHITECTURE.md +++ b/crates/lanspread-peer/ARCHITECTURE.md @@ -124,7 +124,8 @@ Reserved per-game paths: - `.local.installing/` is extraction staging. - `.local.backup/` holds the previous install while an update or uninstall is in flight. -- `.lanspread.json` is the atomic per-game intent log. +- `games//install_intent.json` in the configured state directory is the + atomic per-game intent log. - `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership when the current intent is `None`. @@ -133,11 +134,17 @@ game root only for a catalog ID that is a single direct child of the configured game directory, has a regular root-level `version.ini`, and has no `local/`, `.local.installing/`, or `.local.backup/` path. -Recovery reads `.lanspread.json` and combines the recorded intent with the -observed `local/`, `.local.installing/`, and `.local.backup/` state. Intent -states `Installing`, `Updating`, and `Uninstalling` prove ownership of the -corresponding reserved directories even if the marker was not flushed before a -crash. With intent `None`, markerless `.local.*` directories are left untouched. +Recovery reads app-state `install_intent.json` and combines the recorded intent +with the observed `local/`, `.local.installing/`, and `.local.backup/` state. +Intent states `Installing`, `Updating`, and `Uninstalling` prove ownership of +the corresponding reserved directories even if the marker was not flushed before +a crash. With intent `None`, markerless `.local.*` directories are left +untouched. + +Legacy `.lanspread/`, `.lanspread.json`, `.lanspread.json.tmp`, +`.softlan_game_installed`, and `local/.softlan_first_start_done` files are +handled only by the dedicated pre-start migration phase. Normal operation does +not read legacy state paths. ### Result @@ -195,8 +202,8 @@ Most scans become O(number of game dirs), with full recursion only when needed. - Cache the last accepted `manifest_hash` per peer to short-circuit manifest requests when unchanged. 5. Local index + scan optimizations: - - Introduce a cached index file (e.g., `.lanspread/index.json`) that stores - per-root fingerprints and computed manifests. + - Use the cached `local_library/index.json` file in the configured state + directory to store per-root fingerprints and computed manifests. - Use filesystem watchers with a debounce window to collect changes and incrementally update the cache. - Schedule a low-frequency full scan to reconcile missed watcher events. diff --git a/crates/lanspread-peer/README.md b/crates/lanspread-peer/README.md index 235b57c..4e34ef8 100644 --- a/crates/lanspread-peer/README.md +++ b/crates/lanspread-peer/README.md @@ -97,16 +97,21 @@ truth for whether a download is still running. Install, update, uninstall, downloaded-file removal, and startup recovery live under `src/install/`. -Each game root has an atomic `.lanspread.json` intent log for install-side -operations and uses Lanspread-owned `.local.installing/` and `.local.backup/` -directories marked by `.lanspread_owned`. Startup recovery combines the recorded -intent with the observed filesystem state and only deletes reserved directories -when intent or marker ownership proves they belong to Lanspread. +Install-side operation intent is stored atomically under the configured peer +state directory, at `games//install_intent.json`. Game roots still use +Lanspread-owned `.local.installing/` and `.local.backup/` directories marked by +`.lanspread_owned`. Startup recovery combines the recorded intent with the +observed filesystem state and only deletes reserved directories when intent or +marker ownership proves they belong to Lanspread. Downloaded-file removal is deliberately separate from uninstall: it only accepts catalog IDs that are direct children of the configured game directory, refuses installed or in-flight roots, and deletes the whole game root only after finding a regular root-level `version.ini` sentinel. +Legacy launcher-owned files in game directories are migrated by a dedicated +pre-start phase. Normal install, recovery, scan, and transfer paths use only the +configured state directory for launcher-owned metadata. + ## Integration with `lanspread-tauri-deno-ts` The Tauri application embeds this crate in diff --git a/crates/lanspread-peer/src/context.rs b/crates/lanspread-peer/src/context.rs index be6f956..3a75a5b 100644 --- a/crates/lanspread-peer/src/context.rs +++ b/crates/lanspread-peer/src/context.rs @@ -32,6 +32,7 @@ pub enum OperationKind { #[derive(Clone)] pub struct Ctx { pub game_dir: Arc>, + pub state_dir: Arc, pub local_game_db: Arc>>, pub local_library: Arc>, pub peer_game_db: Arc>, @@ -79,6 +80,7 @@ impl Ctx { peer_game_db: Arc>, peer_id: String, game_dir: PathBuf, + state_dir: PathBuf, unpacker: Arc, shutdown: CancellationToken, task_tracker: TaskTracker, @@ -86,6 +88,7 @@ impl Ctx { ) -> Self { Self { game_dir: Arc::new(RwLock::new(game_dir)), + state_dir: Arc::new(state_dir), local_game_db: Arc::new(RwLock::new(None)), local_library: Arc::new(RwLock::new(LocalLibraryState::empty())), peer_game_db, diff --git a/crates/lanspread-peer/src/download/orchestrator.rs b/crates/lanspread-peer/src/download/orchestrator.rs index 18f4064..64b3666 100644 --- a/crates/lanspread-peer/src/download/orchestrator.rs +++ b/crates/lanspread-peer/src/download/orchestrator.rs @@ -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(()) } diff --git a/crates/lanspread-peer/src/download/planning.rs b/crates/lanspread-peer/src/download/planning.rs index 196b0e8..371a1e3 100644 --- a/crates/lanspread-peer/src/download/planning.rs +++ b/crates/lanspread-peer/src/download/planning.rs @@ -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, - tx_notify_ui: &UnboundedSender, ) -> eyre::Result<(GameFileDescription, Vec)> { 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()); } } diff --git a/crates/lanspread-peer/src/download/storage.rs b/crates/lanspread-peer/src/download/storage.rs index 20c69ec..11992c4 100644 --- a/crates/lanspread-peer/src/download/storage.rs +++ b/crates/lanspread-peer/src/download/storage.rs @@ -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()); } } diff --git a/crates/lanspread-peer/src/handlers.rs b/crates/lanspread-peer/src/handlers.rs index 8a0ffcd..3ccf4d0 100644 --- a/crates/lanspread-peer/src/handlers.rs +++ b/crates/lanspread-peer/src/handlers.rs @@ -341,6 +341,7 @@ pub async fn handle_download_game_files_command( } end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await; download_state_guard.disarm(); + send_download_finished(&tx_notify_ui_clone, &download_id); return; }; @@ -354,6 +355,8 @@ pub async fn handle_download_game_files_command( .await { clear_active_download(&ctx_clone, &download_id).await; + send_download_finished(&tx_notify_ui_clone, &download_id); + download_state_guard.disarm(); run_started_install_operation( &ctx_clone, &tx_notify_ui_clone, @@ -362,7 +365,9 @@ pub async fn handle_download_game_files_command( ) .await; } else { - clear_active_download(&ctx_clone, &download_id).await; + end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await; + download_state_guard.disarm(); + send_download_finished(&tx_notify_ui_clone, &download_id); } } else { if let Err(err) = refresh_local_game_for_ending_operation( @@ -375,8 +380,9 @@ pub async fn handle_download_game_files_command( log::error!("Failed to refresh local library after download: {err}"); } end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await; + download_state_guard.disarm(); + send_download_finished(&tx_notify_ui_clone, &download_id); } - download_state_guard.disarm(); } Err(e) => { if let Err(refresh_err) = refresh_local_game_for_ending_operation( @@ -393,6 +399,7 @@ pub async fn handle_download_game_files_command( end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await; download_state_guard.disarm(); log::error!("Download failed for {download_id}: {e}"); + send_download_failed(&tx_notify_ui_clone, &download_id); } } }); @@ -538,12 +545,13 @@ async fn run_started_install_operation( }, ); + let state_dir = ctx.state_dir.as_ref(); match operation { InstallOperation::Installing => { - install::install(&game_root, &id, ctx.unpacker.clone()).await + install::install(&game_root, state_dir, &id, ctx.unpacker.clone()).await } InstallOperation::Updating => { - install::update(&game_root, &id, ctx.unpacker.clone()).await + install::update(&game_root, state_dir, &id, ctx.unpacker.clone()).await } } }; @@ -601,7 +609,7 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: &str) { + if let Err(err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFinished { id: id.into() }) { + log::error!("Failed to send DownloadGameFilesFinished event: {err}"); + } +} + +fn send_download_failed(tx_notify_ui: &UnboundedSender, id: &str) { + if let Err(err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.into() }) { + log::error!("Failed to send DownloadGameFilesFailed event: {err}"); + } +} + async fn end_download_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: &str) { end_operation(ctx, tx_notify_ui, id).await; clear_active_download(ctx, id).await; @@ -845,7 +865,7 @@ async fn load_local_library_with_policy( ) -> eyre::Result<()> { let game_dir = { ctx.game_dir.read().await.clone() }; let active_ids = active_operation_ids(ctx).await; - install::recover_on_startup(&game_dir, &active_ids).await?; + install::recover_on_startup(&game_dir, ctx.state_dir.as_ref(), &active_ids).await?; scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir, event_policy).await } @@ -870,7 +890,7 @@ async fn scan_and_announce_local_library( event_policy: LocalLibraryEventPolicy, ) -> eyre::Result<()> { let catalog = ctx.catalog.read().await.clone(); - let scan = scan_local_library(game_dir, &catalog).await?; + let scan = scan_local_library(game_dir, ctx.state_dir.as_ref(), &catalog).await?; update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy, None).await; Ok(()) } @@ -884,7 +904,7 @@ async fn refresh_local_game_for_ending_operation( ) -> eyre::Result<()> { let game_dir = { ctx.game_dir.read().await.clone() }; let catalog = ctx.catalog.read().await.clone(); - let scan = rescan_local_game(&game_dir, &catalog, id).await?; + let scan = rescan_local_game(&game_dir, ctx.state_dir.as_ref(), &catalog, id).await?; update_and_announce_games_with_policy( ctx, tx_notify_ui, @@ -1068,7 +1088,8 @@ mod tests { Ctx::new( Arc::new(RwLock::new(PeerGameDB::new())), "peer".to_string(), - game_dir, + game_dir.clone(), + game_dir.join(".test-state"), Arc::new(FakeUnpacker), CancellationToken::new(), TaskTracker::new(), @@ -1332,7 +1353,7 @@ mod tests { .insert("game".to_string(), OperationKind::Installing); let (tx, mut rx) = mpsc::unbounded_channel(); let catalog = ctx.catalog.read().await.clone(); - let scan = scan_local_library(temp.path(), &catalog) + let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog) .await .expect("scan should succeed"); @@ -1378,13 +1399,13 @@ mod tests { let (tx, mut rx) = mpsc::unbounded_channel(); let catalog = ctx.catalog.read().await.clone(); - let scan = scan_local_library(temp.path(), &catalog) + let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog) .await .expect("first scan should succeed"); update_and_announce_games(&ctx, &tx, scan).await; assert_local_update(recv_event(&mut rx).await, false, true); - let scan = scan_local_library(temp.path(), &catalog) + let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog) .await .expect("second scan should succeed"); update_and_announce_games(&ctx, &tx, scan).await; @@ -1403,7 +1424,7 @@ mod tests { let ctx = test_ctx(temp.path().to_path_buf()); let (tx, mut rx) = mpsc::unbounded_channel(); let catalog = ctx.catalog.read().await.clone(); - let scan = scan_local_library(temp.path(), &catalog) + let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog) .await .expect("initial scan should succeed"); update_and_announce_games(&ctx, &tx, scan).await; @@ -1695,7 +1716,7 @@ mod tests { let ctx = test_ctx(temp.path().to_path_buf()); let (tx, mut rx) = mpsc::unbounded_channel(); let catalog = ctx.catalog.read().await.clone(); - let scan = scan_local_library(temp.path(), &catalog) + let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog) .await .expect("initial scan should succeed"); update_and_announce_games(&ctx, &tx, scan).await; @@ -1781,7 +1802,7 @@ mod tests { let ctx = test_ctx(current.path().to_path_buf()); let (tx, mut rx) = mpsc::unbounded_channel(); let catalog = ctx.catalog.read().await.clone(); - let scan = scan_local_library(current.path(), &catalog) + let scan = scan_local_library(current.path(), ctx.state_dir.as_ref(), &catalog) .await .expect("initial scan should succeed"); update_and_announce_games(&ctx, &tx, scan).await; diff --git a/crates/lanspread-peer/src/identity.rs b/crates/lanspread-peer/src/identity.rs index 7a06abf..d7632a2 100644 --- a/crates/lanspread-peer/src/identity.rs +++ b/crates/lanspread-peer/src/identity.rs @@ -1,13 +1,13 @@ -use std::path::{Path, PathBuf}; +use std::path::Path; use uuid::Uuid; -const PEER_ID_FILE: &str = "peer_id"; +use crate::state_paths::peer_id_path; pub const FEATURE_LIBRARY_DELTA: &str = "library-delta-v1"; pub const FEATURE_LIBRARY_SNAPSHOT: &str = "library-snapshot-v1"; -pub fn load_or_create_peer_id(state_dir: Option<&Path>) -> eyre::Result { +pub fn load_or_create_peer_id(state_dir: &Path) -> eyre::Result { let path = peer_id_path(state_dir); if let Ok(existing) = std::fs::read_to_string(&path) { let trimmed = existing.trim(); @@ -30,19 +30,3 @@ pub fn default_features() -> Vec { FEATURE_LIBRARY_SNAPSHOT.to_string(), ] } - -fn peer_id_path(state_dir: Option<&Path>) -> PathBuf { - if let Some(dir) = state_dir { - return dir.join(PEER_ID_FILE); - } - - if let Some(dir) = std::env::var_os("LANSPREAD_STATE_DIR") { - return PathBuf::from(dir).join(PEER_ID_FILE); - } - - if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) { - return PathBuf::from(home).join(".lanspread").join(PEER_ID_FILE); - } - - std::env::temp_dir().join("lanspread").join(PEER_ID_FILE) -} diff --git a/crates/lanspread-peer/src/install/intent.rs b/crates/lanspread-peer/src/install/intent.rs index 8a3f40a..77d281d 100644 --- a/crates/lanspread-peer/src/install/intent.rs +++ b/crates/lanspread-peer/src/install/intent.rs @@ -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) -> 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::(&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!( diff --git a/crates/lanspread-peer/src/install/mod.rs b/crates/lanspread-peer/src/install/mod.rs index e6f4c41..4d518d7 100644 --- a/crates/lanspread-peer/src/install/mod.rs +++ b/crates/lanspread-peer/src/install/mod.rs @@ -1,4 +1,4 @@ -mod intent; +pub(crate) mod intent; mod remove; mod transaction; pub mod unpack; diff --git a/crates/lanspread-peer/src/install/transaction.rs b/crates/lanspread-peer/src/install/transaction.rs index fe10f56..f30ab24 100644 --- a/crates/lanspread-peer/src/install/transaction.rs +++ b/crates/lanspread-peer/src/install/transaction.rs @@ -33,10 +33,16 @@ struct InstallFsState { backup: FsEntryState, } -pub async fn install(game_root: &Path, id: &str, unpacker: Arc) -> eyre::Result<()> { +pub async fn install( + game_root: &Path, + state_dir: &Path, + id: &str, + unpacker: Arc, +) -> 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) -> 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) -> 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) -> eyre::Result<()> { +pub async fn update( + game_root: &Path, + state_dir: &Path, + id: &str, + unpacker: Arc, +) -> 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) -> 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) -> } 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) -> } } -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) -> eyre::Result<()> { +pub async fn recover_on_startup( + game_dir: &Path, + state_dir: &Path, + active_ids: &HashSet, +) -> 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) - 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()); diff --git a/crates/lanspread-peer/src/lib.rs b/crates/lanspread-peer/src/lib.rs index bdff499..3c0afb5 100644 --- a/crates/lanspread-peer/src/lib.rs +++ b/crates/lanspread-peer/src/lib.rs @@ -22,6 +22,7 @@ mod identity; mod install; mod library; mod local_games; +mod migration; mod network; mod path_validation; mod peer; @@ -29,6 +30,7 @@ mod peer_db; mod remote_peer; mod services; mod startup; +mod state_paths; #[cfg(test)] mod test_support; @@ -42,6 +44,7 @@ pub use config::{CHUNK_SIZE, MAX_RETRY_COUNT}; pub use error::PeerError; pub use install::{UnpackFuture, Unpacker}; use lanspread_db::db::{Game, GameFileDescription}; +pub use migration::{MigrationReport, migrate_legacy_state}; pub use peer_db::{ MajorityValidationResult, PeerGameDB, @@ -56,7 +59,6 @@ use tokio::sync::{ }; use tokio_util::{sync::CancellationToken, task::TaskTracker}; -pub use crate::startup::PeerRuntimeHandle; use crate::{ context::Ctx, handlers::{ @@ -73,7 +75,9 @@ use crate::{ handle_uninstall_game_command, load_local_library, }, + state_paths::resolve_state_dir, }; +pub use crate::{startup::PeerRuntimeHandle, state_paths::setup_done_path}; // ============================================================================= // Public API types @@ -300,12 +304,13 @@ pub fn start_peer_with_options( options: PeerStartOptions, ) -> eyre::Result { let PeerStartOptions { state_dir } = options; + let state_dir = resolve_state_dir(state_dir.as_deref()); let game_dir = game_dir.into(); log::info!( "Starting peer system with game directory: {}", game_dir.display() ); - let peer_id = identity::load_or_create_peer_id(state_dir.as_deref())?; + let peer_id = identity::load_or_create_peer_id(&state_dir)?; let (tx_control, rx_control) = tokio::sync::mpsc::unbounded_channel(); @@ -316,6 +321,7 @@ pub fn start_peer_with_options( peer_game_db, peer_id, game_dir, + state_dir, unpacker, catalog, )) @@ -329,6 +335,7 @@ async fn run_peer( peer_game_db: Arc>, peer_id: String, game_dir: PathBuf, + state_dir: PathBuf, unpacker: Arc, shutdown: CancellationToken, task_tracker: TaskTracker, @@ -338,6 +345,7 @@ async fn run_peer( peer_game_db, peer_id, game_dir, + state_dir, unpacker, shutdown, task_tracker, diff --git a/crates/lanspread-peer/src/local_games.rs b/crates/lanspread-peer/src/local_games.rs index 85691e2..dbb6ae6 100644 --- a/crates/lanspread-peer/src/local_games.rs +++ b/crates/lanspread-peer/src/local_games.rs @@ -71,7 +71,7 @@ pub async fn local_download_available( // Local library index and scanning // ============================================================================= -const LIBRARY_INDEX_DIR: &str = ".lanspread"; +const LEGACY_LIBRARY_INDEX_DIR: &str = ".lanspread"; const LIBRARY_INDEX_FILE: &str = "library_index.json"; const INTENT_LOG_FILE: &str = ".lanspread.json"; const VERSION_TMP_FILE: &str = ".version.ini.tmp"; @@ -114,8 +114,14 @@ pub struct LocalLibraryScan { pub revision: u64, } -fn library_index_path(game_dir: &Path) -> PathBuf { - game_dir.join(LIBRARY_INDEX_DIR).join(LIBRARY_INDEX_FILE) +pub(crate) fn legacy_library_index_path(game_dir: &Path) -> PathBuf { + game_dir + .join(LEGACY_LIBRARY_INDEX_DIR) + .join(LIBRARY_INDEX_FILE) +} + +fn library_index_path(state_dir: &Path) -> PathBuf { + crate::state_paths::local_library_index_path(state_dir) } fn library_index_tmp_path(path: &Path) -> PathBuf { @@ -278,7 +284,7 @@ async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result } pub fn is_ignored_game_root_name(name: &str) -> bool { - name == LIBRARY_INDEX_DIR + name == LEGACY_LIBRARY_INDEX_DIR } fn is_reserved_transient_name(name: &str) -> bool { @@ -286,7 +292,7 @@ fn is_reserved_transient_name(name: &str) -> bool { || name == VERSION_TMP_FILE || name == VERSION_DISCARDED_FILE || name == INTENT_LOG_FILE - || name == LIBRARY_INDEX_DIR + || name == LEGACY_LIBRARY_INDEX_DIR } fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool { @@ -550,9 +556,11 @@ fn scan_from_index(index: &LibraryIndex) -> LocalLibraryScan { /// Scans the local game directory and returns summaries plus a game database. pub async fn scan_local_library( game_dir: impl AsRef, + state_dir: impl AsRef, catalog: &HashSet, ) -> eyre::Result { let game_path = game_dir.as_ref(); + let state_path = state_dir.as_ref(); let metadata = match tokio::fs::metadata(game_path).await { Ok(metadata) => metadata, @@ -577,7 +585,7 @@ pub async fn scan_local_library( } let _index_guard = LIBRARY_INDEX_LOCK.lock().await; - let index_path = library_index_path(game_path); + let index_path = library_index_path(state_path); let mut index = load_library_index(&index_path).await; let mut seen_ids = HashSet::new(); let mut summaries = HashMap::new(); @@ -636,12 +644,14 @@ pub async fn scan_local_library( /// Rescans a single game root through the cached index and returns full library state. pub async fn rescan_local_game( game_dir: impl AsRef, + state_dir: impl AsRef, catalog: &HashSet, game_id: &str, ) -> eyre::Result { let game_path = game_dir.as_ref(); + let state_path = state_dir.as_ref(); let _index_guard = LIBRARY_INDEX_LOCK.lock().await; - let index_path = library_index_path(game_path); + let index_path = library_index_path(state_path); let mut index = load_library_index(&index_path).await; let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?; @@ -765,6 +775,7 @@ mod tests { #[tokio::test] async fn scan_uses_version_ini_and_local_dir_as_independent_state() { let temp = TempDir::new("lanspread-local-games"); + let state = TempDir::new("lanspread-local-games-state"); let catalog = HashSet::from([ "ready".to_string(), "local-only".to_string(), @@ -783,7 +794,7 @@ mod tests { b"20250101", ); - let scan = scan_local_library(temp.path(), &catalog) + let scan = scan_local_library(temp.path(), state.path(), &catalog) .await .expect("scan should succeed"); @@ -818,11 +829,12 @@ mod tests { #[tokio::test] async fn rescan_promotes_installed_only_game_to_ready_when_sentinel_appears() { let temp = TempDir::new("lanspread-local-games"); + let state = TempDir::new("lanspread-local-games-state"); let catalog = HashSet::from(["game".to_string()]); std::fs::create_dir_all(temp.path().join("game").join("local")) .expect("local install dir should be created"); - let first_scan = scan_local_library(temp.path(), &catalog) + let first_scan = scan_local_library(temp.path(), state.path(), &catalog) .await .expect("initial scan should succeed"); let local_only = first_scan @@ -835,7 +847,7 @@ mod tests { write_file(&temp.path().join("game").join("version.ini"), b"20250101"); - let rescan = rescan_local_game(temp.path(), &catalog, "game") + let rescan = rescan_local_game(temp.path(), state.path(), &catalog, "game") .await .expect("rescan should succeed"); let ready = rescan @@ -851,11 +863,12 @@ mod tests { #[tokio::test] async fn concurrent_rescans_preserve_both_index_updates() { let temp = TempDir::new("lanspread-local-games-concurrent"); + let state = TempDir::new("lanspread-local-games-state"); let catalog = HashSet::from(["game-a".to_string(), "game-b".to_string()]); write_file(&temp.path().join("game-a").join("version.ini"), b"20250101"); write_file(&temp.path().join("game-b").join("version.ini"), b"20250101"); - let initial = scan_local_library(temp.path(), &catalog) + let initial = scan_local_library(temp.path(), state.path(), &catalog) .await .expect("initial scan should succeed"); assert_eq!(initial.revision, 1); @@ -864,13 +877,13 @@ mod tests { write_file(&temp.path().join("game-b").join("game-b.eti"), b"archive-b"); let (scan_a, scan_b) = tokio::join!( - rescan_local_game(temp.path(), &catalog, "game-a"), - rescan_local_game(temp.path(), &catalog, "game-b") + rescan_local_game(temp.path(), state.path(), &catalog, "game-a"), + rescan_local_game(temp.path(), state.path(), &catalog, "game-b") ); scan_a.expect("game-a rescan should succeed"); scan_b.expect("game-b rescan should succeed"); - let index = load_library_index(&library_index_path(temp.path())).await; + let index = load_library_index(&library_index_path(state.path())).await; assert_eq!(index.revision, 3); let game_a = index .games diff --git a/crates/lanspread-peer/src/migration.rs b/crates/lanspread-peer/src/migration.rs new file mode 100644 index 0000000..a23b8c4 --- /dev/null +++ b/crates/lanspread-peer/src/migration.rs @@ -0,0 +1,612 @@ +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::>() + .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> { + 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::(&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 { + 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 { + 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 { + 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 { + 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()); + } +} diff --git a/crates/lanspread-peer/src/services/handshake.rs b/crates/lanspread-peer/src/services/handshake.rs index 6056f71..90ef9a0 100644 --- a/crates/lanspread-peer/src/services/handshake.rs +++ b/crates/lanspread-peer/src/services/handshake.rs @@ -305,6 +305,7 @@ mod tests { peer_game_db.clone(), "local-peer".to_string(), PathBuf::new(), + PathBuf::new(), Arc::new(NoopUnpacker), CancellationToken::new(), TaskTracker::new(), diff --git a/crates/lanspread-peer/src/services/local_monitor.rs b/crates/lanspread-peer/src/services/local_monitor.rs index 541fcce..c09122e 100644 --- a/crates/lanspread-peer/src/services/local_monitor.rs +++ b/crates/lanspread-peer/src/services/local_monitor.rs @@ -277,7 +277,7 @@ async fn run_gated_rescan( let game_dir = ctx.game_dir.read().await.clone(); let catalog = ctx.catalog.read().await.clone(); - match rescan_local_game(&game_dir, &catalog, &id).await { + match rescan_local_game(&game_dir, ctx.state_dir.as_ref(), &catalog, &id).await { Ok(scan) => update_and_announce_games(&ctx, &tx_notify_ui, scan).await, Err(err) => log::error!("Failed to rescan local game {id}: {err}"), } @@ -293,7 +293,7 @@ async fn run_gated_rescan( async fn run_fallback_scan(ctx: &Ctx, tx_notify_ui: &UnboundedSender) { let game_dir = ctx.game_dir.read().await.clone(); let catalog = ctx.catalog.read().await.clone(); - match scan_local_library(&game_dir, &catalog).await { + match scan_local_library(&game_dir, ctx.state_dir.as_ref(), &catalog).await { Ok(scan) => update_and_announce_games(ctx, tx_notify_ui, scan).await, Err(err) => log::error!("Failed to scan local games directory: {err}"), } @@ -377,7 +377,8 @@ mod tests { Ctx::new( Arc::new(RwLock::new(PeerGameDB::new())), "peer".to_string(), - game_dir, + game_dir.clone(), + game_dir.join(".test-state"), Arc::new(NoopUnpacker), CancellationToken::new(), TaskTracker::new(), diff --git a/crates/lanspread-peer/src/services/stream.rs b/crates/lanspread-peer/src/services/stream.rs index 1399809..39d540c 100644 --- a/crates/lanspread-peer/src/services/stream.rs +++ b/crates/lanspread-peer/src/services/stream.rs @@ -332,7 +332,8 @@ mod tests { Ctx::new( Arc::new(RwLock::new(PeerGameDB::new())), "peer".to_string(), - game_dir, + game_dir.clone(), + game_dir.join(".test-state"), Arc::new(NoopUnpacker), CancellationToken::new(), TaskTracker::new(), diff --git a/crates/lanspread-peer/src/startup.rs b/crates/lanspread-peer/src/startup.rs index a6c95f9..edb993d 100644 --- a/crates/lanspread-peer/src/startup.rs +++ b/crates/lanspread-peer/src/startup.rs @@ -82,6 +82,7 @@ pub(crate) fn spawn_peer_runtime( peer_game_db: Arc>, peer_id: String, game_dir: PathBuf, + state_dir: PathBuf, unpacker: Arc, catalog: Arc>>, ) -> PeerRuntimeHandle { @@ -98,6 +99,7 @@ pub(crate) fn spawn_peer_runtime( peer_game_db, peer_id, game_dir, + state_dir, unpacker, runtime_shutdown.clone(), runtime_tracker.clone(), diff --git a/crates/lanspread-peer/src/state_paths.rs b/crates/lanspread-peer/src/state_paths.rs new file mode 100644 index 0000000..84b6a12 --- /dev/null +++ b/crates/lanspread-peer/src/state_paths.rs @@ -0,0 +1,42 @@ +use std::path::{Path, PathBuf}; + +const PEER_ID_FILE: &str = "peer_id"; +const LOCAL_LIBRARY_DIR: &str = "local_library"; +const LOCAL_LIBRARY_INDEX_FILE: &str = "index.json"; +const GAMES_DIR: &str = "games"; +const SETUP_DONE_FILE: &str = "setup_done"; + +pub(crate) fn resolve_state_dir(explicit: Option<&Path>) -> PathBuf { + if let Some(dir) = explicit { + return dir.to_path_buf(); + } + + if let Some(dir) = std::env::var_os("LANSPREAD_STATE_DIR") { + return PathBuf::from(dir); + } + + if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) { + return PathBuf::from(home).join(".lanspread"); + } + + std::env::temp_dir().join("lanspread") +} + +pub(crate) fn peer_id_path(state_dir: &Path) -> PathBuf { + state_dir.join(PEER_ID_FILE) +} + +pub(crate) fn local_library_index_path(state_dir: &Path) -> PathBuf { + state_dir + .join(LOCAL_LIBRARY_DIR) + .join(LOCAL_LIBRARY_INDEX_FILE) +} + +pub(crate) fn game_state_dir(state_dir: &Path, game_id: &str) -> PathBuf { + state_dir.join(GAMES_DIR).join(game_id) +} + +#[must_use] +pub fn setup_done_path(state_dir: &Path, game_id: &str) -> PathBuf { + game_state_dir(state_dir, game_id).join(SETUP_DONE_FILE) +} diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index 7794959..4071da6 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -1,10 +1,8 @@ -#[cfg(target_os = "windows")] -use std::fs::File; use std::{ collections::{HashMap, HashSet}, net::SocketAddr, path::{Component, Path, PathBuf}, - sync::Arc, + sync::{Arc, OnceLock}, time::{SystemTime, UNIX_EPOCH}, }; @@ -18,9 +16,11 @@ use lanspread_peer::{ PeerEvent, PeerGameDB, PeerRuntimeHandle, + PeerStartOptions, UnpackFuture, Unpacker, - start_peer, + migrate_legacy_state, + start_peer_with_options, }; use tauri::{AppHandle, Emitter as _, Manager}; use tauri_plugin_shell::{ShellExt, process::Command}; @@ -42,6 +42,7 @@ struct LanSpreadState { peer_game_db: Arc>, catalog: Arc>>, unpack_logs: Arc>>, + state_dir: OnceLock, } struct PeerEventTx(UnboundedSender); @@ -109,8 +110,6 @@ async fn get_unpack_logs( Ok(state.inner().unpack_logs.read().await.clone()) } -#[cfg(target_os = "windows")] -const FIRST_START_DONE_FILE: &str = ".softlan_first_start_done"; #[cfg_attr(not(target_os = "windows"), allow(dead_code))] const GAME_SETUP_SCRIPT: &str = "game_setup.cmd"; #[cfg_attr(not(target_os = "windows"), allow(dead_code))] @@ -441,8 +440,23 @@ fn sanitize_username(username: &str) -> String { #[cfg_attr(not(target_os = "windows"), allow(dead_code))] fn script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String { + script_params_with_mode("/c", script_path, id, settings) +} + +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn server_script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String { + script_params_with_mode("/k", script_path, id, settings) +} + +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn script_params_with_mode( + cmd_mode: &str, + script_path: &Path, + id: &str, + settings: &LaunchSettings, +) -> String { format!( - r#"/d /s /c ""{}" "local" "{}" "{}" "{}"""#, + r#"/d /s {cmd_mode} ""{}" "local" "{}" "{}" "{}"""#, script_path.display(), id, settings.language, @@ -487,7 +501,12 @@ async fn get_game_thumbnail( } #[cfg(target_os = "windows")] -fn run_as_admin(file: &str, params: &str, dir: &str) -> bool { +fn run_as_admin( + file: &str, + params: &str, + dir: &str, + show_cmd: windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD, +) -> bool { use std::{ffi::OsStr, os::windows::ffi::OsStrExt}; use windows::{Win32::UI::Shell::ShellExecuteW, core::PCWSTR}; @@ -504,7 +523,7 @@ fn run_as_admin(file: &str, params: &str, dir: &str) -> bool { PCWSTR::from_raw(file_wide.as_ptr()), PCWSTR::from_raw(params_wide.as_ptr()), PCWSTR::from_raw(dir_wide.as_ptr()), - windows::Win32::UI::WindowsAndMessaging::SW_HIDE, + show_cmd, ) }; @@ -540,9 +559,13 @@ async fn run_game_windows( let game_setup_bin = game_path.join(GAME_SETUP_SCRIPT); let game_start_bin = game_path.join(GAME_START_SCRIPT); + let Some(state_dir) = state.inner().state_dir.get().cloned() else { + log::error!("app state directory is not initialized; cannot run game"); + return Ok(()); + }; - let first_start_done_file = game_path.join("local").join(FIRST_START_DONE_FILE); - if !first_start_done_file.exists() && game_setup_bin.exists() { + let setup_done_file = lanspread_peer::setup_done_path(&state_dir, &id); + if !setup_done_file.exists() && game_setup_bin.exists() { if !local_install_is_present(&game_path) { log::warn!( "local install is missing for {}; skipping game_setup", @@ -555,6 +578,7 @@ async fn run_game_windows( "cmd.exe", &script_params(&game_setup_bin, &id, &settings), &game_path.display().to_string(), + windows::Win32::UI::WindowsAndMessaging::SW_HIDE, ); if !result { @@ -562,10 +586,19 @@ async fn run_game_windows( return Ok(()); } - if let Err(e) = File::create(&first_start_done_file) { + if let Some(parent) = setup_done_file.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { log::error!( - "failed to create first-start marker {}: {e}", - first_start_done_file.display() + "failed to create setup marker directory {}: {e}", + parent.display() + ); + } + + if let Err(e) = std::fs::File::create(&setup_done_file) { + log::error!( + "failed to create setup marker {}: {e}", + setup_done_file.display() ); } } @@ -575,6 +608,7 @@ async fn run_game_windows( "cmd.exe", &script_params(&game_start_bin, &id, &settings), &game_path.display().to_string(), + windows::Win32::UI::WindowsAndMessaging::SW_HIDE, ); if !result { @@ -646,8 +680,9 @@ async fn start_server_windows( let result = run_as_admin( "cmd.exe", - &script_params(&server_start_bin, &id, &settings), + &server_script_params(&server_start_bin, &id, &settings), &game_path.display().to_string(), + windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL, ); if !result { @@ -850,6 +885,21 @@ async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> ta } let path_changed = current_path != path; + let Some(state_dir) = state.state_dir.get().cloned() else { + log::error!("app state directory is not initialized; cannot update game directory"); + return Ok(()); + }; + + if path_changed || state.peer_ctrl.read().await.is_none() { + let migration = migrate_legacy_state(&games_folder, &state_dir).await; + if migration.failures > 0 { + log::warn!( + "Legacy state migration completed with {} failure(s)", + migration.failures + ); + } + } + *state.games_folder.write().await = path; ensure_bundled_game_db_loaded(&app_handle).await; @@ -1193,16 +1243,23 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) { return; } + let Some(state_dir) = state.state_dir.get().cloned() else { + log::error!("app state directory is not initialized; cannot start peer"); + return; + }; let tx_peer_event = app_handle.state::().inner().0.clone(); let unpacker = Arc::new(SidecarUnpacker { app_handle: app_handle.clone(), }); - match start_peer( + match start_peer_with_options( games_folder.to_path_buf(), tx_peer_event, state.peer_game_db.clone(), unpacker, state.catalog.clone(), + PeerStartOptions { + state_dir: Some(state_dir), + }, ) { Ok(handle) => { let sender = handle.sender(); @@ -1621,7 +1678,7 @@ mod tests { #[test] fn script_params_use_common_argument_shape() { - let params = script_params( + let start_params = script_params( Path::new("C:/Games/My Game") .join(GAME_START_SCRIPT) .as_path(), @@ -1633,9 +1690,25 @@ mod tests { ); assert_eq!( - params, + start_params, r#"/d /s /c ""C:/Games/My Game/game_start.cmd" "local" "my-game" "en" "Alice"""# ); + + let server_params = server_script_params( + Path::new("C:/Games/My Game") + .join(SERVER_START_SCRIPT) + .as_path(), + "my-game", + &LaunchSettings { + language: "en".to_string(), + username: "Alice".to_string(), + }, + ); + + assert_eq!( + server_params, + r#"/d /s /k ""C:/Games/My Game/server_start.cmd" "local" "my-game" "en" "Alice"""# + ); } #[test] @@ -1743,6 +1816,12 @@ pub fn run() { .manage(LanSpreadState::default()) .manage(PeerEventTx(tx_peer_event)) .setup(move |app| { + let state_dir = app.path().app_data_dir()?; + std::fs::create_dir_all(&state_dir)?; + let state = app.state::(); + if state.state_dir.set(state_dir).is_err() { + log::warn!("app state directory was already initialized"); + } spawn_peer_event_loop(app.handle().clone(), rx_peer_event); Ok(()) })