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
+2
View File
@@ -241,6 +241,7 @@ async fn handle_command(
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,6 +251,7 @@ async fn handle_command(
sender.send(PeerCommand::InstallGame {
id: game_id.clone(),
account_name: None,
language: None,
})?;
Ok(json!({"queued": true, "game_id": game_id}))
}
+3
View File
@@ -116,6 +116,9 @@ Downloaded and installed are independent predicates:
- `installed` is true when `<game_root>/local/` is a directory. The contents of
`local/` are user-owned and are skipped by manifests, fingerprints, and file
serving.
- Install and update transactions unpack into staging, then overwrite the first
discovered game-provided `account_name.txt` and `language.txt` files under
the staged tree from launcher settings before promoting it to `local/`.
Reserved per-game paths:
+19 -10
View File
@@ -200,6 +200,7 @@ pub async fn handle_download_game_files_command(
file_descriptions: Vec<GameFileDescription>,
install_after_download: bool,
account_name: Option<String>,
language: Option<String>,
) {
log::info!("Got PeerCommand::DownloadGameFiles");
if !catalog_contains(ctx, &id).await {
@@ -277,7 +278,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);
spawn_install_operation(ctx, tx_notify_ui, id.clone(), account_name, language);
}
} else {
log::error!("No trusted peers available after majority validation for game {id}");
@@ -364,6 +365,7 @@ pub async fn handle_download_game_files_command(
download_id,
prepared,
account_name,
language,
)
.await;
} else {
@@ -422,8 +424,9 @@ pub async fn handle_install_game_command(
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
account_name: Option<String>,
language: Option<String>,
) {
spawn_install_operation(ctx, tx_notify_ui, id, account_name);
spawn_install_operation(ctx, tx_notify_ui, id, account_name, language);
}
/// Handles the `UninstallGame` command.
@@ -471,11 +474,12 @@ fn spawn_install_operation(
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
account_name: Option<String>,
language: Option<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).await;
run_install_operation(&ctx, &tx_notify_ui, id, account_name, language).await;
});
}
@@ -484,6 +488,7 @@ async fn run_install_operation(
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
account_name: Option<String>,
language: Option<String>,
) {
let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &id).await else {
return;
@@ -494,7 +499,7 @@ async fn run_install_operation(
return;
}
run_started_install_operation(ctx, tx_notify_ui, id, prepared, account_name).await;
run_started_install_operation(ctx, tx_notify_ui, id, prepared, account_name, language).await;
}
struct PreparedInstallOperation {
@@ -547,6 +552,7 @@ async fn run_started_install_operation(
id: String,
prepared: PreparedInstallOperation,
account_name: Option<String>,
language: Option<String>,
) {
let PreparedInstallOperation {
game_root,
@@ -577,6 +583,7 @@ async fn run_started_install_operation(
&id,
ctx.unpacker.clone(),
account_name.as_deref(),
language.as_deref(),
)
.await
}
@@ -587,6 +594,7 @@ async fn run_started_install_operation(
&id,
ctx.unpacker.clone(),
account_name.as_deref(),
language.as_deref(),
)
.await
}
@@ -1503,7 +1511,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).await;
run_install_operation(&ctx, &tx, "game".to_string(), None, None).await;
assert_active_update(
recv_event(&mut rx).await,
@@ -1534,7 +1542,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).await;
run_install_operation(&ctx, &tx, "game".to_string(), None, None).await;
assert_active_update(
recv_event(&mut rx).await,
@@ -1588,7 +1596,8 @@ mod tests {
.await
);
clear_active_download(&ctx, "game").await;
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared, None).await;
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared, None, None)
.await;
}
});
@@ -1655,7 +1664,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).await;
run_install_operation(&ctx, &tx, "game".to_string(), None, None).await;
assert_active_update(
recv_event(&mut rx).await,
@@ -1687,7 +1696,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).await;
run_install_operation(&ctx, &tx, "game".to_string(), None, None).await;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Installing),
@@ -1710,7 +1719,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).await;
run_install_operation(&ctx, &tx, "game".to_string(), None, None).await;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Updating),
@@ -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());
+13 -2
View File
@@ -230,6 +230,7 @@ pub enum PeerCommand {
id: String,
file_descriptions: Vec<GameFileDescription>,
account_name: Option<String>,
language: Option<String>,
},
/// Download game files with an explicit install policy.
DownloadGameFilesWithOptions {
@@ -237,11 +238,13 @@ pub enum PeerCommand {
file_descriptions: Vec<GameFileDescription>,
install_after_download: bool,
account_name: Option<String>,
language: Option<String>,
},
/// Install already-downloaded archives into `local/`.
InstallGame {
id: String,
account_name: Option<String>,
language: Option<String>,
},
/// Remove only the `local/` install for a game.
UninstallGame { id: String },
@@ -411,6 +414,7 @@ async fn handle_peer_commands(
id,
file_descriptions,
account_name,
language,
} => {
handle_download_game_files_command(
ctx,
@@ -419,6 +423,7 @@ async fn handle_peer_commands(
file_descriptions,
true,
account_name,
language,
)
.await;
}
@@ -427,6 +432,7 @@ async fn handle_peer_commands(
file_descriptions,
install_after_download,
account_name,
language,
} => {
handle_download_game_files_command(
ctx,
@@ -435,11 +441,16 @@ async fn handle_peer_commands(
file_descriptions,
install_after_download,
account_name,
language,
)
.await;
}
PeerCommand::InstallGame { id, account_name } => {
handle_install_game_command(ctx, tx_notify_ui, id, account_name).await;
PeerCommand::InstallGame {
id,
account_name,
language,
} => {
handle_install_game_command(ctx, tx_notify_ui, id, account_name, language).await;
}
PeerCommand::UninstallGame { id } => {
handle_uninstall_game_command(ctx, tx_notify_ui, id).await;
@@ -42,10 +42,16 @@ struct LanSpreadState {
peer_game_db: Arc<RwLock<PeerGameDB>>,
catalog: Arc<RwLock<HashSet<String>>>,
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
pending_install_account_names: Arc<RwLock<HashMap<String, String>>>,
pending_install_settings: Arc<RwLock<HashMap<String, InstallSettings>>>,
state_dir: OnceLock<PathBuf>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct InstallSettings {
account_name: String,
language: String,
}
struct PeerEventTx(UnboundedSender<PeerEvent>);
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
@@ -145,6 +151,7 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
#[tauri::command]
async fn install_game(
id: String,
language: String,
username: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
@@ -173,20 +180,21 @@ async fn install_game(
return Ok(false);
};
let account_name = sanitize_username(&username);
let install_settings = install_settings(&language, &username);
let handled = if let Some(peer_ctrl) = peer_ctrl {
let command = if !downloaded {
state
.inner()
.pending_install_account_names
.pending_install_settings
.write()
.await
.insert(id.clone(), account_name);
.insert(id.clone(), install_settings);
PeerCommand::GetGame(id.clone())
} else if !installed {
PeerCommand::InstallGame {
id: id.clone(),
account_name: Some(account_name),
account_name: Some(install_settings.account_name),
language: Some(install_settings.language),
}
} else {
log::info!("Game is already installed: {id}");
@@ -197,7 +205,7 @@ async fn install_game(
log::error!("Failed to send message to peer: {e:?}");
state
.inner()
.pending_install_account_names
.pending_install_settings
.write()
.await
.remove(&id);
@@ -215,6 +223,7 @@ async fn install_game(
#[tauri::command]
async fn update_game(
id: String,
language: String,
username: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
@@ -236,15 +245,15 @@ async fn update_game(
if let Some(peer_ctrl) = peer_ctrl {
state
.inner()
.pending_install_account_names
.pending_install_settings
.write()
.await
.insert(id.clone(), sanitize_username(&username));
.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_account_names
.pending_install_settings
.write()
.await
.remove(&id);
@@ -342,7 +351,7 @@ async fn cancel_download(
) -> tauri::Result<bool> {
state
.inner()
.pending_install_account_names
.pending_install_settings
.write()
.await
.remove(&id);
@@ -459,6 +468,20 @@ fn launch_settings(language: &str, username: &str) -> LaunchSettings {
}
}
fn install_settings(language: &str, username: &str) -> InstallSettings {
InstallSettings {
account_name: sanitize_username(username),
language: install_language(language),
}
}
fn install_language(language: &str) -> String {
match sanitize_language(language).as_str() {
"de" => "german".to_string(),
_ => "english".to_string(),
}
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn sanitize_language(language: &str) -> String {
match language.trim().to_ascii_lowercase().as_str() {
@@ -1481,7 +1504,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
PeerEvent::NoPeersHaveGame { id } => {
log::warn!("PeerEvent::NoPeersHaveGame received for {id}");
clear_pending_install_account_name(app_handle, &id).await;
clear_pending_install_settings(app_handle, &id).await;
emit_game_id_event(
app_handle,
"game-no-peers",
@@ -1520,7 +1543,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
PeerEvent::DownloadGameFilesFailed { id } => {
log::warn!("PeerEvent::DownloadGameFilesFailed received");
clear_pending_install_account_name(app_handle, &id).await;
clear_pending_install_settings(app_handle, &id).await;
emit_game_id_event(
app_handle,
"game-download-failed",
@@ -1530,7 +1553,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
PeerEvent::DownloadGameFilesAllPeersGone { id } => {
log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}");
clear_pending_install_account_name(app_handle, &id).await;
clear_pending_install_settings(app_handle, &id).await;
emit_game_id_event(
app_handle,
"game-download-peers-gone",
@@ -1655,9 +1678,9 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
}
async fn clear_pending_install_account_name(app_handle: &AppHandle, id: &str) {
async fn clear_pending_install_settings(app_handle: &AppHandle, id: &str) {
let state = app_handle.state::<LanSpreadState>();
state.pending_install_account_names.write().await.remove(id);
state.pending_install_settings.write().await.remove(id);
}
async fn handle_got_game_files(
@@ -1674,17 +1697,17 @@ async fn handle_got_game_files(
);
let state = app_handle.state::<LanSpreadState>();
let account_name = state
.pending_install_account_names
.write()
.await
.remove(&id);
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}");
@@ -1921,6 +1944,24 @@ mod tests {
);
}
#[test]
fn install_settings_use_language_file_values() {
assert_eq!(
install_settings("de", " Alice \"Ace\"%PATH%\n "),
InstallSettings {
account_name: "Alice AcePATH".to_string(),
language: "german".to_string(),
}
);
assert_eq!(
install_settings("fr", ""),
InstallSettings {
account_name: DEFAULT_USERNAME.to_string(),
language: "english".to_string(),
}
);
}
#[test]
fn script_params_use_common_argument_shape() {
let start_params = script_params(
@@ -53,6 +53,7 @@ export const useGameActions = (
try {
const success = await invoke<boolean>('install_game', {
id,
language: settings.language,
username: settings.username,
});
if (!success) return;
@@ -64,19 +65,20 @@ export const useGameActions = (
} catch (err) {
console.error('install_game failed:', err);
}
}, [games, settings.username]);
}, [games, settings.language, settings.username]);
const update = useCallback(async (id: string) => {
try {
const success = await invoke<boolean>('update_game', {
id,
language: settings.language,
username: settings.username,
});
if (success) games.markChecking(id);
} catch (err) {
console.error('update_game failed:', err);
}
}, [games, settings.username]);
}, [games, settings.language, settings.username]);
const uninstall = useCallback(async (id: string) => {
try {