diff --git a/crates/lanspread-peer/src/install/transaction.rs b/crates/lanspread-peer/src/install/transaction.rs index 5e204bc..a7ebc85 100644 --- a/crates/lanspread-peer/src/install/transaction.rs +++ b/crates/lanspread-peer/src/install/transaction.rs @@ -50,8 +50,8 @@ pub async fn install( let result = install_inner(game_root, id, unpacker).await; match result { Ok(()) => { + reset_launch_settings_marker(state_dir, id).await?; write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?; - reset_launch_settings_marker(state_dir, id).await; Ok(()) } Err(err) => { @@ -84,8 +84,8 @@ pub async fn update( let result = update_inner(game_root, id, unpacker).await; match result { Ok(()) => { + reset_launch_settings_marker(state_dir, id).await?; write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?; - reset_launch_settings_marker(state_dir, id).await; if let Err(err) = remove_dir_all_if_exists(&backup_dir(game_root)).await { log::warn!( "Failed to clean install backup {}: {err}", @@ -274,18 +274,16 @@ async fn root_eti_archives(game_root: &Path) -> eyre::Result> { Ok(archives) } -/// Drop the per-game launch-settings marker so the launcher reapplies the -/// username/language to the freshly (re)created `local/` on the next play. A -/// failure here is non-fatal; it only means the launcher might skip a settings -/// pass for an install whose marker was already absent. -async fn reset_launch_settings_marker(state_dir: &Path, id: &str) { +/// Drop the per-game launch-settings marker before committing install/update +/// success, so recovery can retry the reset before publishing a clean intent. +async fn reset_launch_settings_marker(state_dir: &Path, id: &str) -> eyre::Result<()> { let marker = launch_settings_applied_path(state_dir, id); - if let Err(err) = remove_file_if_exists(&marker).await { - log::warn!( - "Failed to reset launch-settings marker {}: {err}", + remove_file_if_exists(&marker).await.wrap_err_with(|| { + format!( + "failed to reset launch-settings marker {}", marker.display() - ); - } + ) + }) } async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> { @@ -301,6 +299,7 @@ async fn recover_installing( intent: InstallIntent, fs: InstallFsState, ) -> eyre::Result<()> { + let commit_landed = fs.local == FsEntryState::Present; if let InstallFsState { installing: FsEntryState::Present, .. @@ -308,6 +307,9 @@ async fn recover_installing( { remove_dir_all_if_exists(&installing_dir(game_root)).await?; } + if commit_landed { + reset_launch_settings_marker(state_dir, id).await?; + } write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await } @@ -318,6 +320,16 @@ async fn recover_updating( intent: InstallIntent, fs: InstallFsState, ) -> eyre::Result<()> { + if matches!( + fs, + InstallFsState { + local: FsEntryState::Present, + backup: FsEntryState::Present, + .. + } + ) { + reset_launch_settings_marker(state_dir, id).await?; + } match fs { InstallFsState { local: FsEntryState::Missing, @@ -710,6 +722,7 @@ mod tests { 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"); + write_file(&launch_settings_applied_path(state.path(), "game"), b""); update(&root, state.path(), "game", successful_unpacker()) .await @@ -719,6 +732,7 @@ mod tests { assert!(!root.join("local").join("old.txt").exists()); assert!(!root.join(".local.installing").exists()); assert!(!root.join(".local.backup").exists()); + assert!(!launch_settings_applied_path(state.path(), "game").exists()); let intent = read_intent(state.path(), "game").await; assert_eq!(intent.state, InstallIntentState::None); } @@ -960,6 +974,84 @@ mod tests { } } + #[tokio::test] + async fn recovery_resets_marker_for_commit_landed_install_or_update() { + let state = test_state(); + let cases = [ + ( + "installing-committed", + InstallIntentState::Installing, + false, + ), + ("updating-committed", InstallIntentState::Updating, true), + ]; + + for (id, intent_state, has_backup) in cases { + let temp = TempDir::new("lanspread-install"); + let root = temp.game_root(); + write_file(&root.join("version.ini"), b"20250101"); + write_file(&root.join(LOCAL_DIR).join("payload.txt"), LOCAL_PAYLOAD); + if has_backup { + write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD); + } + write_file(&launch_settings_applied_path(state.path(), id), b""); + write_intent( + state.path(), + id, + &InstallIntent::new(id, intent_state, Some("20250101".into())), + ) + .await + .expect("intent should be written"); + + recover_game_root(&root, state.path(), id) + .await + .expect("recovery should succeed"); + + assert!( + !launch_settings_applied_path(state.path(), id).exists(), + "{id} marker should be reset" + ); + let intent = read_intent(state.path(), id).await; + assert_eq!(intent.state, InstallIntentState::None, "{id}"); + } + } + + #[tokio::test] + async fn recovery_keeps_marker_when_update_rolls_back() { + let temp = TempDir::new("lanspread-install"); + let state = test_state(); + let root = temp.game_root(); + write_file(&root.join("version.ini"), b"20250101"); + write_file( + &root.join(INSTALLING_DIR).join("payload.txt"), + INSTALLING_PAYLOAD, + ); + write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD); + write_file(&launch_settings_applied_path(state.path(), "game"), b""); + write_intent( + state.path(), + "game", + &InstallIntent::new( + "game", + InstallIntentState::Updating, + Some("20250101".into()), + ), + ) + .await + .expect("intent should be written"); + + recover_game_root(&root, state.path(), "game") + .await + .expect("recovery should succeed"); + + assert!(launch_settings_applied_path(state.path(), "game").exists()); + assert_eq!( + std::fs::read(root.join(LOCAL_DIR).join("payload.txt")) + .expect("backup payload should be restored"), + BACKUP_PAYLOAD + ); + } + #[tokio::test] async fn none_recovery_leaves_markerless_reserved_dirs_untouched() { let temp = TempDir::new("lanspread-install"); diff --git a/crates/lanspread-peer/src/launch_settings.rs b/crates/lanspread-peer/src/launch_settings.rs index 34ab8cd..85dd83c 100644 --- a/crates/lanspread-peer/src/launch_settings.rs +++ b/crates/lanspread-peer/src/launch_settings.rs @@ -50,10 +50,10 @@ pub struct LaunchSettingsOutcome { /// If the per-game marker already exists this is a no-op returning /// `already_applied = true`. Otherwise it searches `/local/` for the /// known setting files, stamps `account_name` into the first `account_name.txt` -/// and the first `SmartSteamEmu.ini` `PersonaName` line (preserving that line's -/// existing line ending) and `language` into the first `language.txt`, and — -/// whatever was or was not found — records the marker so the step never runs -/// again for this game. +/// and first `SmartSteamEmu.ini` with a `PersonaName` line (preserving that +/// line's existing line ending) and `language` into the first `language.txt`, +/// and — whatever was or was not found — records the marker so the step never +/// runs again for this game. pub async fn apply_launch_settings_once( state_dir: &Path, game_root: &Path, @@ -104,26 +104,27 @@ async fn overwrite_first_file( Ok(true) } -/// Rewrite the `PersonaName` line in the first `SmartSteamEmu.ini` under `root`. +/// Rewrite the first `PersonaName` line found in any `SmartSteamEmu.ini` under `root`. async fn rewrite_first_persona_name(root: &Path, persona_name: Option<&str>) -> eyre::Result { let Some(persona_name) = persona_name else { return Ok(false); }; - let Some(path) = find_first_file(root, SMART_STEAM_EMU_INI).await? else { - return Ok(false); - }; - let content = tokio::fs::read_to_string(&path) - .await - .wrap_err_with(|| format!("failed to read {}", path.display()))?; - let Some(rewritten) = rewrite_persona_name_content(&content, persona_name) else { - return Ok(false); - }; + for path in find_files(root, SMART_STEAM_EMU_INI).await? { + let content = tokio::fs::read_to_string(&path) + .await + .wrap_err_with(|| format!("failed to read {}", path.display()))?; + let Some(rewritten) = rewrite_persona_name_content(&content, persona_name) else { + continue; + }; - tokio::fs::write(&path, rewritten) - .await - .wrap_err_with(|| format!("failed to write {}", path.display()))?; - Ok(true) + tokio::fs::write(&path, rewritten) + .await + .wrap_err_with(|| format!("failed to write {}", path.display()))?; + return Ok(true); + } + + Ok(false) } /// Find the first regular file named `file_name` anywhere under `root`. @@ -160,6 +161,41 @@ async fn find_first_file(root: &Path, file_name: &str) -> eyre::Result eyre::Result> { + let mut matches = Vec::new(); + let mut pending_dirs = vec![root.to_path_buf()]; + while let Some(dir) = pending_dirs.pop() { + let mut entries = match tokio::fs::read_dir(&dir).await { + Ok(entries) => entries, + Err(err) if err.kind() == ErrorKind::NotFound => continue, + Err(err) => { + return Err(err).wrap_err_with(|| format!("failed to read {}", dir.display())); + } + }; + + let mut child_dirs = Vec::new(); + while let Some(entry) = entries.next_entry().await? { + let file_type = entry.file_type().await?; + let path = entry.path(); + if file_type.is_dir() { + child_dirs.push(path); + } else if file_type.is_file() && entry.file_name() == OsStr::new(file_name) { + matches.push(path); + } + } + + child_dirs.sort(); + child_dirs.reverse(); + pending_dirs.extend(child_dirs); + } + + Ok(matches) +} + /// Rewrite the first `PersonaName` line in `content`, preserving its line ending. /// /// Returns `None` when no matching line exists, so the caller can skip writing. @@ -430,6 +466,33 @@ mod tests { ); } + #[tokio::test] + async fn searches_past_ini_files_without_persona_name() { + let state = TempDir::new("lanspread-launch-state"); + let game = TempDir::new("lanspread-launch-game"); + let root = game.path(); + let first = root.join(LOCAL_DIR).join("a").join(SMART_STEAM_EMU_INI); + let second = root.join(LOCAL_DIR).join("b").join(SMART_STEAM_EMU_INI); + write_file(&first, b"[User]\nLanguage = english\n"); + write_file(&second, b"PersonaName = stubname\n"); + + let outcome = + apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None) + .await + .expect("apply should succeed"); + + assert!(outcome.persona_name_written); + assert_eq!( + std::fs::read_to_string(&first).expect("first ini should be readable"), + "[User]\nLanguage = english\n" + ); + assert_eq!( + std::fs::read_to_string(&second).expect("second ini should be readable"), + "PersonaName = realuser\n" + ); + assert!(launch_settings_applied_path(state.path(), "game").is_file()); + } + 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"); 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 3f8d28f..eb79019 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -740,6 +740,12 @@ async fn start_server_windows( return Ok(false); } + let Some(state_dir) = state.inner().state_dir.get().cloned() else { + log::error!("app state directory is not initialized; cannot start server"); + return Ok(false); + }; + apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await; + let result = run_as_admin( "cmd.exe", &server_script_params(&server_start_bin, &id, &settings),