feat(install): stamp username into account_name.txt after install

Some games ship an `account_name.txt` file somewhere under the unpacked
`local/` tree (location varies per game). After install or update, write
the configured username into the first such file we find so the game
launches under the user's account instead of whatever default the
archive contains.

The search is a deterministic alphabetical DFS rooted at the install
staging dir (`.local.installing/`, which becomes `local/` on rename),
stopping at the first regular-file match. Symlinks named
`account_name.txt` are skipped (`is_file()` is false for symlinks on
Linux), so a hostile archive can't redirect the write outside the game
tree. If no `account_name.txt` exists anywhere in the install, the step
is a no-op. If the write fails, the existing install rollback (cleanup
of staging on fresh installs, restore from backup on updates) handles
it — no partial state is left behind.

The username flows from the Tauri layer, where it is already sanitized
by `sanitize_username`, down through `PeerCommand` variants
(`InstallGame`, `DownloadGameFiles`, `DownloadGameFilesWithOptions`)
into `install`/`update`, which now take an `Option<&str> account_name`.
For the "install game that isn't downloaded yet" path the username has
to bridge the async gap between the `GetGame` / `FetchLatestFromPeers`
request and the eventual `GotGameFiles` event; we park it in a
per-game-id map on `LanSpreadState` and pop it when forwarding the
download command. The map is also cleared defensively on
`cancel_download`, `DownloadGameFilesFailed`, and
`DownloadGameFilesAllPeersGone` so a stale entry can't bleed into a
subsequent install with a different username.

`PeerCommand` is the in-process command channel, not the wire protocol;
no on-wire types changed, so the "one wire version" policy is
preserved. The peer-cli harness keeps passing `account_name: None`
since it tests peer interop, not user-facing settings.

# Test Plan

Unit tests in `crates/lanspread-peer/src/install/transaction.rs`:
- `install_overwrites_first_account_name_file` — unpacker creates
  `a/account_name.txt` and `z/account_name.txt`; after install with
  username "Alice", `a/` is overwritten and `z/` is left untouched,
  pinning the sorted-DFS "first match wins" behavior.
- `install_account_name_missing_file_is_noop` — install with a
  username but no `account_name.txt` anywhere in the archive
  succeeds and creates no spurious file.

