diff --git a/PEER_CLI_SCENARIOS.md b/PEER_CLI_SCENARIOS.md index 8405cf6..a7aee4a 100644 --- a/PEER_CLI_SCENARIOS.md +++ b/PEER_CLI_SCENARIOS.md @@ -45,6 +45,7 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path. | S35 | Unknown game ID from remote peer | A remote peer advertises a game ID that is not in the receiver's catalog. | The receiver does not list the unknown game as downloadable, download attempts fail deterministically, and no local files are created. | | S36 | Latest singleton beats stale majority | Five peers advertise one game; one peer has `20260501`, four peers have `20250101`. | `list-games` reports `eti_game_version=20260501`; all descriptors and chunks come from the singleton latest peer; stale peers contribute zero bytes. | | S37 | Single-source download throughput | A source peer advertises a temporary catalog game with one sparse `2 GiB` `.eti`; an empty client downloads it with `install=false`. | The client emits `download-finished` with throughput measurements (`bytes`, `duration_ms`, `mib_per_s`, `mbit_per_s`), and the downloaded archive size matches the source. | +| S38 | First-play launch-setting stamping | `fixture-persona/css` ships a real RAR `.eti` whose tree buries a CRLF `SmartSteamEmu.ini` with a stub `PersonaName` line under `engine/bin/win64/steam_settings/`, plus a stub `account_name.txt` and `language.txt` under `profiles/local/`. A peer installs `css` (with `--unrar`), then sends `play css` with a username and language, then `play css` again. | After install the marker `games/css/launch_settings_applied` is absent and the stub files are intact under `local/`. The first `play` returns `already_applied=false` with `account_name_written`, `language_written`, and `persona_name_written` all true; the deep `SmartSteamEmu.ini` `PersonaName` value becomes the username with its `\r\n` ending and sibling lines preserved, `account_name.txt` becomes the username, `language.txt` becomes the passed language, and the marker now exists. A second `play` returns `already_applied=true`, rewrites nothing, and leaves the files untouched even if their values were reset externally. | ## Version-Skew Contract @@ -91,8 +92,44 @@ different `version.ini` contents. The existing alpha/bravo/charlie fixtures cover duplicate-source and shared-game cases, but not the three-version skew until a dedicated fixture or temporary games root is prepared. +## First-Play Launch-Setting Contract + +Use S38 to pin down how launcher settings are stamped into an installed game: + +- Stamping happens on the first `play`, not during install/update. The install + transaction only clears the `games//launch_settings_applied` marker so the + next play reapplies settings to a freshly (re)created `local/`. +- The first play stamps the username into the first `account_name.txt` and the + first `SmartSteamEmu.ini` `PersonaName` line, and the language into the first + `language.txt`, searching the whole `local/` tree. The matched `PersonaName` + line keeps its existing line ending (`\n` or `\r\n`). +- The marker records only that we *tried*: it is written unconditionally after + the first play, so a game with none of these files is still marked done. +- S38 needs a real archive expanded with `--unrar`, so it runs against the host + `lanspread-peer-cli` binary rather than the Docker matrix image (which omits + `unrar`). The peer crate's `launch_settings` unit tests cover the rewrite, + line-ending, and marker logic deterministically. + ## Run Log +### 2026-05-28 - First-Play Launch-Setting Stamping (S38) + +- Code under test moved the `account_name.txt`/`language.txt` overwrite out of + the install transaction and into a single first-play step (shared with the new + `SmartSteamEmu.ini` `PersonaName` rewrite) gated by the + `games//launch_settings_applied` marker. +- `just test` passed the whole workspace, including the new + `lanspread_peer::launch_settings` unit tests and + `install::transaction::install_resets_launch_settings_marker`. +- S38 host run: built `crates/lanspread-peer-cli/fixtures/fixture-persona/css` + with a stored RAR `.eti` (verified by `unrar t`) burying a CRLF + `SmartSteamEmu.ini` plus stub `account_name.txt`/`language.txt`. A host peer + installed `css` with `--unrar /usr/bin/unrar`, then `play css` stamped the + username into the deep `PersonaName` line (CRLF preserved, sibling lines + intact) and `account_name.txt`, the language into `language.txt`, and created + the marker. A second `play css` returned `already_applied=true` and rewrote + nothing even after the value was reset externally. + ### 2026-05-19 - Snapshot Status Fix Docker Matrix Pass - Code under test included `5c4976d` (`fix(peer): settle local state before diff --git a/crates/lanspread-peer-cli/fixtures/fixture-persona/css/css.eti b/crates/lanspread-peer-cli/fixtures/fixture-persona/css/css.eti new file mode 100644 index 0000000..39bc0c6 Binary files /dev/null and b/crates/lanspread-peer-cli/fixtures/fixture-persona/css/css.eti differ diff --git a/crates/lanspread-peer-cli/fixtures/fixture-persona/css/version.ini b/crates/lanspread-peer-cli/fixtures/fixture-persona/css/version.ini new file mode 100644 index 0000000..0cca4ac --- /dev/null +++ b/crates/lanspread-peer-cli/fixtures/fixture-persona/css/version.ini @@ -0,0 +1 @@ +20250101 \ No newline at end of file diff --git a/crates/lanspread-peer-cli/src/lib.rs b/crates/lanspread-peer-cli/src/lib.rs index 72746ba..78e4315 100644 --- a/crates/lanspread-peer-cli/src/lib.rs +++ b/crates/lanspread-peer-cli/src/lib.rs @@ -39,6 +39,11 @@ pub enum CliCommand { Uninstall { game_id: String, }, + Play { + game_id: String, + username: String, + language: Option, + }, WaitPeers { count: usize, timeout: Duration, @@ -60,6 +65,7 @@ impl CliCommand { Self::Download { .. } => "download", Self::Install { .. } => "install", Self::Uninstall { .. } => "uninstall", + Self::Play { .. } => "play", Self::WaitPeers { .. } => "wait-peers", Self::Connect { .. } => "connect", Self::Shutdown => "shutdown", @@ -101,6 +107,14 @@ pub fn parse_command_value(value: &Value) -> eyre::Result { "uninstall" => CliCommand::Uninstall { game_id: game_id(object)?, }, + "play" => CliCommand::Play { + game_id: game_id(object)?, + username: required_str(object, "username")?, + language: object + .get("language") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + }, "wait-peers" => CliCommand::WaitPeers { count: required_u64(object, "count")? .try_into() diff --git a/crates/lanspread-peer-cli/src/main.rs b/crates/lanspread-peer-cli/src/main.rs index e966947..81dab6e 100644 --- a/crates/lanspread-peer-cli/src/main.rs +++ b/crates/lanspread-peer-cli/src/main.rs @@ -116,6 +116,8 @@ struct SharedState { peer_game_db: Arc>, catalog: Arc>>, notify: Notify, + games_dir: PathBuf, + state_dir: PathBuf, } #[tokio::main] @@ -153,6 +155,8 @@ async fn main() -> eyre::Result<()> { peer_game_db, catalog: catalog.clone(), notify: Notify::new(), + games_dir: args.games_dir.clone(), + state_dir: args.state_dir.clone(), }); let writer = JsonlWriter::new(); @@ -240,8 +244,6 @@ async fn handle_command( id: game_id.clone(), file_descriptions: files, install_after_download: *install_after_download, - account_name: None, - language: None, })?; Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download})) } @@ -250,11 +252,14 @@ async fn handle_command( ensure_no_active_operation(shared, game_id).await?; sender.send(PeerCommand::InstallGame { id: game_id.clone(), - account_name: None, - language: None, })?; Ok(json!({"queued": true, "game_id": game_id})) } + CliCommand::Play { + game_id, + username, + language, + } => play(shared, game_id, username, language.as_deref()).await, CliCommand::Uninstall { game_id } => { ensure_catalog_game(shared, game_id).await?; ensure_no_active_operation(shared, game_id).await?; @@ -314,6 +319,25 @@ async fn list_games(shared: &SharedState) -> eyre::Result { })) } +async fn play( + shared: &SharedState, + game_id: &str, + username: &str, + language: Option<&str>, +) -> eyre::Result { + ensure_catalog_game(shared, game_id).await?; + let game_root = shared.games_dir.join(game_id); + let outcome = lanspread_peer::apply_launch_settings_once( + &shared.state_dir, + &game_root, + game_id, + Some(username), + language, + ) + .await?; + Ok(json!({ "game_id": game_id, "outcome": outcome })) +} + async fn ensure_catalog_game(shared: &SharedState, game_id: &str) -> eyre::Result<()> { if shared.catalog.read().await.contains(game_id) { return Ok(()); diff --git a/crates/lanspread-peer/src/handlers.rs b/crates/lanspread-peer/src/handlers.rs index 59e6e0f..8adca68 100644 --- a/crates/lanspread-peer/src/handlers.rs +++ b/crates/lanspread-peer/src/handlers.rs @@ -199,8 +199,6 @@ pub async fn handle_download_game_files_command( id: String, file_descriptions: Vec, install_after_download: bool, - account_name: Option, - language: Option, ) { log::info!("Got PeerCommand::DownloadGameFiles"); if !catalog_contains(ctx, &id).await { @@ -278,7 +276,7 @@ pub async fn handle_download_game_files_command( log::error!("Failed to send DownloadGameFilesFinished event: {e}"); } if install_after_download { - spawn_install_operation(ctx, tx_notify_ui, id.clone(), account_name, language); + spawn_install_operation(ctx, tx_notify_ui, id.clone()); } } else { log::error!("No trusted peers available after majority validation for game {id}"); @@ -364,8 +362,6 @@ pub async fn handle_download_game_files_command( &tx_notify_ui_clone, download_id, prepared, - account_name, - language, ) .await; } else { @@ -423,10 +419,8 @@ pub async fn handle_install_game_command( ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: String, - account_name: Option, - language: Option, ) { - spawn_install_operation(ctx, tx_notify_ui, id, account_name, language); + spawn_install_operation(ctx, tx_notify_ui, id); } /// Handles the `UninstallGame` command. @@ -469,27 +463,15 @@ pub async fn handle_cancel_download_command( cancel_token.cancel(); } -fn spawn_install_operation( - ctx: &Ctx, - tx_notify_ui: &UnboundedSender, - id: String, - account_name: Option, - language: Option, -) { +fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: String) { let ctx = ctx.clone(); let tx_notify_ui = tx_notify_ui.clone(); ctx.task_tracker.clone().spawn(async move { - run_install_operation(&ctx, &tx_notify_ui, id, account_name, language).await; + run_install_operation(&ctx, &tx_notify_ui, id).await; }); } -async fn run_install_operation( - ctx: &Ctx, - tx_notify_ui: &UnboundedSender, - id: String, - account_name: Option, - language: Option, -) { +async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: String) { let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &id).await else { return; }; @@ -499,7 +481,7 @@ async fn run_install_operation( return; } - run_started_install_operation(ctx, tx_notify_ui, id, prepared, account_name, language).await; + run_started_install_operation(ctx, tx_notify_ui, id, prepared).await; } struct PreparedInstallOperation { @@ -551,8 +533,6 @@ async fn run_started_install_operation( tx_notify_ui: &UnboundedSender, id: String, prepared: PreparedInstallOperation, - account_name: Option, - language: Option, ) { let PreparedInstallOperation { game_root, @@ -577,26 +557,10 @@ async fn run_started_install_operation( let state_dir = ctx.state_dir.as_ref(); match operation { InstallOperation::Installing => { - install::install( - &game_root, - state_dir, - &id, - ctx.unpacker.clone(), - account_name.as_deref(), - language.as_deref(), - ) - .await + install::install(&game_root, state_dir, &id, ctx.unpacker.clone()).await } InstallOperation::Updating => { - install::update( - &game_root, - state_dir, - &id, - ctx.unpacker.clone(), - account_name.as_deref(), - language.as_deref(), - ) - .await + install::update(&game_root, state_dir, &id, ctx.unpacker.clone()).await } } }; @@ -1511,7 +1475,7 @@ mod tests { update_and_announce_games(&ctx, &tx, scan).await; assert_local_update(recv_event(&mut rx).await, true, true); - run_install_operation(&ctx, &tx, "game".to_string(), None, None).await; + run_install_operation(&ctx, &tx, "game".to_string()).await; assert_active_update( recv_event(&mut rx).await, @@ -1542,7 +1506,7 @@ mod tests { let ctx = test_ctx(temp.path().to_path_buf()); let (tx, mut rx) = mpsc::unbounded_channel(); - run_install_operation(&ctx, &tx, "game".to_string(), None, None).await; + run_install_operation(&ctx, &tx, "game".to_string()).await; assert_active_update( recv_event(&mut rx).await, @@ -1596,8 +1560,7 @@ mod tests { .await ); clear_active_download(&ctx, "game").await; - run_started_install_operation(&ctx, &tx, "game".to_string(), prepared, None, None) - .await; + run_started_install_operation(&ctx, &tx, "game".to_string(), prepared).await; } }); @@ -1664,7 +1627,7 @@ mod tests { let ctx = test_ctx(temp.path().to_path_buf()); let (tx, mut rx) = mpsc::unbounded_channel(); - run_install_operation(&ctx, &tx, "game".to_string(), None, None).await; + run_install_operation(&ctx, &tx, "game".to_string()).await; assert_active_update( recv_event(&mut rx).await, @@ -1696,7 +1659,7 @@ mod tests { let ctx = test_ctx(temp.path().to_path_buf()); let (tx, mut rx) = mpsc::unbounded_channel(); - run_install_operation(&ctx, &tx, "game".to_string(), None, None).await; + run_install_operation(&ctx, &tx, "game".to_string()).await; assert_active_update( recv_event(&mut rx).await, active_update("game", ActiveOperationKind::Installing), @@ -1719,7 +1682,7 @@ mod tests { write_file(&root.join("version.ini"), b"20250101"); write_file(&root.join("game.eti"), b"new archive"); - run_install_operation(&ctx, &tx, "game".to_string(), None, None).await; + run_install_operation(&ctx, &tx, "game".to_string()).await; assert_active_update( recv_event(&mut rx).await, active_update("game", ActiveOperationKind::Updating), diff --git a/crates/lanspread-peer/src/install/transaction.rs b/crates/lanspread-peer/src/install/transaction.rs index 57a8643..5e204bc 100644 --- a/crates/lanspread-peer/src/install/transaction.rs +++ b/crates/lanspread-peer/src/install/transaction.rs @@ -1,6 +1,5 @@ use std::{ collections::HashSet, - ffi::OsStr, io::ErrorKind, path::{Path, PathBuf}, sync::Arc, @@ -12,7 +11,7 @@ use super::{ intent::{InstallIntent, InstallIntentState, read_intent, write_intent}, unpack::Unpacker, }; -use crate::local_games::version_ini_is_regular_file; +use crate::{local_games::version_ini_is_regular_file, state_paths::launch_settings_applied_path}; const LOCAL_DIR: &str = "local"; const INSTALLING_DIR: &str = ".local.installing"; @@ -20,8 +19,6 @@ const BACKUP_DIR: &str = ".local.backup"; const OWNED_MARKER: &str = ".lanspread_owned"; const VERSION_TMP_FILE: &str = ".version.ini.tmp"; const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded"; -const ACCOUNT_NAME_FILE: &str = "account_name.txt"; -const LANGUAGE_FILE: &str = "language.txt"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum FsEntryState { @@ -41,8 +38,6 @@ pub async fn install( state_dir: &Path, id: &str, unpacker: Arc, - account_name: Option<&str>, - language: Option<&str>, ) -> eyre::Result<()> { let eti_version = read_downloaded_version(game_root).await; write_intent( @@ -52,10 +47,11 @@ pub async fn install( ) .await?; - let result = install_inner(game_root, id, unpacker, account_name, language).await; + let result = install_inner(game_root, id, unpacker).await; match result { Ok(()) => { write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?; + reset_launch_settings_marker(state_dir, id).await; Ok(()) } Err(err) => { @@ -76,8 +72,6 @@ pub async fn update( state_dir: &Path, id: &str, unpacker: Arc, - account_name: Option<&str>, - language: Option<&str>, ) -> eyre::Result<()> { let eti_version = read_downloaded_version(game_root).await; write_intent( @@ -87,10 +81,11 @@ pub async fn update( ) .await?; - let result = update_inner(game_root, id, unpacker, account_name, language).await; + let result = update_inner(game_root, id, unpacker).await; match result { Ok(()) => { 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 { log::warn!( "Failed to clean install backup {}: {err}", @@ -195,8 +190,6 @@ async fn install_inner( game_root: &Path, id: &str, unpacker: Arc, - account_name: Option<&str>, - language: Option<&str>, ) -> eyre::Result<()> { let local = local_dir(game_root); if path_is_dir(&local).await { @@ -206,21 +199,13 @@ async fn install_inner( let staging = installing_dir(game_root); prepare_owned_empty_dir(&staging).await?; unpack_archives(game_root, &staging, unpacker).await?; - write_install_setting_if_present(&staging, ACCOUNT_NAME_FILE, account_name).await?; - write_install_setting_if_present(&staging, LANGUAGE_FILE, language).await?; tokio::fs::rename(&staging, &local) .await .wrap_err_with(|| format!("failed to promote install for {id}"))?; Ok(()) } -async fn update_inner( - game_root: &Path, - id: &str, - unpacker: Arc, - account_name: Option<&str>, - language: Option<&str>, -) -> eyre::Result<()> { +async fn update_inner(game_root: &Path, id: &str, unpacker: Arc) -> eyre::Result<()> { let local = local_dir(game_root); let backup = backup_dir(game_root); let staging = installing_dir(game_root); @@ -236,8 +221,6 @@ async fn update_inner( prepare_owned_empty_dir(&staging).await?; unpack_archives(game_root, &staging, unpacker).await?; - write_install_setting_if_present(&staging, ACCOUNT_NAME_FILE, account_name).await?; - write_install_setting_if_present(&staging, LANGUAGE_FILE, language).await?; tokio::fs::rename(&staging, &local) .await .wrap_err_with(|| format!("failed to promote update for {id}"))?; @@ -291,47 +274,18 @@ async fn root_eti_archives(game_root: &Path) -> eyre::Result> { Ok(archives) } -async fn write_install_setting_if_present( - install_root: &Path, - file_name: &str, - value: Option<&str>, -) -> eyre::Result<()> { - let Some(value) = value else { - return Ok(()); - }; - - let Some(path) = find_install_setting_file(install_root, file_name).await? else { - return Ok(()); - }; - - tokio::fs::write(&path, value) - .await - .wrap_err_with(|| format!("failed to write {}", path.display()))?; - Ok(()) -} - -async fn find_install_setting_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 = tokio::fs::read_dir(&dir).await?; - 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 entry.file_name() == OsStr::new(file_name) && file_type.is_file() { - return Ok(Some(path)); - } - if file_type.is_dir() { - child_dirs.push(path); - } - } - - child_dirs.sort(); - child_dirs.reverse(); - pending_dirs.extend(child_dirs); +/// Drop the per-game launch-settings marker so the launcher reapplies the +/// username/language to the freshly (re)created `local/` on the next play. A +/// failure here is non-fatal; it only means the launcher might skip a settings +/// 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); + if let Err(err) = remove_file_if_exists(&marker).await { + log::warn!( + "Failed to reset launch-settings marker {}: {err}", + marker.display() + ); } - - Ok(None) } async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> { @@ -638,16 +592,9 @@ mod tests { write_file(&root.join("game.eti"), b"archive"); write_file(&root.join("version.ini"), b"20250101"); - install( - &root, - state.path(), - "game", - successful_unpacker(), - None, - None, - ) - .await - .expect("install should succeed"); + install(&root, state.path(), "game", successful_unpacker()) + .await + .expect("install should succeed"); assert!(root.join("local").join("payload.txt").is_file()); assert!(!root.join(".local.installing").exists()); @@ -656,26 +603,19 @@ mod tests { } #[tokio::test] - async fn install_account_name_missing_file_is_noop() { + async fn install_resets_launch_settings_marker() { let temp = TempDir::new("lanspread-install"); let state = test_state(); let root = temp.game_root(); write_file(&root.join("game.eti"), b"archive"); write_file(&root.join("version.ini"), b"20250101"); + write_file(&launch_settings_applied_path(state.path(), "game"), b""); - install( - &root, - state.path(), - "game", - successful_unpacker(), - Some("Alice"), - None, - ) - .await - .expect("install should succeed without account file"); + install(&root, state.path(), "game", successful_unpacker()) + .await + .expect("install should succeed"); - assert!(root.join("local").join("payload.txt").is_file()); - assert!(!root.join("local").join(ACCOUNT_NAME_FILE).exists()); + assert!(!launch_settings_applied_path(state.path(), "game").exists()); } #[tokio::test] @@ -688,7 +628,7 @@ mod tests { write_file(&root.join("version.ini"), b"20250101"); let unpacker = Arc::new(FakeUnpacker::default()); - install(&root, state.path(), "game", unpacker.clone(), None, None) + install(&root, state.path(), "game", unpacker.clone()) .await .expect("install should succeed"); @@ -702,63 +642,6 @@ mod tests { assert_eq!(archives, vec!["a.eti", "b.eti"]); } - #[tokio::test] - async fn install_overwrites_first_install_setting_files() { - struct InstallSettingsUnpacker; - - impl Unpacker for InstallSettingsUnpacker { - fn unpack<'a>(&'a self, _archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> { - Box::pin(async move { - tokio::fs::create_dir_all(dest.join("a")).await?; - tokio::fs::create_dir_all(dest.join("z")).await?; - tokio::fs::write(dest.join("a").join(ACCOUNT_NAME_FILE), b"old-a").await?; - tokio::fs::write(dest.join("z").join(ACCOUNT_NAME_FILE), b"old-z").await?; - tokio::fs::write(dest.join("a").join(LANGUAGE_FILE), b"old-a").await?; - tokio::fs::write(dest.join("z").join(LANGUAGE_FILE), b"old-z").await?; - Ok(()) - }) - } - } - - let temp = TempDir::new("lanspread-install"); - let state = test_state(); - let root = temp.game_root(); - write_file(&root.join("game.eti"), b"archive"); - write_file(&root.join("version.ini"), b"20250101"); - - install( - &root, - state.path(), - "game", - Arc::new(InstallSettingsUnpacker), - Some("Alice"), - Some("german"), - ) - .await - .expect("install should succeed"); - - assert_eq!( - std::fs::read_to_string(root.join("local").join("a").join(ACCOUNT_NAME_FILE)) - .expect("first account file should be readable"), - "Alice" - ); - assert_eq!( - std::fs::read_to_string(root.join("local").join("z").join(ACCOUNT_NAME_FILE)) - .expect("second account file should be readable"), - "old-z" - ); - assert_eq!( - std::fs::read_to_string(root.join("local").join("a").join(LANGUAGE_FILE)) - .expect("first language file should be readable"), - "german" - ); - assert_eq!( - std::fs::read_to_string(root.join("local").join("z").join(LANGUAGE_FILE)) - .expect("second language file should be readable"), - "old-z" - ); - } - #[tokio::test] async fn update_failure_restores_previous_local() { let temp = TempDir::new("lanspread-install"); @@ -773,8 +656,6 @@ mod tests { state.path(), "game", Arc::new(FakeUnpacker::failing()), - None, - None, ) .await .expect_err("update should fail"); @@ -801,8 +682,6 @@ mod tests { state.path(), "game", Arc::new(FakeUnpacker::commit_conflict()), - None, - None, ) .await .expect_err("update should fail at commit rename"); @@ -832,16 +711,9 @@ mod tests { write_file(&root.join("version.ini"), b"20250101"); write_file(&root.join("local").join("old.txt"), b"old"); - update( - &root, - state.path(), - "game", - successful_unpacker(), - None, - None, - ) - .await - .expect("update should succeed"); + update(&root, state.path(), "game", successful_unpacker()) + .await + .expect("update should succeed"); assert!(root.join("local").join("payload.txt").is_file()); assert!(!root.join("local").join("old.txt").exists()); diff --git a/crates/lanspread-peer/src/launch_settings.rs b/crates/lanspread-peer/src/launch_settings.rs new file mode 100644 index 0000000..34ab8cd --- /dev/null +++ b/crates/lanspread-peer/src/launch_settings.rs @@ -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 `/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 { + 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 `PersonaName` line in the first `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); + }; + 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> { + 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 { + 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" + ); + } + + 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"); + } +} diff --git a/crates/lanspread-peer/src/lib.rs b/crates/lanspread-peer/src/lib.rs index 8f19bbd..c29cf7d 100644 --- a/crates/lanspread-peer/src/lib.rs +++ b/crates/lanspread-peer/src/lib.rs @@ -20,6 +20,7 @@ mod events; mod handlers; mod identity; mod install; +mod launch_settings; mod library; mod local_games; mod migration; @@ -77,7 +78,11 @@ use crate::{ }, state_paths::resolve_state_dir, }; -pub use crate::{startup::PeerRuntimeHandle, state_paths::setup_done_path}; +pub use crate::{ + launch_settings::{LaunchSettingsOutcome, apply_launch_settings_once}, + startup::PeerRuntimeHandle, + state_paths::{launch_settings_applied_path, setup_done_path}, +}; // ============================================================================= // Public API types @@ -229,23 +234,15 @@ pub enum PeerCommand { DownloadGameFiles { id: String, file_descriptions: Vec, - account_name: Option, - language: Option, }, /// Download game files with an explicit install policy. DownloadGameFilesWithOptions { id: String, file_descriptions: Vec, install_after_download: bool, - account_name: Option, - language: Option, }, /// Install already-downloaded archives into `local/`. - InstallGame { - id: String, - account_name: Option, - language: Option, - }, + InstallGame { id: String }, /// Remove only the `local/` install for a game. UninstallGame { id: String }, /// Remove downloaded archive files for an uninstalled game. @@ -413,26 +410,14 @@ async fn handle_peer_commands( PeerCommand::DownloadGameFiles { id, file_descriptions, - account_name, - language, } => { - handle_download_game_files_command( - ctx, - tx_notify_ui, - id, - file_descriptions, - true, - account_name, - language, - ) - .await; + handle_download_game_files_command(ctx, tx_notify_ui, id, file_descriptions, true) + .await; } PeerCommand::DownloadGameFilesWithOptions { id, file_descriptions, install_after_download, - account_name, - language, } => { handle_download_game_files_command( ctx, @@ -440,17 +425,11 @@ async fn handle_peer_commands( id, file_descriptions, install_after_download, - account_name, - language, ) .await; } - PeerCommand::InstallGame { - id, - account_name, - language, - } => { - handle_install_game_command(ctx, tx_notify_ui, id, account_name, language).await; + PeerCommand::InstallGame { id } => { + handle_install_game_command(ctx, tx_notify_ui, id).await; } PeerCommand::UninstallGame { id } => { handle_uninstall_game_command(ctx, tx_notify_ui, id).await; diff --git a/crates/lanspread-peer/src/state_paths.rs b/crates/lanspread-peer/src/state_paths.rs index 84b6a12..57eec15 100644 --- a/crates/lanspread-peer/src/state_paths.rs +++ b/crates/lanspread-peer/src/state_paths.rs @@ -5,6 +5,7 @@ const LOCAL_LIBRARY_DIR: &str = "local_library"; const LOCAL_LIBRARY_INDEX_FILE: &str = "index.json"; const GAMES_DIR: &str = "games"; const SETUP_DONE_FILE: &str = "setup_done"; +const LAUNCH_SETTINGS_APPLIED_FILE: &str = "launch_settings_applied"; pub(crate) fn resolve_state_dir(explicit: Option<&Path>) -> PathBuf { if let Some(dir) = explicit { @@ -40,3 +41,8 @@ pub(crate) fn game_state_dir(state_dir: &Path, game_id: &str) -> PathBuf { pub fn setup_done_path(state_dir: &Path, game_id: &str) -> PathBuf { game_state_dir(state_dir, game_id).join(SETUP_DONE_FILE) } + +#[must_use] +pub fn launch_settings_applied_path(state_dir: &Path, game_id: &str) -> PathBuf { + game_state_dir(state_dir, game_id).join(LAUNCH_SETTINGS_APPLIED_FILE) +} diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index 48c0194..3f8d28f 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -42,7 +42,6 @@ struct LanSpreadState { peer_game_db: Arc>, catalog: Arc>>, unpack_logs: Arc>>, - pending_install_settings: Arc>>, state_dir: OnceLock, } @@ -180,22 +179,12 @@ async fn install_game( return Ok(false); }; - let install_settings = install_settings(&language, &username); + let _ = (language, username); let handled = if let Some(peer_ctrl) = peer_ctrl { let command = if !downloaded { - state - .inner() - .pending_install_settings - .write() - .await - .insert(id.clone(), install_settings); PeerCommand::GetGame(id.clone()) } else if !installed { - PeerCommand::InstallGame { - id: id.clone(), - account_name: Some(install_settings.account_name), - language: Some(install_settings.language), - } + PeerCommand::InstallGame { id: id.clone() } } else { log::info!("Game is already installed: {id}"); return Ok(false); @@ -203,12 +192,6 @@ async fn install_game( if let Err(e) = peer_ctrl.send(command) { log::error!("Failed to send message to peer: {e:?}"); - state - .inner() - .pending_install_settings - .write() - .await - .remove(&id); return Ok(false); } true @@ -242,21 +225,10 @@ async fn update_game( let peer_ctrl_arc = state.inner().peer_ctrl.clone(); let peer_ctrl = peer_ctrl_arc.read().await.clone(); + let _ = (language, username); if let Some(peer_ctrl) = peer_ctrl { - state - .inner() - .pending_install_settings - .write() - .await - .insert(id.clone(), install_settings(&language, &username)); if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id: id.clone() }) { log::error!("Failed to send message to peer: {e:?}"); - state - .inner() - .pending_install_settings - .write() - .await - .remove(&id); return Ok(false); } Ok(true) @@ -349,13 +321,6 @@ async fn cancel_download( id: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { - state - .inner() - .pending_install_settings - .write() - .await - .remove(&id); - let is_active_download = { let active_operations = state.inner().active_operations.read().await; matches!( @@ -672,6 +637,8 @@ async fn run_game_windows( } } + apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await; + if game_start_bin.exists() { let result = run_as_admin( "cmd.exe", @@ -688,6 +655,32 @@ async fn run_game_windows( Ok(()) } +/// Stamp the launcher's username and language into the installed game's setting +/// files the first time it is played. Uses the same processed values the install +/// transaction used to write before this step moved to play time. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +async fn apply_launch_settings( + state_dir: &Path, + game_path: &Path, + id: &str, + language: &str, + username: &str, +) { + let settings = install_settings(language, username); + match lanspread_peer::apply_launch_settings_once( + state_dir, + game_path, + id, + Some(&settings.account_name), + Some(&settings.language), + ) + .await + { + Ok(outcome) => log::info!("launch settings for {id}: {outcome:?}"), + Err(e) => log::error!("failed to apply launch settings for {id}: {e}"), + } +} + #[tauri::command] async fn run_game( id: String, @@ -1504,7 +1497,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) { } PeerEvent::NoPeersHaveGame { id } => { log::warn!("PeerEvent::NoPeersHaveGame received for {id}"); - clear_pending_install_settings(app_handle, &id).await; emit_game_id_event( app_handle, "game-no-peers", @@ -1543,7 +1535,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) { } PeerEvent::DownloadGameFilesFailed { id } => { log::warn!("PeerEvent::DownloadGameFilesFailed received"); - clear_pending_install_settings(app_handle, &id).await; emit_game_id_event( app_handle, "game-download-failed", @@ -1553,7 +1544,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) { } PeerEvent::DownloadGameFilesAllPeersGone { id } => { log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}"); - clear_pending_install_settings(app_handle, &id).await; emit_game_id_event( app_handle, "game-download-peers-gone", @@ -1678,11 +1668,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) { } } -async fn clear_pending_install_settings(app_handle: &AppHandle, id: &str) { - let state = app_handle.state::(); - state.pending_install_settings.write().await.remove(id); -} - async fn handle_got_game_files( app_handle: &AppHandle, id: String, @@ -1697,17 +1682,11 @@ async fn handle_got_game_files( ); let state = app_handle.state::(); - let install_settings = state.pending_install_settings.write().await.remove(&id); - let (account_name, language) = install_settings.map_or((None, None), |settings| { - (Some(settings.account_name), Some(settings.language)) - }); let peer_ctrl = state.peer_ctrl.read().await.clone(); if let Some(peer_ctrl) = peer_ctrl && let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles { id, file_descriptions, - account_name, - language, }) { log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}");