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
@@ -42,6 +42,7 @@ 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>>>,
state_dir: OnceLock<PathBuf>,
}
@@ -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<bool> {
async fn install_game(
id: String,
username: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
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<bool> {
async fn update_game(
id: String,
username: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
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<bool> {
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::<LanSpreadState>();
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::<LanSpreadState>();
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}");
@@ -51,7 +51,10 @@ export const useGameActions = (
const install = useCallback(async (id: string) => {
try {
const success = await invoke<boolean>('install_game', { id });
const success = await invoke<boolean>('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<boolean>('update_game', { id });
const success = await invoke<boolean>('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 {