diff --git a/crates/lanspread-peer-cli/src/main.rs b/crates/lanspread-peer-cli/src/main.rs index 67ecb28..411b863 100644 --- a/crates/lanspread-peer-cli/src/main.rs +++ b/crates/lanspread-peer-cli/src/main.rs @@ -240,6 +240,7 @@ async fn handle_command( id: game_id.clone(), file_descriptions: files, install_after_download: *install_after_download, + account_name: None, })?; 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?; sender.send(PeerCommand::InstallGame { id: game_id.clone(), + account_name: None, })?; Ok(json!({"queued": true, "game_id": game_id})) } diff --git a/crates/lanspread-peer/src/handlers.rs b/crates/lanspread-peer/src/handlers.rs index 8adca68..699c7e8 100644 --- a/crates/lanspread-peer/src/handlers.rs +++ b/crates/lanspread-peer/src/handlers.rs @@ -199,6 +199,7 @@ pub async fn handle_download_game_files_command( id: String, file_descriptions: Vec, install_after_download: bool, + account_name: Option, ) { log::info!("Got PeerCommand::DownloadGameFiles"); 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}"); } 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 { 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, download_id, prepared, + account_name, ) .await; } else { @@ -419,8 +421,9 @@ pub async fn handle_install_game_command( ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: String, + account_name: Option, ) { - spawn_install_operation(ctx, tx_notify_ui, id); + spawn_install_operation(ctx, tx_notify_ui, id, account_name); } /// Handles the `UninstallGame` command. @@ -463,15 +466,25 @@ pub async fn handle_cancel_download_command( cancel_token.cancel(); } -fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: String) { +fn spawn_install_operation( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + id: String, + account_name: Option, +) { 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).await; + run_install_operation(&ctx, &tx_notify_ui, id, account_name).await; }); } -async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: String) { +async fn run_install_operation( + ctx: &Ctx, + tx_notify_ui: &UnboundedSender, + id: String, + account_name: Option, +) { let Some(prepared) = prepare_install_operation(ctx, tx_notify_ui, &id).await else { return; }; @@ -481,7 +494,7 @@ async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: String, prepared: PreparedInstallOperation, + account_name: Option, ) { let PreparedInstallOperation { game_root, @@ -557,10 +571,24 @@ async fn run_started_install_operation( let state_dir = ctx.state_dir.as_ref(); match operation { 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 => { - 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; 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( recv_event(&mut rx).await, @@ -1506,7 +1534,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()).await; + run_install_operation(&ctx, &tx, "game".to_string(), None).await; assert_active_update( recv_event(&mut rx).await, @@ -1560,7 +1588,7 @@ mod tests { .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 (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( recv_event(&mut rx).await, @@ -1659,7 +1687,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()).await; + run_install_operation(&ctx, &tx, "game".to_string(), None).await; assert_active_update( recv_event(&mut rx).await, active_update("game", ActiveOperationKind::Installing), @@ -1682,7 +1710,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()).await; + run_install_operation(&ctx, &tx, "game".to_string(), None).await; assert_active_update( recv_event(&mut rx).await, active_update("game", ActiveOperationKind::Updating), diff --git a/crates/lanspread-peer/src/install/transaction.rs b/crates/lanspread-peer/src/install/transaction.rs index f30ab24..0b33596 100644 --- a/crates/lanspread-peer/src/install/transaction.rs +++ b/crates/lanspread-peer/src/install/transaction.rs @@ -1,5 +1,6 @@ use std::{ collections::HashSet, + ffi::OsStr, io::ErrorKind, path::{Path, PathBuf}, sync::Arc, @@ -19,6 +20,7 @@ const BACKUP_DIR: &str = ".local.backup"; 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"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum FsEntryState { @@ -38,6 +40,7 @@ pub async fn install( state_dir: &Path, id: &str, unpacker: Arc, + account_name: Option<&str>, ) -> eyre::Result<()> { let eti_version = read_downloaded_version(game_root).await; write_intent( @@ -47,7 +50,7 @@ pub async fn install( ) .await?; - let result = install_inner(game_root, id, unpacker).await; + let result = install_inner(game_root, id, unpacker, account_name).await; match result { Ok(()) => { write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?; @@ -71,6 +74,7 @@ pub async fn update( state_dir: &Path, id: &str, unpacker: Arc, + account_name: Option<&str>, ) -> eyre::Result<()> { let eti_version = read_downloaded_version(game_root).await; write_intent( @@ -80,7 +84,7 @@ pub async fn update( ) .await?; - let result = update_inner(game_root, id, unpacker).await; + let result = update_inner(game_root, id, unpacker, account_name).await; match result { Ok(()) => { write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?; @@ -188,6 +192,7 @@ async fn install_inner( game_root: &Path, id: &str, unpacker: Arc, + account_name: Option<&str>, ) -> eyre::Result<()> { let local = local_dir(game_root); if path_is_dir(&local).await { @@ -197,13 +202,19 @@ 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?; tokio::fs::rename(&staging, &local) .await .wrap_err_with(|| format!("failed to promote install for {id}"))?; Ok(()) } -async fn update_inner(game_root: &Path, id: &str, unpacker: Arc) -> eyre::Result<()> { +async fn update_inner( + game_root: &Path, + id: &str, + unpacker: Arc, + account_name: Option<&str>, +) -> eyre::Result<()> { let local = local_dir(game_root); let backup = backup_dir(game_root); let staging = installing_dir(game_root); @@ -219,6 +230,7 @@ async fn update_inner(game_root: &Path, id: &str, unpacker: Arc) - prepare_owned_empty_dir(&staging).await?; unpack_archives(game_root, &staging, unpacker).await?; + write_account_name_if_present(&staging, account_name).await?; tokio::fs::rename(&staging, &local) .await .wrap_err_with(|| format!("failed to promote update for {id}"))?; @@ -272,6 +284,48 @@ async fn root_eti_archives(game_root: &Path) -> eyre::Result> { 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> { + 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<()> { sweep_owned_orphan(&installing_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("version.ini"), b"20250101"); - install(&root, state.path(), "game", successful_unpacker()) + install(&root, state.path(), "game", successful_unpacker(), None) .await .expect("install should succeed"); @@ -586,6 +640,28 @@ mod tests { 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] async fn install_unpacks_multiple_root_eti_archives_in_sorted_order() { let temp = TempDir::new("lanspread-install"); @@ -596,7 +672,7 @@ mod tests { write_file(&root.join("version.ini"), b"20250101"); let unpacker = Arc::new(FakeUnpacker::default()); - install(&root, state.path(), "game", unpacker.clone()) + install(&root, state.path(), "game", unpacker.clone(), None) .await .expect("install should succeed"); @@ -610,6 +686,50 @@ mod tests { 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] async fn update_failure_restores_previous_local() { let temp = TempDir::new("lanspread-install"); @@ -624,6 +744,7 @@ mod tests { state.path(), "game", Arc::new(FakeUnpacker::failing()), + None, ) .await .expect_err("update should fail"); @@ -650,6 +771,7 @@ mod tests { state.path(), "game", Arc::new(FakeUnpacker::commit_conflict()), + None, ) .await .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("local").join("old.txt"), b"old"); - update(&root, state.path(), "game", successful_unpacker()) + update(&root, state.path(), "game", successful_unpacker(), None) .await .expect("update should succeed"); diff --git a/crates/lanspread-peer/src/lib.rs b/crates/lanspread-peer/src/lib.rs index 3c0afb5..edebf9b 100644 --- a/crates/lanspread-peer/src/lib.rs +++ b/crates/lanspread-peer/src/lib.rs @@ -229,15 +229,20 @@ pub enum PeerCommand { DownloadGameFiles { id: String, file_descriptions: Vec, + account_name: Option, }, /// Download game files with an explicit install policy. DownloadGameFilesWithOptions { id: String, file_descriptions: Vec, install_after_download: bool, + account_name: Option, }, /// Install already-downloaded archives into `local/`. - InstallGame { id: String }, + InstallGame { + id: String, + account_name: Option, + }, /// Remove only the `local/` install for a game. UninstallGame { id: String }, /// Remove downloaded archive files for an uninstalled game. @@ -405,14 +410,23 @@ async fn handle_peer_commands( PeerCommand::DownloadGameFiles { id, file_descriptions, + account_name, } => { - handle_download_game_files_command(ctx, tx_notify_ui, id, file_descriptions, true) - .await; + handle_download_game_files_command( + ctx, + tx_notify_ui, + id, + file_descriptions, + true, + account_name, + ) + .await; } PeerCommand::DownloadGameFilesWithOptions { id, file_descriptions, install_after_download, + account_name, } => { handle_download_game_files_command( ctx, @@ -420,11 +434,12 @@ async fn handle_peer_commands( id, file_descriptions, install_after_download, + account_name, ) .await; } - PeerCommand::InstallGame { id } => { - handle_install_game_command(ctx, tx_notify_ui, id).await; + PeerCommand::InstallGame { id, account_name } => { + handle_install_game_command(ctx, tx_notify_ui, id, account_name).await; } PeerCommand::UninstallGame { id } => { handle_uninstall_game_command(ctx, tx_notify_ui, id).await; diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index 30d2e54..d398994 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -42,6 +42,7 @@ struct LanSpreadState { peer_game_db: Arc>, catalog: Arc>>, unpack_logs: Arc>>, + pending_install_account_names: Arc>>, state_dir: OnceLock, } @@ -142,7 +143,11 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result } #[tauri::command] -async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result { +async fn install_game( + id: String, + username: String, + state: tauri::State<'_, LanSpreadState>, +) -> tauri::Result { if state .inner() .active_operations @@ -168,11 +173,21 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta return Ok(false); }; + let account_name = sanitize_username(&username); let handled = if let Some(peer_ctrl) = peer_ctrl { 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 { - PeerCommand::InstallGame { id } + PeerCommand::InstallGame { + id: id.clone(), + account_name: Some(account_name), + } } else { log::info!("Game is already installed: {id}"); 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) { log::error!("Failed to send message to peer: {e:?}"); + state + .inner() + .pending_install_account_names + .write() + .await + .remove(&id); + return Ok(false); } true } else { @@ -191,7 +213,11 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta } #[tauri::command] -async fn update_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result { +async fn update_game( + id: String, + username: String, + state: tauri::State<'_, LanSpreadState>, +) -> tauri::Result { if state .inner() .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(); 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:?}"); + state + .inner() + .pending_install_account_names + .write() + .await + .remove(&id); return Ok(false); } Ok(true) @@ -302,6 +340,13 @@ async fn cancel_download( id: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { + state + .inner() + .pending_install_account_names + .write() + .await + .remove(&id); + let is_active_download = { let active_operations = state.inner().active_operations.read().await; matches!( @@ -1436,6 +1481,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; emit_game_id_event( app_handle, "game-no-peers", @@ -1474,6 +1520,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; emit_game_id_event( app_handle, "game-download-failed", @@ -1483,6 +1530,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; emit_game_id_event( app_handle, "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::(); + state.pending_install_account_names.write().await.remove(id); +} + async fn handle_got_game_files( app_handle: &AppHandle, id: String, @@ -1621,11 +1674,17 @@ async fn handle_got_game_files( ); let state = app_handle.state::(); + let account_name = state + .pending_install_account_names + .write() + .await + .remove(&id); 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, }) { log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}"); diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts index 7f1e82f..51fcdfd 100644 --- a/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts @@ -51,7 +51,10 @@ export const useGameActions = ( const install = useCallback(async (id: string) => { try { - const success = await invoke('install_game', { id }); + const success = await invoke('install_game', { + id, + username: settings.username, + }); if (!success) return; const game = games.games.find(item => item.id === id); @@ -61,16 +64,19 @@ export const useGameActions = ( } catch (err) { console.error('install_game failed:', err); } - }, [games]); + }, [games, settings.username]); const update = useCallback(async (id: string) => { try { - const success = await invoke('update_game', { id }); + const success = await invoke('update_game', { + id, + username: settings.username, + }); if (success) games.markChecking(id); } catch (err) { console.error('update_game failed:', err); } - }, [games]); + }, [games, settings.username]); const uninstall = useCallback(async (id: string) => { try {