feat(peer): stamp launcher settings on first play, add PersonaName rewrite

Some games ship a SmartSteamEmu.ini somewhere under their installed
local/ tree with a `PersonaName = ...` line that must carry the player's
configured username. They also ship account_name.txt and language.txt
files that the launcher already overwrote with the username/language.

Previously that account_name.txt/language.txt overwrite happened inside
the install transaction, so it only applied to freshly (re)installed
games — games already installed by an older build never got fixed up,
and the SmartSteamEmu.ini PersonaName line was not handled at all.

This moves all per-user setting application out of install and into a
single one-shot step performed the first time a game is played, gated by
a new per-game marker `games/<id>/launch_settings_applied` under the
state dir. On first play we search the whole local/ tree and stamp:

  - the username into the first account_name.txt,
  - the language into the first language.txt,
  - the username into the first SmartSteamEmu.ini PersonaName line,
    preserving that line's existing line ending (\n or \r\n) and its
    surrounding whitespace, leaving sibling lines untouched.

The marker only records that we *tried*: it is written unconditionally
after the first play, so a game with none of these files is still marked
done and never rescanned. Because already-installed games have no marker
yet, they are fixed up on their next play rather than only on reinstall.

To keep the marker honest across version changes, the install and update
transactions now clear it on success, so a freshly extracted local/ is
re-stamped on the next play.

Behavior changes from the user's perspective:
  - The first time you press Play after this change, your username/
    language are (re)applied to an existing install, including games you
    installed before this feature existed.
  - SmartSteamEmu.ini's PersonaName now reflects the launcher username.

Plumbing: account_name/language are removed from PeerCommand::InstallGame
/DownloadGameFiles[WithOptions] and the whole install handler chain, and
the Tauri pending_install_settings bookkeeping is gone — the launcher now
computes the values at play time in run_game and calls
lanspread_peer::apply_launch_settings_once. The headless harness gains a
`play` command exposing the same step for scripted testing.

Test Plan
  - just test: new lanspread_peer::launch_settings unit tests cover the
    PersonaName rewrite, \n/\r\n preservation, first-match search, the
    unconditional marker, and the no-op-once-applied path; a transaction
    test covers the install marker reset. Whole workspace is green.
  - just clippy clean; the change adds no new clippy warnings (incl.
    --tests).
  - S38 (new in PEER_CLI_SCENARIOS.md): host run of lanspread-peer-cli
    against the new fixture-persona/css RAR .eti (with --unrar) installs
    css, then `play css` stamps the deeply-buried CRLF PersonaName line,
    account_name.txt, and language.txt and creates the marker; a second
    `play` is a no-op even after the values are reset externally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 05:39:41 +02:00
parent 9bafd981d7
commit 09709cc008
11 changed files with 611 additions and 297 deletions
@@ -0,0 +1,439 @@
//! 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 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.
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 `PersonaName` line in the first `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);
};
tokio::fs::write(&path, rewritten)
.await
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
Ok(true)
}
/// 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)
}
/// 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"
);
}
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");
}
}