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:
@@ -241,6 +241,7 @@ async fn handle_command(
|
|||||||
file_descriptions: files,
|
file_descriptions: files,
|
||||||
install_after_download: *install_after_download,
|
install_after_download: *install_after_download,
|
||||||
account_name: None,
|
account_name: None,
|
||||||
|
language: None,
|
||||||
})?;
|
})?;
|
||||||
Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download}))
|
Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download}))
|
||||||
}
|
}
|
||||||
@@ -250,6 +251,7 @@ async fn handle_command(
|
|||||||
sender.send(PeerCommand::InstallGame {
|
sender.send(PeerCommand::InstallGame {
|
||||||
id: game_id.clone(),
|
id: game_id.clone(),
|
||||||
account_name: None,
|
account_name: None,
|
||||||
|
language: None,
|
||||||
})?;
|
})?;
|
||||||
Ok(json!({"queued": true, "game_id": game_id}))
|
Ok(json!({"queued": true, "game_id": game_id}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ Downloaded and installed are independent predicates:
|
|||||||
- `installed` is true when `<game_root>/local/` is a directory. The contents of
|
- `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
|
`local/` are user-owned and are skipped by manifests, fingerprints, and file
|
||||||
serving.
|
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:
|
Reserved per-game paths:
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ pub async fn handle_download_game_files_command(
|
|||||||
file_descriptions: Vec<GameFileDescription>,
|
file_descriptions: Vec<GameFileDescription>,
|
||||||
install_after_download: bool,
|
install_after_download: bool,
|
||||||
account_name: Option<String>,
|
account_name: Option<String>,
|
||||||
|
language: Option<String>,
|
||||||
) {
|
) {
|
||||||
log::info!("Got PeerCommand::DownloadGameFiles");
|
log::info!("Got PeerCommand::DownloadGameFiles");
|
||||||
if !catalog_contains(ctx, &id).await {
|
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}");
|
log::error!("Failed to send DownloadGameFilesFinished event: {e}");
|
||||||
}
|
}
|
||||||
if install_after_download {
|
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 {
|
} else {
|
||||||
log::error!("No trusted peers available after majority validation for game {id}");
|
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,
|
download_id,
|
||||||
prepared,
|
prepared,
|
||||||
account_name,
|
account_name,
|
||||||
|
language,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
@@ -422,8 +424,9 @@ pub async fn handle_install_game_command(
|
|||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
id: String,
|
id: String,
|
||||||
account_name: Option<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.
|
/// Handles the `UninstallGame` command.
|
||||||
@@ -471,11 +474,12 @@ fn spawn_install_operation(
|
|||||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
id: String,
|
id: String,
|
||||||
account_name: Option<String>,
|
account_name: Option<String>,
|
||||||
|
language: Option<String>,
|
||||||
) {
|
) {
|
||||||
let ctx = ctx.clone();
|
let ctx = ctx.clone();
|
||||||
let tx_notify_ui = tx_notify_ui.clone();
|
let tx_notify_ui = tx_notify_ui.clone();
|
||||||
ctx.task_tracker.clone().spawn(async move {
|
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>,
|
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||||
id: String,
|
id: String,
|
||||||
account_name: Option<String>,
|
account_name: Option<String>,
|
||||||
|
language: Option<String>,
|
||||||
) {
|
) {
|
||||||
let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &id).await else {
|
let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &id).await else {
|
||||||
return;
|
return;
|
||||||
@@ -494,7 +499,7 @@ async fn run_install_operation(
|
|||||||
return;
|
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 {
|
struct PreparedInstallOperation {
|
||||||
@@ -547,6 +552,7 @@ async fn run_started_install_operation(
|
|||||||
id: String,
|
id: String,
|
||||||
prepared: PreparedInstallOperation,
|
prepared: PreparedInstallOperation,
|
||||||
account_name: Option<String>,
|
account_name: Option<String>,
|
||||||
|
language: Option<String>,
|
||||||
) {
|
) {
|
||||||
let PreparedInstallOperation {
|
let PreparedInstallOperation {
|
||||||
game_root,
|
game_root,
|
||||||
@@ -577,6 +583,7 @@ async fn run_started_install_operation(
|
|||||||
&id,
|
&id,
|
||||||
ctx.unpacker.clone(),
|
ctx.unpacker.clone(),
|
||||||
account_name.as_deref(),
|
account_name.as_deref(),
|
||||||
|
language.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -587,6 +594,7 @@ async fn run_started_install_operation(
|
|||||||
&id,
|
&id,
|
||||||
ctx.unpacker.clone(),
|
ctx.unpacker.clone(),
|
||||||
account_name.as_deref(),
|
account_name.as_deref(),
|
||||||
|
language.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -1503,7 +1511,7 @@ mod tests {
|
|||||||
update_and_announce_games(&ctx, &tx, scan).await;
|
update_and_announce_games(&ctx, &tx, scan).await;
|
||||||
assert_local_update(recv_event(&mut rx).await, true, true);
|
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(
|
assert_active_update(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
@@ -1534,7 +1542,7 @@ mod tests {
|
|||||||
let ctx = test_ctx(temp.path().to_path_buf());
|
let ctx = test_ctx(temp.path().to_path_buf());
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
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(
|
assert_active_update(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
@@ -1588,7 +1596,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
);
|
);
|
||||||
clear_active_download(&ctx, "game").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 ctx = test_ctx(temp.path().to_path_buf());
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
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(
|
assert_active_update(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
@@ -1687,7 +1696,7 @@ mod tests {
|
|||||||
let ctx = test_ctx(temp.path().to_path_buf());
|
let ctx = test_ctx(temp.path().to_path_buf());
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
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(
|
assert_active_update(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
active_update("game", ActiveOperationKind::Installing),
|
active_update("game", ActiveOperationKind::Installing),
|
||||||
@@ -1710,7 +1719,7 @@ mod tests {
|
|||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
write_file(&root.join("game.eti"), b"new archive");
|
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(
|
assert_active_update(
|
||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
active_update("game", ActiveOperationKind::Updating),
|
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_TMP_FILE: &str = ".version.ini.tmp";
|
||||||
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
|
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
|
||||||
const ACCOUNT_NAME_FILE: &str = "account_name.txt";
|
const ACCOUNT_NAME_FILE: &str = "account_name.txt";
|
||||||
|
const LANGUAGE_FILE: &str = "language.txt";
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum FsEntryState {
|
enum FsEntryState {
|
||||||
@@ -41,6 +42,7 @@ pub async fn install(
|
|||||||
id: &str,
|
id: &str,
|
||||||
unpacker: Arc<dyn Unpacker>,
|
unpacker: Arc<dyn Unpacker>,
|
||||||
account_name: Option<&str>,
|
account_name: Option<&str>,
|
||||||
|
language: Option<&str>,
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let eti_version = read_downloaded_version(game_root).await;
|
let eti_version = read_downloaded_version(game_root).await;
|
||||||
write_intent(
|
write_intent(
|
||||||
@@ -50,7 +52,7 @@ pub async fn install(
|
|||||||
)
|
)
|
||||||
.await?;
|
.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 {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||||
@@ -75,6 +77,7 @@ pub async fn update(
|
|||||||
id: &str,
|
id: &str,
|
||||||
unpacker: Arc<dyn Unpacker>,
|
unpacker: Arc<dyn Unpacker>,
|
||||||
account_name: Option<&str>,
|
account_name: Option<&str>,
|
||||||
|
language: Option<&str>,
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let eti_version = read_downloaded_version(game_root).await;
|
let eti_version = read_downloaded_version(game_root).await;
|
||||||
write_intent(
|
write_intent(
|
||||||
@@ -84,7 +87,7 @@ pub async fn update(
|
|||||||
)
|
)
|
||||||
.await?;
|
.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 {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
|
||||||
@@ -193,6 +196,7 @@ async fn install_inner(
|
|||||||
id: &str,
|
id: &str,
|
||||||
unpacker: Arc<dyn Unpacker>,
|
unpacker: Arc<dyn Unpacker>,
|
||||||
account_name: Option<&str>,
|
account_name: Option<&str>,
|
||||||
|
language: Option<&str>,
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let local = local_dir(game_root);
|
let local = local_dir(game_root);
|
||||||
if path_is_dir(&local).await {
|
if path_is_dir(&local).await {
|
||||||
@@ -202,7 +206,8 @@ async fn install_inner(
|
|||||||
let staging = installing_dir(game_root);
|
let staging = installing_dir(game_root);
|
||||||
prepare_owned_empty_dir(&staging).await?;
|
prepare_owned_empty_dir(&staging).await?;
|
||||||
unpack_archives(game_root, &staging, unpacker).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)
|
tokio::fs::rename(&staging, &local)
|
||||||
.await
|
.await
|
||||||
.wrap_err_with(|| format!("failed to promote install for {id}"))?;
|
.wrap_err_with(|| format!("failed to promote install for {id}"))?;
|
||||||
@@ -214,6 +219,7 @@ async fn update_inner(
|
|||||||
id: &str,
|
id: &str,
|
||||||
unpacker: Arc<dyn Unpacker>,
|
unpacker: Arc<dyn Unpacker>,
|
||||||
account_name: Option<&str>,
|
account_name: Option<&str>,
|
||||||
|
language: Option<&str>,
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let local = local_dir(game_root);
|
let local = local_dir(game_root);
|
||||||
let backup = backup_dir(game_root);
|
let backup = backup_dir(game_root);
|
||||||
@@ -230,7 +236,8 @@ async fn update_inner(
|
|||||||
|
|
||||||
prepare_owned_empty_dir(&staging).await?;
|
prepare_owned_empty_dir(&staging).await?;
|
||||||
unpack_archives(game_root, &staging, unpacker).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)
|
tokio::fs::rename(&staging, &local)
|
||||||
.await
|
.await
|
||||||
.wrap_err_with(|| format!("failed to promote update for {id}"))?;
|
.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)
|
Ok(archives)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_account_name_if_present(
|
async fn write_install_setting_if_present(
|
||||||
install_root: &Path,
|
install_root: &Path,
|
||||||
account_name: Option<&str>,
|
file_name: &str,
|
||||||
|
value: Option<&str>,
|
||||||
) -> eyre::Result<()> {
|
) -> eyre::Result<()> {
|
||||||
let Some(account_name) = account_name else {
|
let Some(value) = value else {
|
||||||
return Ok(());
|
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(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
tokio::fs::write(&path, account_name)
|
tokio::fs::write(&path, value)
|
||||||
.await
|
.await
|
||||||
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
|
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
|
||||||
Ok(())
|
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()];
|
let mut pending_dirs = vec![root.to_path_buf()];
|
||||||
while let Some(dir) = pending_dirs.pop() {
|
while let Some(dir) = pending_dirs.pop() {
|
||||||
let mut entries = tokio::fs::read_dir(&dir).await?;
|
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? {
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
let file_type = entry.file_type().await?;
|
let file_type = entry.file_type().await?;
|
||||||
let path = entry.path();
|
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));
|
return Ok(Some(path));
|
||||||
}
|
}
|
||||||
if file_type.is_dir() {
|
if file_type.is_dir() {
|
||||||
@@ -630,7 +638,14 @@ mod tests {
|
|||||||
write_file(&root.join("game.eti"), b"archive");
|
write_file(&root.join("game.eti"), b"archive");
|
||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
|
|
||||||
install(&root, state.path(), "game", successful_unpacker(), None)
|
install(
|
||||||
|
&root,
|
||||||
|
state.path(),
|
||||||
|
"game",
|
||||||
|
successful_unpacker(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("install should succeed");
|
.expect("install should succeed");
|
||||||
|
|
||||||
@@ -654,6 +669,7 @@ mod tests {
|
|||||||
"game",
|
"game",
|
||||||
successful_unpacker(),
|
successful_unpacker(),
|
||||||
Some("Alice"),
|
Some("Alice"),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("install should succeed without account file");
|
.expect("install should succeed without account file");
|
||||||
@@ -672,7 +688,7 @@ mod tests {
|
|||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
let unpacker = Arc::new(FakeUnpacker::default());
|
let unpacker = Arc::new(FakeUnpacker::default());
|
||||||
|
|
||||||
install(&root, state.path(), "game", unpacker.clone(), None)
|
install(&root, state.path(), "game", unpacker.clone(), None, None)
|
||||||
.await
|
.await
|
||||||
.expect("install should succeed");
|
.expect("install should succeed");
|
||||||
|
|
||||||
@@ -687,16 +703,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn install_overwrites_first_account_name_file() {
|
async fn install_overwrites_first_install_setting_files() {
|
||||||
struct AccountNameUnpacker;
|
struct InstallSettingsUnpacker;
|
||||||
|
|
||||||
impl Unpacker for AccountNameUnpacker {
|
impl Unpacker for InstallSettingsUnpacker {
|
||||||
fn unpack<'a>(&'a self, _archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
fn unpack<'a>(&'a self, _archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
tokio::fs::create_dir_all(dest.join("a")).await?;
|
tokio::fs::create_dir_all(dest.join("a")).await?;
|
||||||
tokio::fs::create_dir_all(dest.join("z")).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("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("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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -712,8 +730,9 @@ mod tests {
|
|||||||
&root,
|
&root,
|
||||||
state.path(),
|
state.path(),
|
||||||
"game",
|
"game",
|
||||||
Arc::new(AccountNameUnpacker),
|
Arc::new(InstallSettingsUnpacker),
|
||||||
Some("Alice"),
|
Some("Alice"),
|
||||||
|
Some("german"),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("install should succeed");
|
.expect("install should succeed");
|
||||||
@@ -728,6 +747,16 @@ mod tests {
|
|||||||
.expect("second account file should be readable"),
|
.expect("second account file should be readable"),
|
||||||
"old-z"
|
"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]
|
#[tokio::test]
|
||||||
@@ -745,6 +774,7 @@ mod tests {
|
|||||||
"game",
|
"game",
|
||||||
Arc::new(FakeUnpacker::failing()),
|
Arc::new(FakeUnpacker::failing()),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("update should fail");
|
.expect_err("update should fail");
|
||||||
@@ -772,6 +802,7 @@ mod tests {
|
|||||||
"game",
|
"game",
|
||||||
Arc::new(FakeUnpacker::commit_conflict()),
|
Arc::new(FakeUnpacker::commit_conflict()),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("update should fail at commit rename");
|
.expect_err("update should fail at commit rename");
|
||||||
@@ -801,7 +832,14 @@ mod tests {
|
|||||||
write_file(&root.join("version.ini"), b"20250101");
|
write_file(&root.join("version.ini"), b"20250101");
|
||||||
write_file(&root.join("local").join("old.txt"), b"old");
|
write_file(&root.join("local").join("old.txt"), b"old");
|
||||||
|
|
||||||
update(&root, state.path(), "game", successful_unpacker(), None)
|
update(
|
||||||
|
&root,
|
||||||
|
state.path(),
|
||||||
|
"game",
|
||||||
|
successful_unpacker(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("update should succeed");
|
.expect("update should succeed");
|
||||||
|
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ pub enum PeerCommand {
|
|||||||
id: String,
|
id: String,
|
||||||
file_descriptions: Vec<GameFileDescription>,
|
file_descriptions: Vec<GameFileDescription>,
|
||||||
account_name: Option<String>,
|
account_name: Option<String>,
|
||||||
|
language: Option<String>,
|
||||||
},
|
},
|
||||||
/// Download game files with an explicit install policy.
|
/// Download game files with an explicit install policy.
|
||||||
DownloadGameFilesWithOptions {
|
DownloadGameFilesWithOptions {
|
||||||
@@ -237,11 +238,13 @@ pub enum PeerCommand {
|
|||||||
file_descriptions: Vec<GameFileDescription>,
|
file_descriptions: Vec<GameFileDescription>,
|
||||||
install_after_download: bool,
|
install_after_download: bool,
|
||||||
account_name: Option<String>,
|
account_name: Option<String>,
|
||||||
|
language: Option<String>,
|
||||||
},
|
},
|
||||||
/// Install already-downloaded archives into `local/`.
|
/// Install already-downloaded archives into `local/`.
|
||||||
InstallGame {
|
InstallGame {
|
||||||
id: String,
|
id: String,
|
||||||
account_name: Option<String>,
|
account_name: Option<String>,
|
||||||
|
language: Option<String>,
|
||||||
},
|
},
|
||||||
/// Remove only the `local/` install for a game.
|
/// Remove only the `local/` install for a game.
|
||||||
UninstallGame { id: String },
|
UninstallGame { id: String },
|
||||||
@@ -411,6 +414,7 @@ async fn handle_peer_commands(
|
|||||||
id,
|
id,
|
||||||
file_descriptions,
|
file_descriptions,
|
||||||
account_name,
|
account_name,
|
||||||
|
language,
|
||||||
} => {
|
} => {
|
||||||
handle_download_game_files_command(
|
handle_download_game_files_command(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -419,6 +423,7 @@ async fn handle_peer_commands(
|
|||||||
file_descriptions,
|
file_descriptions,
|
||||||
true,
|
true,
|
||||||
account_name,
|
account_name,
|
||||||
|
language,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -427,6 +432,7 @@ async fn handle_peer_commands(
|
|||||||
file_descriptions,
|
file_descriptions,
|
||||||
install_after_download,
|
install_after_download,
|
||||||
account_name,
|
account_name,
|
||||||
|
language,
|
||||||
} => {
|
} => {
|
||||||
handle_download_game_files_command(
|
handle_download_game_files_command(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -435,11 +441,16 @@ async fn handle_peer_commands(
|
|||||||
file_descriptions,
|
file_descriptions,
|
||||||
install_after_download,
|
install_after_download,
|
||||||
account_name,
|
account_name,
|
||||||
|
language,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
PeerCommand::InstallGame { id, account_name } => {
|
PeerCommand::InstallGame {
|
||||||
handle_install_game_command(ctx, tx_notify_ui, id, account_name).await;
|
id,
|
||||||
|
account_name,
|
||||||
|
language,
|
||||||
|
} => {
|
||||||
|
handle_install_game_command(ctx, tx_notify_ui, id, account_name, language).await;
|
||||||
}
|
}
|
||||||
PeerCommand::UninstallGame { id } => {
|
PeerCommand::UninstallGame { id } => {
|
||||||
handle_uninstall_game_command(ctx, tx_notify_ui, id).await;
|
handle_uninstall_game_command(ctx, tx_notify_ui, id).await;
|
||||||
|
|||||||
@@ -42,10 +42,16 @@ struct LanSpreadState {
|
|||||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
catalog: Arc<RwLock<HashSet<String>>>,
|
catalog: Arc<RwLock<HashSet<String>>>,
|
||||||
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
|
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>,
|
state_dir: OnceLock<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct InstallSettings {
|
||||||
|
account_name: String,
|
||||||
|
language: String,
|
||||||
|
}
|
||||||
|
|
||||||
struct PeerEventTx(UnboundedSender<PeerEvent>);
|
struct PeerEventTx(UnboundedSender<PeerEvent>);
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
||||||
@@ -145,6 +151,7 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn install_game(
|
async fn install_game(
|
||||||
id: String,
|
id: String,
|
||||||
|
language: String,
|
||||||
username: String,
|
username: String,
|
||||||
state: tauri::State<'_, LanSpreadState>,
|
state: tauri::State<'_, LanSpreadState>,
|
||||||
) -> tauri::Result<bool> {
|
) -> tauri::Result<bool> {
|
||||||
@@ -173,20 +180,21 @@ async fn install_game(
|
|||||||
return Ok(false);
|
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 handled = if let Some(peer_ctrl) = peer_ctrl {
|
||||||
let command = if !downloaded {
|
let command = if !downloaded {
|
||||||
state
|
state
|
||||||
.inner()
|
.inner()
|
||||||
.pending_install_account_names
|
.pending_install_settings
|
||||||
.write()
|
.write()
|
||||||
.await
|
.await
|
||||||
.insert(id.clone(), account_name);
|
.insert(id.clone(), install_settings);
|
||||||
PeerCommand::GetGame(id.clone())
|
PeerCommand::GetGame(id.clone())
|
||||||
} else if !installed {
|
} else if !installed {
|
||||||
PeerCommand::InstallGame {
|
PeerCommand::InstallGame {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
account_name: Some(account_name),
|
account_name: Some(install_settings.account_name),
|
||||||
|
language: Some(install_settings.language),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log::info!("Game is already installed: {id}");
|
log::info!("Game is already installed: {id}");
|
||||||
@@ -197,7 +205,7 @@ async fn install_game(
|
|||||||
log::error!("Failed to send message to peer: {e:?}");
|
log::error!("Failed to send message to peer: {e:?}");
|
||||||
state
|
state
|
||||||
.inner()
|
.inner()
|
||||||
.pending_install_account_names
|
.pending_install_settings
|
||||||
.write()
|
.write()
|
||||||
.await
|
.await
|
||||||
.remove(&id);
|
.remove(&id);
|
||||||
@@ -215,6 +223,7 @@ async fn install_game(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn update_game(
|
async fn update_game(
|
||||||
id: String,
|
id: String,
|
||||||
|
language: String,
|
||||||
username: String,
|
username: String,
|
||||||
state: tauri::State<'_, LanSpreadState>,
|
state: tauri::State<'_, LanSpreadState>,
|
||||||
) -> tauri::Result<bool> {
|
) -> tauri::Result<bool> {
|
||||||
@@ -236,15 +245,15 @@ async fn update_game(
|
|||||||
if let Some(peer_ctrl) = peer_ctrl {
|
if let Some(peer_ctrl) = peer_ctrl {
|
||||||
state
|
state
|
||||||
.inner()
|
.inner()
|
||||||
.pending_install_account_names
|
.pending_install_settings
|
||||||
.write()
|
.write()
|
||||||
.await
|
.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() }) {
|
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id: id.clone() }) {
|
||||||
log::error!("Failed to send message to peer: {e:?}");
|
log::error!("Failed to send message to peer: {e:?}");
|
||||||
state
|
state
|
||||||
.inner()
|
.inner()
|
||||||
.pending_install_account_names
|
.pending_install_settings
|
||||||
.write()
|
.write()
|
||||||
.await
|
.await
|
||||||
.remove(&id);
|
.remove(&id);
|
||||||
@@ -342,7 +351,7 @@ async fn cancel_download(
|
|||||||
) -> tauri::Result<bool> {
|
) -> tauri::Result<bool> {
|
||||||
state
|
state
|
||||||
.inner()
|
.inner()
|
||||||
.pending_install_account_names
|
.pending_install_settings
|
||||||
.write()
|
.write()
|
||||||
.await
|
.await
|
||||||
.remove(&id);
|
.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))]
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
fn sanitize_language(language: &str) -> String {
|
fn sanitize_language(language: &str) -> String {
|
||||||
match language.trim().to_ascii_lowercase().as_str() {
|
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 } => {
|
PeerEvent::NoPeersHaveGame { id } => {
|
||||||
log::warn!("PeerEvent::NoPeersHaveGame received for {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(
|
emit_game_id_event(
|
||||||
app_handle,
|
app_handle,
|
||||||
"game-no-peers",
|
"game-no-peers",
|
||||||
@@ -1520,7 +1543,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|||||||
}
|
}
|
||||||
PeerEvent::DownloadGameFilesFailed { id } => {
|
PeerEvent::DownloadGameFilesFailed { id } => {
|
||||||
log::warn!("PeerEvent::DownloadGameFilesFailed received");
|
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(
|
emit_game_id_event(
|
||||||
app_handle,
|
app_handle,
|
||||||
"game-download-failed",
|
"game-download-failed",
|
||||||
@@ -1530,7 +1553,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|||||||
}
|
}
|
||||||
PeerEvent::DownloadGameFilesAllPeersGone { id } => {
|
PeerEvent::DownloadGameFilesAllPeersGone { id } => {
|
||||||
log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {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(
|
emit_game_id_event(
|
||||||
app_handle,
|
app_handle,
|
||||||
"game-download-peers-gone",
|
"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>();
|
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(
|
async fn handle_got_game_files(
|
||||||
@@ -1674,17 +1697,17 @@ async fn handle_got_game_files(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let state = app_handle.state::<LanSpreadState>();
|
let state = app_handle.state::<LanSpreadState>();
|
||||||
let account_name = state
|
let install_settings = state.pending_install_settings.write().await.remove(&id);
|
||||||
.pending_install_account_names
|
let (account_name, language) = install_settings.map_or((None, None), |settings| {
|
||||||
.write()
|
(Some(settings.account_name), Some(settings.language))
|
||||||
.await
|
});
|
||||||
.remove(&id);
|
|
||||||
let peer_ctrl = state.peer_ctrl.read().await.clone();
|
let peer_ctrl = state.peer_ctrl.read().await.clone();
|
||||||
if let Some(peer_ctrl) = peer_ctrl
|
if let Some(peer_ctrl) = peer_ctrl
|
||||||
&& let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles {
|
&& let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles {
|
||||||
id,
|
id,
|
||||||
file_descriptions,
|
file_descriptions,
|
||||||
account_name,
|
account_name,
|
||||||
|
language,
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}");
|
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]
|
#[test]
|
||||||
fn script_params_use_common_argument_shape() {
|
fn script_params_use_common_argument_shape() {
|
||||||
let start_params = script_params(
|
let start_params = script_params(
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export const useGameActions = (
|
|||||||
try {
|
try {
|
||||||
const success = await invoke<boolean>('install_game', {
|
const success = await invoke<boolean>('install_game', {
|
||||||
id,
|
id,
|
||||||
|
language: settings.language,
|
||||||
username: settings.username,
|
username: settings.username,
|
||||||
});
|
});
|
||||||
if (!success) return;
|
if (!success) return;
|
||||||
@@ -64,19 +65,20 @@ export const useGameActions = (
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('install_game failed:', err);
|
console.error('install_game failed:', err);
|
||||||
}
|
}
|
||||||
}, [games, settings.username]);
|
}, [games, settings.language, settings.username]);
|
||||||
|
|
||||||
const update = useCallback(async (id: string) => {
|
const update = useCallback(async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const success = await invoke<boolean>('update_game', {
|
const success = await invoke<boolean>('update_game', {
|
||||||
id,
|
id,
|
||||||
|
language: settings.language,
|
||||||
username: settings.username,
|
username: settings.username,
|
||||||
});
|
});
|
||||||
if (success) games.markChecking(id);
|
if (success) games.markChecking(id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('update_game failed:', err);
|
console.error('update_game failed:', err);
|
||||||
}
|
}
|
||||||
}, [games, settings.username]);
|
}, [games, settings.language, settings.username]);
|
||||||
|
|
||||||
const uninstall = useCallback(async (id: string) => {
|
const uninstall = useCallback(async (id: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user