feat(ui): delegate install lifecycle to the peer

Remove the Tauri-side whole-game backup and unpack flow. The Tauri shell now
provides an injected unrar sidecar implementation and lets the peer own
install, update, uninstall, rollback, and recovery decisions.

Route install commands by local state: missing version.ini fetches from peers,
downloaded archives without local/ send InstallGame directly, and already
installed games are left to the Play action. Updates request a fresh download
and uninstalls forward UninstallGame. The UI mirrors peer operation events for
downloading, installing, updating, and uninstalling.

Render installed-but-not-downloaded games as LocalOnly and surface the local
version for downloaded-but-not-installed games. Add a secondary uninstall
affordance that does not change the main Install/Open action.

Test Plan:
- just fmt
- just clippy
- just test
- just build

Refs: PLAN.md
This commit is contained in:
2026-05-15 18:20:45 +02:00
parent 6c8a2bb9f0
commit c5dfbf99a0
3 changed files with 399 additions and 256 deletions
@@ -1,7 +1,7 @@
#[cfg(target_os = "windows")]
use std::fs::File;
use std::{
collections::HashSet,
collections::{HashMap, HashSet},
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
@@ -10,7 +10,16 @@ use std::{
use eyre::bail;
use lanspread_compat::eti::get_games;
use lanspread_db::db::{Game, GameDB, GameFileDescription};
use lanspread_peer::{PeerCommand, PeerEvent, PeerGameDB, PeerRuntimeHandle, start_peer};
use lanspread_peer::{
InstallOperation,
PeerCommand,
PeerEvent,
PeerGameDB,
PeerRuntimeHandle,
UnpackFuture,
Unpacker,
start_peer,
};
use tauri::{AppHandle, Emitter as _, Manager};
use tauri_plugin_shell::{ShellExt, process::Command};
use tokio::sync::{
@@ -26,13 +35,35 @@ struct LanSpreadState {
peer_ctrl: Arc<RwLock<Option<UnboundedSender<PeerCommand>>>>,
peer_runtime: Arc<RwLock<Option<PeerRuntimeHandle>>>,
games: Arc<RwLock<GameDB>>,
games_in_download: Arc<RwLock<HashSet<String>>>,
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
games_folder: Arc<RwLock<String>>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
catalog: Arc<RwLock<HashSet<String>>>,
}
struct PeerEventTx(UnboundedSender<PeerEvent>);
#[derive(Clone, Copy, Debug)]
enum UiOperationKind {
Downloading,
Installing,
Updating,
Uninstalling,
}
struct SidecarUnpacker {
app_handle: AppHandle,
}
impl Unpacker for SidecarUnpacker {
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
Box::pin(async move {
let sidecar = self.app_handle.shell().sidecar("unrar")?;
do_unrar(sidecar, archive, dest).await
})
}
}
#[cfg(target_os = "windows")]
const FIRST_START_DONE_FILE: &str = ".softlan_first_start_done";
@@ -56,26 +87,35 @@ 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> {
let games_in_download = state.inner().games_in_download.clone();
let already_in_download = {
let guard = games_in_download.read().await;
if guard.contains(&id) {
log::warn!("Game is already downloading: {id}");
true
} else {
false
}
};
if already_in_download {
if state
.inner()
.active_operations
.read()
.await
.contains_key(&id)
{
log::warn!("Game already has an active operation: {id}");
return Ok(false);
}
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
let peer_ctrl = peer_ctrl_arc.read().await.clone();
let games_folder = state.inner().games_folder.read().await.clone();
let game_path = PathBuf::from(games_folder).join(&id);
let downloaded = game_path.join("version.ini").is_file();
let installed = local_install_is_present(&game_path);
let handled = if let Some(peer_ctrl) = peer_ctrl {
if let Err(e) = peer_ctrl.send(PeerCommand::GetGame(id)) {
let command = if !downloaded {
PeerCommand::GetGame(id)
} else if !installed {
PeerCommand::InstallGame { id }
} else {
log::info!("Game is already installed: {id}");
return Ok(false);
};
if let Err(e) = peer_ctrl.send(command) {
log::error!("Failed to send message to peer: {e:?}");
}
true
@@ -87,169 +127,56 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta
Ok(handled)
}
/// Backup the current game folder by renaming it to `___TO_BE_DELETE___GameNameHere`
/// # Errors
/// Returns error if backup fails
fn backup_game_folder(game_path: &Path) -> eyre::Result<PathBuf> {
if !game_path.exists() {
bail!("Game folder does not exist: {}", game_path.display());
}
let game_name = game_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| eyre::eyre!("Invalid game folder name"))?;
let parent = game_path
.parent()
.ok_or_else(|| eyre::eyre!("Cannot get parent directory"))?;
let backup_name = format!("___TO_BE_DELETE___{game_name}");
let backup_path = parent.join(backup_name);
// Remove existing backup if it exists
if backup_path.exists() {
std::fs::remove_dir_all(&backup_path)?;
}
// Rename current game folder to backup
std::fs::rename(game_path, &backup_path)?;
log::info!(
"Backed up game folder: {} -> {}",
game_path.display(),
backup_path.display()
);
Ok(backup_path)
}
/// Restore game folder from backup
/// # Errors
/// Returns error if restore fails
fn restore_game_folder(game_path: &Path, backup_path: &Path) -> eyre::Result<()> {
if !backup_path.exists() {
bail!("Backup folder does not exist: {}", backup_path.display());
}
// Remove the partially installed game folder if it exists
if game_path.exists() {
std::fs::remove_dir_all(game_path)?;
}
// Restore backup
std::fs::rename(backup_path, game_path)?;
log::info!(
"Restored game folder from backup: {} -> {}",
backup_path.display(),
game_path.display()
);
Ok(())
}
/// Remove backup folder after successful update
/// # Errors
/// Returns error if cleanup fails
fn cleanup_backup_folder(backup_path: &Path) -> eyre::Result<()> {
if backup_path.exists() {
std::fs::remove_dir_all(backup_path)?;
log::info!("Cleaned up backup folder: {}", backup_path.display());
}
Ok(())
}
async fn cleanup_failed_download(app_handle: &AppHandle, id: &str) {
app_handle
.state::<LanSpreadState>()
.inner()
.games_in_download
.write()
.await
.remove(id);
let games_folder = app_handle
.state::<LanSpreadState>()
.inner()
.games_folder
.read()
.await
.clone();
if games_folder.is_empty() {
return;
}
let backup_name = format!("___TO_BE_DELETE___{id}");
let backup_path = PathBuf::from(&games_folder).join(&backup_name);
let game_path = PathBuf::from(&games_folder).join(id);
if game_path.exists() {
if let Err(e) = std::fs::remove_dir_all(&game_path) {
log::error!("Failed to delete half-downloaded game folder: {e}");
} else {
log::info!(
"Deleted half-downloaded game folder: {}",
game_path.display()
);
}
}
if let Err(e) = restore_game_folder(&game_path, &backup_path) {
log::error!("Failed to restore backup after download failure: {e}");
}
}
#[tauri::command]
async fn update_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<bool> {
let games_in_download = state.inner().games_in_download.clone();
let already_in_download = {
let guard = games_in_download.read().await;
if guard.contains(&id) {
log::warn!("Game is already downloading/updating: {id}");
true
} else {
false
}
};
if already_in_download {
if state
.inner()
.active_operations
.read()
.await
.contains_key(&id)
{
log::warn!("Game already has an active operation: {id}");
return Ok(false);
}
// Get the games folder
let games_folder_lock = state.inner().games_folder.clone();
let games_folder = {
let guard = games_folder_lock.read().await;
guard.clone()
};
let games_folder = PathBuf::from(games_folder);
let game_path = games_folder.join(&id);
// Backup current game folder
let backup_path = match backup_game_folder(&game_path) {
Ok(path) => path,
Err(e) => {
log::error!("Failed to backup game folder for {id}: {e}");
return Ok(false);
}
};
log::info!("Starting update for game: {id}");
// Start the download process
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
let peer_ctrl = peer_ctrl_arc.read().await.clone();
if let Some(peer_ctrl) = peer_ctrl {
if let Err(e) = peer_ctrl.send(PeerCommand::GetGame(id.clone())) {
if let Err(e) = peer_ctrl.send(PeerCommand::GetGame(id)) {
log::error!("Failed to send message to peer: {e:?}");
return Ok(false);
}
Ok(true)
} else {
log::warn!("Peer system not initialized yet");
Ok(false)
}
}
// Try to restore backup if download fails to start
if let Err(restore_err) = restore_game_folder(&game_path, &backup_path) {
log::error!("Failed to restore backup after download failure: {restore_err}");
}
#[tauri::command]
async fn uninstall_game(
id: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
if state
.inner()
.active_operations
.read()
.await
.contains_key(&id)
{
log::warn!("Game already has an active operation: {id}");
return Ok(false);
}
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
let peer_ctrl = peer_ctrl_arc.read().await.clone();
if let Some(peer_ctrl) = peer_ctrl {
if let Err(e) = peer_ctrl.send(PeerCommand::UninstallGame { id }) {
log::error!("Failed to send message to peer: {e:?}");
return Ok(false);
}
Ok(true)
@@ -344,9 +271,9 @@ async fn run_game_windows(
let first_start_done_file = game_path.join("local").join(FIRST_START_DONE_FILE);
if !first_start_done_file.exists() && game_setup_bin.exists() {
if !local_install_is_ready(&game_path) {
if !local_install_is_present(&game_path) {
log::warn!(
"local install is missing or incomplete for {}; skipping game_setup",
"local install is missing for {}; skipping game_setup",
game_path.display()
);
return Ok(());
@@ -414,32 +341,8 @@ fn eti_package_exists(game_path: &Path, game_id: &str) -> bool {
game_path.is_dir() && game_path.join(format!("{game_id}.eti")).is_file()
}
fn local_install_is_ready(game_path: &Path) -> bool {
let local_dir = game_path.join("local");
if !local_dir.is_dir() {
return false;
}
match std::fs::read_dir(&local_dir) {
Ok(mut entries) => match entries.next() {
Some(Ok(_)) => true,
Some(Err(e)) => {
log::warn!(
"Failed to inspect entry in local dir {}: {e}",
local_dir.display()
);
false
}
None => false,
},
Err(e) => {
log::warn!(
"Failed to enumerate local dir for game {}: {e}",
game_path.display()
);
false
}
}
fn local_install_is_present(game_path: &Path) -> bool {
game_path.join("local").is_dir()
}
fn update_game_installation_state(game: &mut Game, games_root: &Path) {
@@ -448,16 +351,15 @@ fn update_game_installation_state(game: &mut Game, games_root: &Path) {
return;
}
let downloaded = eti_package_exists(&game_path, &game.id);
let downloaded = game_path.join("version.ini").is_file();
game.downloaded = downloaded;
let installed = downloaded && local_install_is_ready(&game_path);
let installed = local_install_is_present(&game_path);
game.installed = installed;
// Size stays anchored to bundled game.db; skip expensive recalculation.
if installed {
log::debug!("Set {game} to installed");
if downloaded {
match lanspread_db::db::read_version_from_ini(&game_path) {
Ok(version) => {
game.local_version = version;
@@ -472,12 +374,17 @@ fn update_game_installation_state(game: &mut Game, games_root: &Path) {
}
} else {
game.local_version = None;
if downloaded {
log::debug!(
"Game {} is downloaded but awaiting local install contents",
game.id
);
}
}
if installed {
log::debug!("Set {game} to installed");
}
if eti_package_exists(&game_path, &game.id) && !downloaded {
log::debug!(
"Game {} has archives but no version.ini sentinel; treating as not downloaded",
game.id
);
}
}
@@ -758,6 +665,11 @@ async fn do_unrar(sidecar: Command, rar_file: &Path, dest_dir: &Path) -> eyre::R
if !out.status.success() {
log::error!("unrar stderr: {}", String::from_utf8_lossy(&out.stderr));
bail!(
"unrar failed with status {:?}: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
}
return Ok(());
@@ -771,16 +683,6 @@ async fn do_unrar(sidecar: Command, rar_file: &Path, dest_dir: &Path) -> eyre::R
bail!("failed to create directory: {dest_dir:?}");
}
async fn unpack_game(id: &str, sidecar: Command, games_folder: &str) {
let game_path = PathBuf::from(games_folder).join(id);
let eti_rar = game_path.join(format!("{id}.eti"));
let local_path = game_path.join("local");
if let Err(e) = do_unrar(sidecar, &eti_rar, &local_path).await {
log::error!("{} -> {}: {e}", eti_rar.display(), local_path.display());
}
}
/// Resolve the bundled catalog database packaged with the Tauri application.
fn resolve_bundled_game_db_path(app_handle: &AppHandle) -> PathBuf {
app_handle
@@ -812,7 +714,9 @@ async fn ensure_bundled_game_db_loaded(app_handle: &AppHandle) {
if needs_load {
let game_db = load_bundled_game_db(app_handle).await;
let catalog = game_db.games.keys().cloned().collect::<HashSet<_>>();
*state.games.write().await = game_db;
*state.catalog.write().await = catalog;
}
}
@@ -828,10 +732,15 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
}
let tx_peer_event = app_handle.state::<PeerEventTx>().inner().0.clone();
let unpacker = Arc::new(SidecarUnpacker {
app_handle: app_handle.clone(),
});
match start_peer(
games_folder.to_path_buf(),
tx_peer_event,
state.peer_game_db.clone(),
unpacker,
state.catalog.clone(),
) {
Ok(handle) => {
let sender = handle.sender();
@@ -895,7 +804,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
);
app_handle
.state::<LanSpreadState>()
.games_in_download
.active_operations
.write()
.await
.remove(&id);
@@ -904,10 +813,10 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
log::info!("PeerEvent::DownloadGameFilesBegin received");
app_handle
.state::<LanSpreadState>()
.games_in_download
.active_operations
.write()
.await
.insert(id.clone());
.insert(id.clone(), UiOperationKind::Downloading);
emit_game_id_event(
app_handle,
"game-download-begin",
@@ -926,7 +835,12 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
&id,
"PeerEvent::DownloadGameFilesFailed",
);
cleanup_failed_download(app_handle, &id).await;
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.remove(&id);
}
PeerEvent::DownloadGameFilesAllPeersGone { id } => {
log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}");
@@ -936,7 +850,107 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
&id,
"PeerEvent::DownloadGameFilesAllPeersGone",
);
cleanup_failed_download(app_handle, &id).await;
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.remove(&id);
}
PeerEvent::InstallGameBegin { id, operation } => {
let operation_name: &'static str = (&operation).into();
log::info!("PeerEvent::InstallGameBegin received for {id}: {operation_name}");
let ui_operation = match operation {
InstallOperation::Installing => UiOperationKind::Installing,
InstallOperation::Updating => UiOperationKind::Updating,
};
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.insert(id.clone(), ui_operation);
emit_game_id_event(
app_handle,
"game-install-begin",
&id,
"PeerEvent::InstallGameBegin",
);
}
PeerEvent::InstallGameFinished { id } => {
log::info!("PeerEvent::InstallGameFinished received for {id}");
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.remove(&id);
emit_game_id_event(
app_handle,
"game-unpack-finished",
&id,
"PeerEvent::InstallGameFinished",
);
}
PeerEvent::InstallGameFailed { id } => {
log::warn!("PeerEvent::InstallGameFailed received for {id}");
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.remove(&id);
emit_game_id_event(
app_handle,
"game-install-failed",
&id,
"PeerEvent::InstallGameFailed",
);
}
PeerEvent::UninstallGameBegin { id } => {
log::info!("PeerEvent::UninstallGameBegin received for {id}");
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.insert(id.clone(), UiOperationKind::Uninstalling);
emit_game_id_event(
app_handle,
"game-uninstall-begin",
&id,
"PeerEvent::UninstallGameBegin",
);
}
PeerEvent::UninstallGameFinished { id } => {
log::info!("PeerEvent::UninstallGameFinished received for {id}");
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.remove(&id);
emit_game_id_event(
app_handle,
"game-uninstall-finished",
&id,
"PeerEvent::UninstallGameFinished",
);
}
PeerEvent::UninstallGameFailed { id } => {
log::warn!("PeerEvent::UninstallGameFailed received for {id}");
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.remove(&id);
emit_game_id_event(
app_handle,
"game-uninstall-failed",
&id,
"PeerEvent::UninstallGameFailed",
);
}
PeerEvent::PeerConnected(addr) => {
log::info!("Peer connected: {addr}");
@@ -1009,41 +1023,10 @@ async fn handle_download_finished(app_handle: &AppHandle, id: String) {
app_handle
.state::<LanSpreadState>()
.games_in_download
.active_operations
.write()
.await
.remove(&id);
let games_folder = app_handle
.state::<LanSpreadState>()
.games_folder
.read()
.await
.clone();
if let Ok(sidecar) = app_handle.shell().sidecar("unrar") {
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
unpack_game(&id, sidecar, &games_folder).await;
if !games_folder.is_empty() {
let backup_name = format!("___TO_BE_DELETE___{id}");
let backup_path = PathBuf::from(&games_folder).join(backup_name);
if let Err(e) = cleanup_backup_folder(&backup_path) {
log::error!("Failed to cleanup backup folder after successful update: {e}");
}
}
log::info!("PeerEvent::UnpackGameFinished received");
emit_game_id_event(
&app_handle,
"game-unpack-finished",
&id,
"PeerEvent::UnpackGameFinished",
);
});
}
}
#[allow(clippy::missing_panics_doc)]
@@ -1071,6 +1054,7 @@ pub fn run() {
run_game,
update_game_directory,
update_game,
uninstall_game,
get_peer_count,
get_game_thumbnail
])