18f21bdf30
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
503 lines
18 KiB
Rust
503 lines
18 KiB
Rust
//! One-shot launcher-setting application performed the first time a game is played.
|
|
//!
|
|
//! Some games ship per-user setting files somewhere under their installed
|
|
//! `local/` tree — an `account_name.txt`, a `language.txt`, and/or a
|
|
//! `SmartSteamEmu.ini` carrying a `PersonaName = ...` line. The first time the
|
|
//! user launches a game we stamp the launcher's configured username and language
|
|
//! into whichever of those files exist, then record a per-game marker so the
|
|
//! step never runs again.
|
|
//!
|
|
//! The marker only records that we *tried*: it is written unconditionally after
|
|
//! the first attempt, whether or not any file or matching line was found. Moving
|
|
//! this out of the install transaction means already-installed games are fixed
|
|
//! up on their next play rather than only on a fresh (re)install.
|
|
|
|
use std::{
|
|
ffi::OsStr,
|
|
io::ErrorKind,
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
use eyre::WrapErr;
|
|
|
|
use crate::state_paths::launch_settings_applied_path;
|
|
|
|
const LOCAL_DIR: &str = "local";
|
|
const ACCOUNT_NAME_FILE: &str = "account_name.txt";
|
|
const LANGUAGE_FILE: &str = "language.txt";
|
|
const SMART_STEAM_EMU_INI: &str = "SmartSteamEmu.ini";
|
|
const PERSONA_NAME_KEY: &str = "PersonaName";
|
|
|
|
/// What the one-shot launcher-setting step did for a game.
|
|
///
|
|
/// These flags are an independent, observable report of each file's fate rather
|
|
/// than a state machine, so a plain record of bools is the clearest shape here.
|
|
#[allow(clippy::struct_excessive_bools)]
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
|
pub struct LaunchSettingsOutcome {
|
|
/// The marker already existed, so nothing was searched or changed.
|
|
pub already_applied: bool,
|
|
/// An `account_name.txt` was found and overwritten with the username.
|
|
pub account_name_written: bool,
|
|
/// A `language.txt` was found and overwritten with the language.
|
|
pub language_written: bool,
|
|
/// A `SmartSteamEmu.ini` `PersonaName` line was found and rewritten.
|
|
pub persona_name_written: bool,
|
|
}
|
|
|
|
/// Apply launcher settings once for `game_id`, then mark the attempt as done.
|
|
///
|
|
/// 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 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,
|
|
game_id: &str,
|
|
account_name: Option<&str>,
|
|
language: Option<&str>,
|
|
) -> eyre::Result<LaunchSettingsOutcome> {
|
|
let marker = launch_settings_applied_path(state_dir, game_id);
|
|
if tokio::fs::try_exists(&marker).await.unwrap_or(false) {
|
|
return Ok(LaunchSettingsOutcome {
|
|
already_applied: true,
|
|
..LaunchSettingsOutcome::default()
|
|
});
|
|
}
|
|
|
|
let local_root = game_root.join(LOCAL_DIR);
|
|
let outcome = LaunchSettingsOutcome {
|
|
already_applied: false,
|
|
account_name_written: overwrite_first_file(&local_root, ACCOUNT_NAME_FILE, account_name)
|
|
.await?,
|
|
language_written: overwrite_first_file(&local_root, LANGUAGE_FILE, language).await?,
|
|
persona_name_written: rewrite_first_persona_name(&local_root, account_name).await?,
|
|
};
|
|
|
|
mark_applied(&marker).await?;
|
|
Ok(outcome)
|
|
}
|
|
|
|
/// Overwrite the first file named `file_name` under `root` with `value`.
|
|
///
|
|
/// Returns `false` without touching anything when `value` is `None` or no such
|
|
/// file exists.
|
|
async fn overwrite_first_file(
|
|
root: &Path,
|
|
file_name: &str,
|
|
value: Option<&str>,
|
|
) -> eyre::Result<bool> {
|
|
let Some(value) = value else {
|
|
return Ok(false);
|
|
};
|
|
let Some(path) = find_first_file(root, file_name).await? else {
|
|
return Ok(false);
|
|
};
|
|
|
|
tokio::fs::write(&path, value)
|
|
.await
|
|
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
|
|
Ok(true)
|
|
}
|
|
|
|
/// 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);
|
|
};
|
|
|
|
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()))?;
|
|
return Ok(true);
|
|
}
|
|
|
|
Ok(false)
|
|
}
|
|
|
|
/// Find the first regular file named `file_name` anywhere under `root`.
|
|
///
|
|
/// A missing `root` (for example an uninstalled game with no `local/`) yields
|
|
/// `None`. Directories are visited in sorted order for deterministic results.
|
|
async fn find_first_file(root: &Path, file_name: &str) -> eyre::Result<Option<PathBuf>> {
|
|
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) {
|
|
return Ok(Some(path));
|
|
}
|
|
}
|
|
|
|
child_dirs.sort();
|
|
child_dirs.reverse();
|
|
pending_dirs.extend(child_dirs);
|
|
}
|
|
|
|
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.
|
|
fn rewrite_persona_name_content(content: &str, persona_name: &str) -> Option<String> {
|
|
let mut output = String::with_capacity(content.len() + persona_name.len());
|
|
let mut replaced = false;
|
|
for segment in content.split_inclusive('\n') {
|
|
if replaced {
|
|
output.push_str(segment);
|
|
continue;
|
|
}
|
|
|
|
let (body, ending) = split_trailing_newline(segment);
|
|
if let Some(new_body) = rewrite_persona_line(body, persona_name) {
|
|
output.push_str(&new_body);
|
|
output.push_str(ending);
|
|
replaced = true;
|
|
} else {
|
|
output.push_str(segment);
|
|
}
|
|
}
|
|
|
|
replaced.then_some(output)
|
|
}
|
|
|
|
/// Split a `split_inclusive('\n')` segment into its body and trailing newline.
|
|
fn split_trailing_newline(segment: &str) -> (&str, &str) {
|
|
if let Some(body) = segment.strip_suffix("\r\n") {
|
|
(body, "\r\n")
|
|
} else if let Some(body) = segment.strip_suffix('\n') {
|
|
(body, "\n")
|
|
} else {
|
|
(segment, "")
|
|
}
|
|
}
|
|
|
|
/// Rewrite a single line matching `^\s*PersonaName\s*=\s*...` to use `persona_name`.
|
|
///
|
|
/// Leading whitespace and the spacing around `=` are preserved; everything after
|
|
/// the separator's trailing whitespace is replaced with `persona_name`.
|
|
fn rewrite_persona_line(line: &str, persona_name: &str) -> Option<String> {
|
|
let leading_len = line.len() - line.trim_start().len();
|
|
let (leading, rest) = line.split_at(leading_len);
|
|
|
|
let rest = rest.strip_prefix(PERSONA_NAME_KEY)?;
|
|
let mid_len = rest.len() - rest.trim_start().len();
|
|
let (mid_ws, after_mid) = rest.split_at(mid_len);
|
|
|
|
let after_eq = after_mid.strip_prefix('=')?;
|
|
let post_len = after_eq.len() - after_eq.trim_start().len();
|
|
let post_ws = &after_eq[..post_len];
|
|
|
|
Some(format!(
|
|
"{leading}{PERSONA_NAME_KEY}{mid_ws}={post_ws}{persona_name}"
|
|
))
|
|
}
|
|
|
|
async fn mark_applied(marker: &Path) -> eyre::Result<()> {
|
|
if let Some(parent) = marker.parent() {
|
|
tokio::fs::create_dir_all(parent)
|
|
.await
|
|
.wrap_err_with(|| format!("failed to create {}", parent.display()))?;
|
|
}
|
|
tokio::fs::write(marker, [])
|
|
.await
|
|
.wrap_err_with(|| format!("failed to write {}", marker.display()))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::test_support::TempDir;
|
|
|
|
#[test]
|
|
fn rewrites_simple_line_and_preserves_unix_ending() {
|
|
let content = "[User]\nPersonaName = stubname\nLanguage = english\n";
|
|
let rewritten =
|
|
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
|
|
assert_eq!(
|
|
rewritten,
|
|
"[User]\nPersonaName = realuser\nLanguage = english\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_crlf_line_ending() {
|
|
let content = "[User]\r\nPersonaName = stubname\r\nLanguage = english\r\n";
|
|
let rewritten =
|
|
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
|
|
assert_eq!(
|
|
rewritten,
|
|
"[User]\r\nPersonaName = realuser\r\nLanguage = english\r\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_leading_whitespace_and_separator_spacing() {
|
|
let content = "\tPersonaName=stubname\n";
|
|
let rewritten =
|
|
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
|
|
assert_eq!(rewritten, "\tPersonaName=realuser\n");
|
|
}
|
|
|
|
#[test]
|
|
fn rewrites_final_line_without_trailing_newline() {
|
|
let content = "PersonaName = stubname";
|
|
let rewritten =
|
|
rewrite_persona_name_content(content, "realuser").expect("line should be rewritten");
|
|
assert_eq!(rewritten, "PersonaName = realuser");
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_similar_keys() {
|
|
assert!(rewrite_persona_line("PersonaNameExtra = x", "realuser").is_none());
|
|
assert!(rewrite_persona_line("MyPersonaName = x", "realuser").is_none());
|
|
assert!(rewrite_persona_line("PersonaName foo", "realuser").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn returns_none_when_no_persona_line() {
|
|
let content = "[User]\nLanguage = english\n";
|
|
assert!(rewrite_persona_name_content(content, "realuser").is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn applies_username_to_both_files_then_marks_done() {
|
|
let state = TempDir::new("lanspread-launch-state");
|
|
let game = TempDir::new("lanspread-launch-game");
|
|
let root = game.path();
|
|
let account = root.join(LOCAL_DIR).join("profile").join(ACCOUNT_NAME_FILE);
|
|
let ini = root
|
|
.join(LOCAL_DIR)
|
|
.join("config")
|
|
.join("emu")
|
|
.join(SMART_STEAM_EMU_INI);
|
|
let language = root.join(LOCAL_DIR).join(LANGUAGE_FILE);
|
|
write_file(&account, b"stubname");
|
|
write_file(&ini, b"[User]\r\nPersonaName = stubname\r\n");
|
|
write_file(&language, b"english");
|
|
|
|
let outcome = apply_launch_settings_once(
|
|
state.path(),
|
|
root,
|
|
"game",
|
|
Some("realuser"),
|
|
Some("german"),
|
|
)
|
|
.await
|
|
.expect("apply should succeed");
|
|
|
|
assert_eq!(
|
|
outcome,
|
|
LaunchSettingsOutcome {
|
|
already_applied: false,
|
|
account_name_written: true,
|
|
language_written: true,
|
|
persona_name_written: true,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(&account).expect("account file should be readable"),
|
|
"realuser"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(&ini).expect("ini should be readable"),
|
|
"[User]\r\nPersonaName = realuser\r\n"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(&language).expect("language file should be readable"),
|
|
"german"
|
|
);
|
|
assert!(launch_settings_applied_path(state.path(), "game").is_file());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn is_noop_once_marker_exists() {
|
|
let state = TempDir::new("lanspread-launch-state");
|
|
let game = TempDir::new("lanspread-launch-game");
|
|
let root = game.path();
|
|
let ini = root.join(LOCAL_DIR).join(SMART_STEAM_EMU_INI);
|
|
write_file(&ini, b"PersonaName = stubname\n");
|
|
|
|
let first = apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
|
|
.await
|
|
.expect("first apply should succeed");
|
|
assert!(first.persona_name_written);
|
|
assert!(!first.already_applied);
|
|
|
|
// Externally reset the value; a second apply must not touch it again.
|
|
write_file(&ini, b"PersonaName = stubname\n");
|
|
let second = apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
|
|
.await
|
|
.expect("second apply should succeed");
|
|
|
|
assert!(second.already_applied);
|
|
assert!(!second.persona_name_written);
|
|
assert_eq!(
|
|
std::fs::read_to_string(&ini).expect("ini should be readable"),
|
|
"PersonaName = stubname\n"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn marks_done_even_when_nothing_found() {
|
|
let state = TempDir::new("lanspread-launch-state");
|
|
let game = TempDir::new("lanspread-launch-game");
|
|
let root = game.path();
|
|
write_file(
|
|
&root.join(LOCAL_DIR).join("readme.txt"),
|
|
b"no settings here",
|
|
);
|
|
|
|
let outcome = apply_launch_settings_once(
|
|
state.path(),
|
|
root,
|
|
"game",
|
|
Some("realuser"),
|
|
Some("german"),
|
|
)
|
|
.await
|
|
.expect("apply should succeed");
|
|
|
|
assert_eq!(outcome, LaunchSettingsOutcome::default());
|
|
assert!(launch_settings_applied_path(state.path(), "game").is_file());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn marks_done_when_local_missing() {
|
|
let state = TempDir::new("lanspread-launch-state");
|
|
let game = TempDir::new("lanspread-launch-game");
|
|
|
|
let outcome = apply_launch_settings_once(
|
|
state.path(),
|
|
game.path(),
|
|
"game",
|
|
Some("realuser"),
|
|
Some("german"),
|
|
)
|
|
.await
|
|
.expect("apply should succeed");
|
|
|
|
assert_eq!(outcome, LaunchSettingsOutcome::default());
|
|
assert!(launch_settings_applied_path(state.path(), "game").is_file());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn overwrites_first_account_file_in_sorted_order() {
|
|
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(ACCOUNT_NAME_FILE);
|
|
let second = root.join(LOCAL_DIR).join("z").join(ACCOUNT_NAME_FILE);
|
|
write_file(&first, b"old-a");
|
|
write_file(&second, b"old-z");
|
|
|
|
apply_launch_settings_once(state.path(), root, "game", Some("realuser"), None)
|
|
.await
|
|
.expect("apply should succeed");
|
|
|
|
assert_eq!(
|
|
std::fs::read_to_string(&first).expect("first account file should be readable"),
|
|
"realuser"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(&second).expect("second account file should be readable"),
|
|
"old-z"
|
|
);
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
std::fs::write(path, bytes).expect("file should be written");
|
|
}
|
|
}
|