Manual GUI check: in Settings, set a username; install a game whose
archive contains `account_name.txt`; open `local/` and confirm the
file now holds the configured username. Repeat for the update flow
(install, change username, click update).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 20:56:42 +02:00
parent 2e7a0cff2f
commit 574acfca45
6 changed files with 266 additions and 34 deletions
+2
View File
@@ -240,6 +240,7 @@ async fn handle_command(
id: game_id.clone(), id: game_id.clone(),
file_descriptions: files, file_descriptions: files,
install_after_download: *install_after_download, install_after_download: *install_after_download,
account_name: 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}))
} }
@@ -248,6 +249,7 @@ async fn handle_command(
ensure_no_active_operation(shared, game_id).await?; ensure_no_active_operation(shared, game_id).await?;
sender.send(PeerCommand::InstallGame { sender.send(PeerCommand::InstallGame {
id: game_id.clone(), id: game_id.clone(),
account_name: None,
})?; })?;
Ok(json!({"queued": true, "game_id": game_id})) Ok(json!({"queued": true, "game_id": game_id}))
} }
+42 -14
View File
@@ -199,6 +199,7 @@ pub async fn handle_download_game_files_command(
id: String, id: String,
file_descriptions: Vec<GameFileDescription>, file_descriptions: Vec<GameFileDescription>,
install_after_download: bool, install_after_download: bool,
account_name: Option<String>,
) { ) {
log::info!("Got PeerCommand::DownloadGameFiles"); log::info!("Got PeerCommand::DownloadGameFiles");
if !catalog_contains(ctx, &id).await { if !catalog_contains(ctx, &id).await {
@@ -276,7 +277,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()); spawn_install_operation(ctx, tx_notify_ui, id.clone(), account_name);
} }
} 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}");
@@ -362,6 +363,7 @@ pub async fn handle_download_game_files_command(
&tx_notify_ui_clone, &tx_notify_ui_clone,
download_id, download_id,
prepared, prepared,
account_name,
) )
.await; .await;
} else { } else {
@@ -419,8 +421,9 @@ pub async fn handle_install_game_command(
ctx: &Ctx, ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>, tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String, id: String,
account_name: Option<String>,
) { ) {
spawn_install_operation(ctx, tx_notify_ui, id); spawn_install_operation(ctx, tx_notify_ui, id, account_name);
} }
/// Handles the `UninstallGame` command. /// Handles the `UninstallGame` command.
@@ -463,15 +466,25 @@ pub async fn handle_cancel_download_command(
cancel_token.cancel(); cancel_token.cancel();
} }
fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) { fn spawn_install_operation(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
account_name: 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).await; run_install_operation(&ctx, &tx_notify_ui, id, account_name).await;
}); });
} }
async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) { async fn run_install_operation(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
account_name: 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;
}; };
@@ -481,7 +494,7 @@ async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
return; return;
} }
run_started_install_operation(ctx, tx_notify_ui, id, prepared).await; run_started_install_operation(ctx, tx_notify_ui, id, prepared, account_name).await;
} }
struct PreparedInstallOperation { struct PreparedInstallOperation {
@@ -533,6 +546,7 @@ async fn run_started_install_operation(
tx_notify_ui: &UnboundedSender<PeerEvent>, tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String, id: String,
prepared: PreparedInstallOperation, prepared: PreparedInstallOperation,
account_name: Option<String>,
) { ) {
let PreparedInstallOperation { let PreparedInstallOperation {
game_root, game_root,
@@ -557,10 +571,24 @@ async fn run_started_install_operation(
let state_dir = ctx.state_dir.as_ref(); let state_dir = ctx.state_dir.as_ref();
match operation { match operation {
InstallOperation::Installing => { InstallOperation::Installing => {
install::install(&game_root, state_dir, &id, ctx.unpacker.clone()).await install::install(
&game_root,
state_dir,
&id,
ctx.unpacker.clone(),
account_name.as_deref(),
)
.await
} }
InstallOperation::Updating => { InstallOperation::Updating => {
install::update(&game_root, state_dir, &id, ctx.unpacker.clone()).await install::update(
&game_root,
state_dir,
&id,
ctx.unpacker.clone(),
account_name.as_deref(),
)
.await
} }
} }
}; };
@@ -1475,7 +1503,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()).await; run_install_operation(&ctx, &tx, "game".to_string(), None).await;
assert_active_update( assert_active_update(
recv_event(&mut rx).await, recv_event(&mut rx).await,
@@ -1506,7 +1534,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()).await; run_install_operation(&ctx, &tx, "game".to_string(), None).await;
assert_active_update( assert_active_update(
recv_event(&mut rx).await, recv_event(&mut rx).await,
@@ -1560,7 +1588,7 @@ 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).await; run_started_install_operation(&ctx, &tx, "game".to_string(), prepared, None).await;
} }
}); });
@@ -1627,7 +1655,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()).await; run_install_operation(&ctx, &tx, "game".to_string(), None).await;
assert_active_update( assert_active_update(
recv_event(&mut rx).await, recv_event(&mut rx).await,
@@ -1659,7 +1687,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()).await; run_install_operation(&ctx, &tx, "game".to_string(), 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),
@@ -1682,7 +1710,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()).await; run_install_operation(&ctx, &tx, "game".to_string(), 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),
@@ -1,5 +1,6 @@
use std::{ use std::{
collections::HashSet, collections::HashSet,
ffi::OsStr,
io::ErrorKind, io::ErrorKind,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc, sync::Arc,
@@ -19,6 +20,7 @@ const BACKUP_DIR: &str = ".local.backup";
const OWNED_MARKER: &str = ".lanspread_owned"; 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";
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FsEntryState { enum FsEntryState {
@@ -38,6 +40,7 @@ pub async fn install(
state_dir: &Path, state_dir: &Path,
id: &str, id: &str,
unpacker: Arc<dyn Unpacker>, unpacker: Arc<dyn Unpacker>,
account_name: 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(
@@ -47,7 +50,7 @@ pub async fn install(
) )
.await?; .await?;
let result = install_inner(game_root, id, unpacker).await; let result = install_inner(game_root, id, unpacker, account_name).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?;
@@ -71,6 +74,7 @@ pub async fn update(
state_dir: &Path, state_dir: &Path,
id: &str, id: &str,
unpacker: Arc<dyn Unpacker>, unpacker: Arc<dyn Unpacker>,
account_name: 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(
@@ -80,7 +84,7 @@ pub async fn update(
) )
.await?; .await?;
let result = update_inner(game_root, id, unpacker).await; let result = update_inner(game_root, id, unpacker, account_name).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?;
@@ -188,6 +192,7 @@ async fn install_inner(
game_root: &Path, game_root: &Path,
id: &str, id: &str,
unpacker: Arc<dyn Unpacker>, unpacker: Arc<dyn Unpacker>,
account_name: 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 {
@@ -197,13 +202,19 @@ 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?;
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}"))?;
Ok(()) Ok(())
} }
async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> { async fn update_inner(
game_root: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
account_name: Option<&str>,
) -> 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);
let staging = installing_dir(game_root); let staging = installing_dir(game_root);
@@ -219,6 +230,7 @@ async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -
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?;
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}"))?;
@@ -272,6 +284,48 @@ async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
Ok(archives) Ok(archives)
} }
async fn write_account_name_if_present(
install_root: &Path,
account_name: Option<&str>,
) -> eyre::Result<()> {
let Some(account_name) = account_name else {
return Ok(());
};
let Some(path) = find_account_name_file(install_root).await? else {
return Ok(());
};
tokio::fs::write(&path, account_name)
.await
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
Ok(())
}
async fn find_account_name_file(root: &Path) -> 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?;
let mut child_dirs = Vec::new();
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() {
return Ok(Some(path));
}
if file_type.is_dir() {
child_dirs.push(path);
}
}
child_dirs.sort();
child_dirs.reverse();
pending_dirs.extend(child_dirs);
}
Ok(None)
}
async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> { async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
sweep_owned_orphan(&installing_dir(game_root)).await?; sweep_owned_orphan(&installing_dir(game_root)).await?;
sweep_owned_orphan(&backup_dir(game_root)).await?; sweep_owned_orphan(&backup_dir(game_root)).await?;
@@ -576,7 +630,7 @@ 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()) install(&root, state.path(), "game", successful_unpacker(), None)
.await .await
.expect("install should succeed"); .expect("install should succeed");
@@ -586,6 +640,28 @@ mod tests {
assert_eq!(intent.state, InstallIntentState::None); assert_eq!(intent.state, InstallIntentState::None);
} }
#[tokio::test]
async fn install_account_name_missing_file_is_noop() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
install(
&root,
state.path(),
"game",
successful_unpacker(),
Some("Alice"),
)
.await
.expect("install should succeed without account file");
assert!(root.join("local").join("payload.txt").is_file());
assert!(!root.join("local").join(ACCOUNT_NAME_FILE).exists());
}
#[tokio::test] #[tokio::test]
async fn install_unpacks_multiple_root_eti_archives_in_sorted_order() { async fn install_unpacks_multiple_root_eti_archives_in_sorted_order() {
let temp = TempDir::new("lanspread-install"); let temp = TempDir::new("lanspread-install");
@@ -596,7 +672,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()) install(&root, state.path(), "game", unpacker.clone(), None)
.await .await
.expect("install should succeed"); .expect("install should succeed");
@@ -610,6 +686,50 @@ mod tests {
assert_eq!(archives, vec!["a.eti", "b.eti"]); assert_eq!(archives, vec!["a.eti", "b.eti"]);
} }
#[tokio::test]
async fn install_overwrites_first_account_name_file() {
struct AccountNameUnpacker;
impl Unpacker for AccountNameUnpacker {
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?;
Ok(())
})
}
}
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
install(
&root,
state.path(),
"game",
Arc::new(AccountNameUnpacker),
Some("Alice"),
)
.await
.expect("install should succeed");
assert_eq!(
std::fs::read_to_string(root.join("local").join("a").join(ACCOUNT_NAME_FILE))
.expect("first account file should be readable"),
"Alice"
);
assert_eq!(
std::fs::read_to_string(root.join("local").join("z").join(ACCOUNT_NAME_FILE))
.expect("second account file should be readable"),
"old-z"
);
}
#[tokio::test] #[tokio::test]
async fn update_failure_restores_previous_local() { async fn update_failure_restores_previous_local() {
let temp = TempDir::new("lanspread-install"); let temp = TempDir::new("lanspread-install");
@@ -624,6 +744,7 @@ mod tests {
state.path(), state.path(),
"game", "game",
Arc::new(FakeUnpacker::failing()), Arc::new(FakeUnpacker::failing()),
None,
) )
.await .await
.expect_err("update should fail"); .expect_err("update should fail");
@@ -650,6 +771,7 @@ mod tests {
state.path(), state.path(),
"game", "game",
Arc::new(FakeUnpacker::commit_conflict()), Arc::new(FakeUnpacker::commit_conflict()),
None,
) )
.await .await
.expect_err("update should fail at commit rename"); .expect_err("update should fail at commit rename");
@@ -679,7 +801,7 @@ 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()) update(&root, state.path(), "game", successful_unpacker(), None)
.await .await
.expect("update should succeed"); .expect("update should succeed");
+19 -4
View File
@@ -229,15 +229,20 @@ pub enum PeerCommand {
DownloadGameFiles { DownloadGameFiles {
id: String, id: String,
file_descriptions: Vec<GameFileDescription>, file_descriptions: Vec<GameFileDescription>,
account_name: Option<String>,
}, },
/// Download game files with an explicit install policy. /// Download game files with an explicit install policy.
DownloadGameFilesWithOptions { DownloadGameFilesWithOptions {
id: String, id: String,
file_descriptions: Vec<GameFileDescription>, file_descriptions: Vec<GameFileDescription>,
install_after_download: bool, install_after_download: bool,
account_name: Option<String>,
}, },
/// Install already-downloaded archives into `local/`. /// Install already-downloaded archives into `local/`.
InstallGame { id: String }, InstallGame {
id: String,
account_name: Option<String>,
},
/// Remove only the `local/` install for a game. /// Remove only the `local/` install for a game.
UninstallGame { id: String }, UninstallGame { id: String },
/// Remove downloaded archive files for an uninstalled game. /// Remove downloaded archive files for an uninstalled game.
@@ -405,14 +410,23 @@ async fn handle_peer_commands(
PeerCommand::DownloadGameFiles { PeerCommand::DownloadGameFiles {
id, id,
file_descriptions, file_descriptions,
account_name,
} => { } => {
handle_download_game_files_command(ctx, tx_notify_ui, id, file_descriptions, true) handle_download_game_files_command(
ctx,
tx_notify_ui,
id,
file_descriptions,
true,
account_name,
)
.await; .await;
} }
PeerCommand::DownloadGameFilesWithOptions { PeerCommand::DownloadGameFilesWithOptions {
id, id,
file_descriptions, file_descriptions,
install_after_download, install_after_download,
account_name,
} => { } => {
handle_download_game_files_command( handle_download_game_files_command(
ctx, ctx,
@@ -420,11 +434,12 @@ async fn handle_peer_commands(
id, id,
file_descriptions, file_descriptions,
install_after_download, install_after_download,
account_name,
) )
.await; .await;
} }
PeerCommand::InstallGame { id } => { PeerCommand::InstallGame { id, account_name } => {
handle_install_game_command(ctx, tx_notify_ui, id).await; handle_install_game_command(ctx, tx_notify_ui, id, account_name).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,6 +42,7 @@ 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>>>,
state_dir: OnceLock<PathBuf>, state_dir: OnceLock<PathBuf>,
} }
@@ -142,7 +143,11 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
} }
#[tauri::command] #[tauri::command]
async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<bool> { async fn install_game(
id: String,
username: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
if state if state
.inner() .inner()
.active_operations .active_operations
@@ -168,11 +173,21 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta
return Ok(false); return Ok(false);
}; };
let account_name = sanitize_username(&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 {
PeerCommand::GetGame(id) state
.inner()
.pending_install_account_names
.write()
.await
.insert(id.clone(), account_name);
PeerCommand::GetGame(id.clone())
} else if !installed { } else if !installed {
PeerCommand::InstallGame { id } PeerCommand::InstallGame {
id: id.clone(),
account_name: Some(account_name),
}
} else { } else {
log::info!("Game is already installed: {id}"); log::info!("Game is already installed: {id}");
return Ok(false); return Ok(false);
@@ -180,6 +195,13 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta
if let Err(e) = peer_ctrl.send(command) { if let Err(e) = peer_ctrl.send(command) {
log::error!("Failed to send message to peer: {e:?}"); log::error!("Failed to send message to peer: {e:?}");
state
.inner()
.pending_install_account_names
.write()
.await
.remove(&id);
return Ok(false);
} }
true true
} else { } else {
@@ -191,7 +213,11 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta
} }
#[tauri::command] #[tauri::command]
async fn update_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<bool> { async fn update_game(
id: String,
username: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
if state if state
.inner() .inner()
.active_operations .active_operations
@@ -208,8 +234,20 @@ async fn update_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tau
let peer_ctrl = peer_ctrl_arc.read().await.clone(); let peer_ctrl = peer_ctrl_arc.read().await.clone();
if let Some(peer_ctrl) = peer_ctrl { if let Some(peer_ctrl) = peer_ctrl {
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id }) { state
.inner()
.pending_install_account_names
.write()
.await
.insert(id.clone(), sanitize_username(&username));
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
.inner()
.pending_install_account_names
.write()
.await
.remove(&id);
return Ok(false); return Ok(false);
} }
Ok(true) Ok(true)
@@ -302,6 +340,13 @@ async fn cancel_download(
id: String, id: String,
state: tauri::State<'_, LanSpreadState>, state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> { ) -> tauri::Result<bool> {
state
.inner()
.pending_install_account_names
.write()
.await
.remove(&id);
let is_active_download = { let is_active_download = {
let active_operations = state.inner().active_operations.read().await; let active_operations = state.inner().active_operations.read().await;
matches!( matches!(
@@ -1436,6 +1481,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;
emit_game_id_event( emit_game_id_event(
app_handle, app_handle,
"game-no-peers", "game-no-peers",
@@ -1474,6 +1520,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;
emit_game_id_event( emit_game_id_event(
app_handle, app_handle,
"game-download-failed", "game-download-failed",
@@ -1483,6 +1530,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;
emit_game_id_event( emit_game_id_event(
app_handle, app_handle,
"game-download-peers-gone", "game-download-peers-gone",
@@ -1607,6 +1655,11 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
} }
} }
async fn clear_pending_install_account_name(app_handle: &AppHandle, id: &str) {
let state = app_handle.state::<LanSpreadState>();
state.pending_install_account_names.write().await.remove(id);
}
async fn handle_got_game_files( async fn handle_got_game_files(
app_handle: &AppHandle, app_handle: &AppHandle,
id: String, id: String,
@@ -1621,11 +1674,17 @@ async fn handle_got_game_files(
); );
let state = app_handle.state::<LanSpreadState>(); let state = app_handle.state::<LanSpreadState>();
let account_name = state
.pending_install_account_names
.write()
.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,
}) })
{ {
log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}"); log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}");
@@ -51,7 +51,10 @@ export const useGameActions = (
const install = useCallback(async (id: string) => { const install = useCallback(async (id: string) => {
try { try {
const success = await invoke<boolean>('install_game', { id }); const success = await invoke<boolean>('install_game', {
id,
username: settings.username,
});
if (!success) return; if (!success) return;
const game = games.games.find(item => item.id === id); const game = games.games.find(item => item.id === id);
@@ -61,16 +64,19 @@ export const useGameActions = (
} catch (err) { } catch (err) {
console.error('install_game failed:', err); console.error('install_game failed:', err);
} }
}, [games]); }, [games, settings.username]);
const update = useCallback(async (id: string) => { const update = useCallback(async (id: string) => {
try { try {
const success = await invoke<boolean>('update_game', { id }); const success = await invoke<boolean>('update_game', {
id,
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]); }, [games, settings.username]);
const uninstall = useCallback(async (id: string) => { const uninstall = useCallback(async (id: string) => {
try { try {