fix(launch): stamp first-use settings on every launch path

First-use launch settings moved out of install/update transactions, but three
edge cases could still leave archive stub values in place while the marker said
the settings pass had already happened.

Start Server now runs the same stamping preflight as Play before launching
server_start.cmd. That covers games whose server scripts read account_name.txt,
language.txt, or SmartSteamEmu.ini before the user ever presses Play.

Install and update now reset the per-game marker before committing
InstallIntent::None. Recovery also clears the marker for install/update states
where the new local tree has already landed, so a crash after promotion cannot
publish a clean intent while preserving a stale marker. Rollback recovery keeps
the marker, because the old local tree remains the active install.

SmartSteamEmu.ini stamping now searches every matching file until one contains a
PersonaName line. This keeps a decoy or incomplete INI from permanently blocking
the real one while still preserving early-exit behavior for account_name.txt and
language.txt, which only need the first matching file.

Test Plan:
- just fmt
- just test
- just clippy
- git diff --check

Refs: local review findings
This commit is contained in:
2026-05-29 06:36:19 +02:00
parent 09709cc008
commit 18f21bdf30
3 changed files with 191 additions and 30 deletions
+104 -12
View File
@@ -50,8 +50,8 @@ pub async fn install(
let result = install_inner(game_root, id, unpacker).await; let result = install_inner(game_root, id, unpacker).await;
match result { match result {
Ok(()) => { Ok(()) => {
reset_launch_settings_marker(state_dir, id).await?;
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?; write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
reset_launch_settings_marker(state_dir, id).await;
Ok(()) Ok(())
} }
Err(err) => { Err(err) => {
@@ -84,8 +84,8 @@ pub async fn update(
let result = update_inner(game_root, id, unpacker).await; let result = update_inner(game_root, id, unpacker).await;
match result { match result {
Ok(()) => { Ok(()) => {
reset_launch_settings_marker(state_dir, id).await?;
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).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 { if let Err(err) = remove_dir_all_if_exists(&backup_dir(game_root)).await {
log::warn!( log::warn!(
"Failed to clean install backup {}: {err}", "Failed to clean install backup {}: {err}",
@@ -274,18 +274,16 @@ async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
Ok(archives) Ok(archives)
} }
/// Drop the per-game launch-settings marker so the launcher reapplies the /// Drop the per-game launch-settings marker before committing install/update
/// username/language to the freshly (re)created `local/` on the next play. A /// success, so recovery can retry the reset before publishing a clean intent.
/// failure here is non-fatal; it only means the launcher might skip a settings async fn reset_launch_settings_marker(state_dir: &Path, id: &str) -> eyre::Result<()> {
/// pass for an install whose marker was already absent.
async fn reset_launch_settings_marker(state_dir: &Path, id: &str) {
let marker = launch_settings_applied_path(state_dir, id); let marker = launch_settings_applied_path(state_dir, id);
if let Err(err) = remove_file_if_exists(&marker).await { remove_file_if_exists(&marker).await.wrap_err_with(|| {
log::warn!( format!(
"Failed to reset launch-settings marker {}: {err}", "failed to reset launch-settings marker {}",
marker.display() marker.display()
); )
} })
} }
async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> { async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
@@ -301,6 +299,7 @@ async fn recover_installing(
intent: InstallIntent, intent: InstallIntent,
fs: InstallFsState, fs: InstallFsState,
) -> eyre::Result<()> { ) -> eyre::Result<()> {
let commit_landed = fs.local == FsEntryState::Present;
if let InstallFsState { if let InstallFsState {
installing: FsEntryState::Present, installing: FsEntryState::Present,
.. ..
@@ -308,6 +307,9 @@ async fn recover_installing(
{ {
remove_dir_all_if_exists(&installing_dir(game_root)).await?; 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 write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
} }
@@ -318,6 +320,16 @@ async fn recover_updating(
intent: InstallIntent, intent: InstallIntent,
fs: InstallFsState, fs: InstallFsState,
) -> eyre::Result<()> { ) -> eyre::Result<()> {
if matches!(
fs,
InstallFsState {
local: FsEntryState::Present,
backup: FsEntryState::Present,
..
}
) {
reset_launch_settings_marker(state_dir, id).await?;
}
match fs { match fs {
InstallFsState { InstallFsState {
local: FsEntryState::Missing, local: FsEntryState::Missing,
@@ -710,6 +722,7 @@ mod tests {
write_file(&root.join("game.eti"), b"archive"); write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101"); write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("local").join("old.txt"), b"old"); 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()) update(&root, state.path(), "game", successful_unpacker())
.await .await
@@ -719,6 +732,7 @@ mod tests {
assert!(!root.join("local").join("old.txt").exists()); assert!(!root.join("local").join("old.txt").exists());
assert!(!root.join(".local.installing").exists()); assert!(!root.join(".local.installing").exists());
assert!(!root.join(".local.backup").exists()); assert!(!root.join(".local.backup").exists());
assert!(!launch_settings_applied_path(state.path(), "game").exists());
let intent = read_intent(state.path(), "game").await; let intent = read_intent(state.path(), "game").await;
assert_eq!(intent.state, InstallIntentState::None); 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] #[tokio::test]
async fn none_recovery_leaves_markerless_reserved_dirs_untouched() { async fn none_recovery_leaves_markerless_reserved_dirs_untouched() {
let temp = TempDir::new("lanspread-install"); let temp = TempDir::new("lanspread-install");
+73 -10
View File
@@ -50,10 +50,10 @@ pub struct LaunchSettingsOutcome {
/// If the per-game marker already exists this is a no-op returning /// If the per-game marker already exists this is a no-op returning
/// `already_applied = true`. Otherwise it searches `<game_root>/local/` for the /// `already_applied = true`. Otherwise it searches `<game_root>/local/` for the
/// known setting files, stamps `account_name` into the first `account_name.txt` /// known setting files, stamps `account_name` into the first `account_name.txt`
/// and the first `SmartSteamEmu.ini` `PersonaName` line (preserving that line's /// and first `SmartSteamEmu.ini` with a `PersonaName` line (preserving that
/// existing line ending) and `language` into the first `language.txt`, and — /// line's existing line ending) and `language` into the first `language.txt`,
/// whatever was or was not found — records the marker so the step never runs /// and — whatever was or was not found — records the marker so the step never
/// again for this game. /// runs again for this game.
pub async fn apply_launch_settings_once( pub async fn apply_launch_settings_once(
state_dir: &Path, state_dir: &Path,
game_root: &Path, game_root: &Path,
@@ -104,26 +104,27 @@ async fn overwrite_first_file(
Ok(true) 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<bool> { async fn rewrite_first_persona_name(root: &Path, persona_name: Option<&str>) -> eyre::Result<bool> {
let Some(persona_name) = persona_name else { let Some(persona_name) = persona_name else {
return Ok(false); return Ok(false);
}; };
let Some(path) = find_first_file(root, SMART_STEAM_EMU_INI).await? else {
return Ok(false);
};
for path in find_files(root, SMART_STEAM_EMU_INI).await? {
let content = tokio::fs::read_to_string(&path) let content = tokio::fs::read_to_string(&path)
.await .await
.wrap_err_with(|| format!("failed to read {}", path.display()))?; .wrap_err_with(|| format!("failed to read {}", path.display()))?;
let Some(rewritten) = rewrite_persona_name_content(&content, persona_name) else { let Some(rewritten) = rewrite_persona_name_content(&content, persona_name) else {
return Ok(false); continue;
}; };
tokio::fs::write(&path, rewritten) tokio::fs::write(&path, rewritten)
.await .await
.wrap_err_with(|| format!("failed to write {}", path.display()))?; .wrap_err_with(|| format!("failed to write {}", path.display()))?;
Ok(true) return Ok(true);
}
Ok(false)
} }
/// Find the first regular file named `file_name` anywhere under `root`. /// 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<Option<Pa
Ok(None) Ok(None)
} }
/// Find every regular file named `file_name` anywhere under `root`.
///
/// A missing `root` yields an empty list. Directories are visited in sorted
/// order for deterministic results.
async fn find_files(root: &Path, file_name: &str) -> eyre::Result<Vec<PathBuf>> {
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. /// Rewrite the first `PersonaName` line in `content`, preserving its line ending.
/// ///
/// Returns `None` when no matching line exists, so the caller can skip writing. /// 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]) { fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent dir should be created"); std::fs::create_dir_all(parent).expect("parent dir should be created");
@@ -740,6 +740,12 @@ async fn start_server_windows(
return Ok(false); 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( let result = run_as_admin(
"cmd.exe", "cmd.exe",
&server_script_params(&server_start_bin, &id, &settings), &server_script_params(&server_start_bin, &id, &settings),