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,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");
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
|
||||||
|
|
||||||
let content = tokio::fs::read_to_string(&path)
|
for path in find_files(root, SMART_STEAM_EMU_INI).await? {
|
||||||
.await
|
let content = tokio::fs::read_to_string(&path)
|
||||||
.wrap_err_with(|| format!("failed to read {}", path.display()))?;
|
.await
|
||||||
let Some(rewritten) = rewrite_persona_name_content(&content, persona_name) else {
|
.wrap_err_with(|| format!("failed to read {}", path.display()))?;
|
||||||
return Ok(false);
|
let Some(rewritten) = rewrite_persona_name_content(&content, persona_name) else {
|
||||||
};
|
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),
|
||||||
|
|||||||
Reference in New Issue
Block a user