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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user