//! 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 `/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 { 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 { 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 { 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> { 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> { 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 { 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 { 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"); } }