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
@@ -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(