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
+81 -18
View File
@@ -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 `<game_root>/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<bool> {
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<Option<Pa
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.
///
/// 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");