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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user