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:
@@ -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<dyn Unpacker>,
|
||||
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<dyn Unpacker>,
|
||||
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<dyn Unpacker>,
|
||||
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<dyn Unpacker>,
|
||||
account_name: Option<&str>,
|
||||
language: Option<&str>,
|
||||
) -> eyre::Result<()> {
|
||||
async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> 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<Vec<PathBuf>> {
|
||||
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<Option<PathBuf>> {
|
||||
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());
|
||||
|
||||
Reference in New Issue
Block a user