feat(install): write launcher language marker files

Some games include a language.txt marker in the unpacked local tree, similar
in spirit to account_name.txt. Installs and updates now carry the launcher
language alongside the account name so those game-provided marker files are
rewritten before staged files are promoted into local/.

The Tauri command boundary keeps the UI setting vocabulary as de/en, then maps
it to the file vocabulary expected by games: german or english. Unknown values
continue through the existing DEFAULT_LANGUAGE path, so the marker file falls
back to english just like script launch arguments fall back to en.

The transaction layer deliberately reuses the same first-match traversal helper
for both marker files. The searches stay independent, so games may place
account_name.txt and language.txt in different directories if their archive
layout requires that.

Test Plan:
- just fmt
- just test
- just frontend-test
- just clippy
- deno task build
- git diff --check

Refs: none
This commit is contained in:
2026-05-21 22:24:59 +02:00
parent e06a887da1
commit 9bafd981d7
7 changed files with 162 additions and 56 deletions
@@ -21,6 +21,7 @@ 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,6 +42,7 @@ pub async fn install(
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(
@@ -50,7 +52,7 @@ pub async fn install(
)
.await?;
let result = install_inner(game_root, id, unpacker, account_name).await;
let result = install_inner(game_root, id, unpacker, account_name, language).await;
match result {
Ok(()) => {
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
@@ -75,6 +77,7 @@ pub async fn update(
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(
@@ -84,7 +87,7 @@ pub async fn update(
)
.await?;
let result = update_inner(game_root, id, unpacker, account_name).await;
let result = update_inner(game_root, id, unpacker, account_name, language).await;
match result {
Ok(()) => {
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
@@ -193,6 +196,7 @@ async fn install_inner(
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 {
@@ -202,7 +206,8 @@ async fn install_inner(
let staging = installing_dir(game_root);
prepare_owned_empty_dir(&staging).await?;
unpack_archives(game_root, &staging, unpacker).await?;
write_account_name_if_present(&staging, account_name).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}"))?;
@@ -214,6 +219,7 @@ async fn update_inner(
id: &str,
unpacker: Arc<dyn Unpacker>,
account_name: Option<&str>,
language: Option<&str>,
) -> eyre::Result<()> {
let local = local_dir(game_root);
let backup = backup_dir(game_root);
@@ -230,7 +236,8 @@ async fn update_inner(
prepare_owned_empty_dir(&staging).await?;
unpack_archives(game_root, &staging, unpacker).await?;
write_account_name_if_present(&staging, account_name).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}"))?;
@@ -284,25 +291,26 @@ async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
Ok(archives)
}
async fn write_account_name_if_present(
async fn write_install_setting_if_present(
install_root: &Path,
account_name: Option<&str>,
file_name: &str,
value: Option<&str>,
) -> eyre::Result<()> {
let Some(account_name) = account_name else {
let Some(value) = value else {
return Ok(());
};
let Some(path) = find_account_name_file(install_root).await? else {
let Some(path) = find_install_setting_file(install_root, file_name).await? else {
return Ok(());
};
tokio::fs::write(&path, account_name)
tokio::fs::write(&path, value)
.await
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
Ok(())
}
async fn find_account_name_file(root: &Path) -> eyre::Result<Option<PathBuf>> {
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?;
@@ -310,7 +318,7 @@ async fn find_account_name_file(root: &Path) -> eyre::Result<Option<PathBuf>> {
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(ACCOUNT_NAME_FILE) && file_type.is_file() {
if entry.file_name() == OsStr::new(file_name) && file_type.is_file() {
return Ok(Some(path));
}
if file_type.is_dir() {
@@ -630,9 +638,16 @@ 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)
.await
.expect("install should succeed");
install(
&root,
state.path(),
"game",
successful_unpacker(),
None,
None,
)
.await
.expect("install should succeed");
assert!(root.join("local").join("payload.txt").is_file());
assert!(!root.join(".local.installing").exists());
@@ -654,6 +669,7 @@ mod tests {
"game",
successful_unpacker(),
Some("Alice"),
None,
)
.await
.expect("install should succeed without account file");
@@ -672,7 +688,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)
install(&root, state.path(), "game", unpacker.clone(), None, None)
.await
.expect("install should succeed");
@@ -687,16 +703,18 @@ mod tests {
}
#[tokio::test]
async fn install_overwrites_first_account_name_file() {
struct AccountNameUnpacker;
async fn install_overwrites_first_install_setting_files() {
struct InstallSettingsUnpacker;
impl Unpacker for AccountNameUnpacker {
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(())
})
}
@@ -712,8 +730,9 @@ mod tests {
&root,
state.path(),
"game",
Arc::new(AccountNameUnpacker),
Arc::new(InstallSettingsUnpacker),
Some("Alice"),
Some("german"),
)
.await
.expect("install should succeed");
@@ -728,6 +747,16 @@ mod tests {
.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]
@@ -745,6 +774,7 @@ mod tests {
"game",
Arc::new(FakeUnpacker::failing()),
None,
None,
)
.await
.expect_err("update should fail");
@@ -772,6 +802,7 @@ mod tests {
"game",
Arc::new(FakeUnpacker::commit_conflict()),
None,
None,
)
.await
.expect_err("update should fail at commit rename");
@@ -801,9 +832,16 @@ 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)
.await
.expect("update should succeed");
update(
&root,
state.path(),
"game",
successful_unpacker(),
None,
None,
)
.await
.expect("update should succeed");
assert!(root.join("local").join("payload.txt").is_file());
assert!(!root.join("local").join("old.txt").exists());