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")] #[cfg(target_os = "windows")]
use std::fs::File; use std::fs::File;
use std::{ use std::{
collections::HashSet, collections::{HashMap, HashSet},
net::SocketAddr, net::SocketAddr,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc, sync::Arc,
@@ -10,7 +10,16 @@ use std::{
use eyre::bail; use eyre::bail;
use lanspread_compat::eti::get_games; use lanspread_compat::eti::get_games;
use lanspread_db::db::{Game, GameDB, GameFileDescription}; 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::{AppHandle, Emitter as _, Manager};
use tauri_plugin_shell::{ShellExt, process::Command}; use tauri_plugin_shell::{ShellExt, process::Command};
use tokio::sync::{ use tokio::sync::{
@@ -26,13 +35,35 @@ struct LanSpreadState {
peer_ctrl: Arc<RwLock<Option<UnboundedSender<PeerCommand>>>>, peer_ctrl: Arc<RwLock<Option<UnboundedSender<PeerCommand>>>>,
peer_runtime: Arc<RwLock<Option<PeerRuntimeHandle>>>, peer_runtime: Arc<RwLock<Option<PeerRuntimeHandle>>>,
games: Arc<RwLock<GameDB>>, games: Arc<RwLock<GameDB>>,
games_in_download: Arc<RwLock<HashSet<String>>>, active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
games_folder: Arc<RwLock<String>>, games_folder: Arc<RwLock<String>>,
peer_game_db: Arc<RwLock<PeerGameDB>>, peer_game_db: Arc<RwLock<PeerGameDB>>,
catalog: Arc<RwLock<HashSet<String>>>,
} }
struct PeerEventTx(UnboundedSender<PeerEvent>); 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")] #[cfg(target_os = "windows")]
const FIRST_START_DONE_FILE: &str = ".softlan_first_start_done"; 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] #[tauri::command]
async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<bool> { async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<bool> {
let games_in_download = state.inner().games_in_download.clone(); if state
let already_in_download = { .inner()
let guard = games_in_download.read().await; .active_operations
if guard.contains(&id) { .read()
log::warn!("Game is already downloading: {id}"); .await
true .contains_key(&id)
} else { {
false log::warn!("Game already has an active operation: {id}");
}
};
if already_in_download {
return Ok(false); return Ok(false);
} }
let peer_ctrl_arc = state.inner().peer_ctrl.clone(); let peer_ctrl_arc = state.inner().peer_ctrl.clone();
let peer_ctrl = peer_ctrl_arc.read().await.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 { 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:?}"); log::error!("Failed to send message to peer: {e:?}");
} }
true true
@@ -87,169 +127,56 @@ async fn install_game(id: String, state: tauri::State<'_, LanSpreadState>) -> ta
Ok(handled) 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] #[tauri::command]
async fn update_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<bool> { async fn update_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<bool> {
let games_in_download = state.inner().games_in_download.clone(); if state
let already_in_download = { .inner()
let guard = games_in_download.read().await; .active_operations
if guard.contains(&id) { .read()
log::warn!("Game is already downloading/updating: {id}"); .await
true .contains_key(&id)
} else { {
false log::warn!("Game already has an active operation: {id}");
}
};
if already_in_download {
return Ok(false); 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}"); log::info!("Starting update for game: {id}");
// Start the download process
let peer_ctrl_arc = state.inner().peer_ctrl.clone(); let peer_ctrl_arc = state.inner().peer_ctrl.clone();
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::GetGame(id.clone())) { if let Err(e) = peer_ctrl.send(PeerCommand::GetGame(id)) {
log::error!("Failed to send message to peer: {e:?}"); 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 #[tauri::command]
if let Err(restore_err) = restore_game_folder(&game_path, &backup_path) { async fn uninstall_game(
log::error!("Failed to restore backup after download failure: {restore_err}"); 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); return Ok(false);
} }
Ok(true) 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); 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 !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!( log::warn!(
"local install is missing or incomplete for {}; skipping game_setup", "local install is missing for {}; skipping game_setup",
game_path.display() game_path.display()
); );
return Ok(()); 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() game_path.is_dir() && game_path.join(format!("{game_id}.eti")).is_file()
} }
fn local_install_is_ready(game_path: &Path) -> bool { fn local_install_is_present(game_path: &Path) -> bool {
let local_dir = game_path.join("local"); game_path.join("local").is_dir()
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 update_game_installation_state(game: &mut Game, games_root: &Path) { 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; return;
} }
let downloaded = eti_package_exists(&game_path, &game.id); let downloaded = game_path.join("version.ini").is_file();
game.downloaded = downloaded; game.downloaded = downloaded;
let installed = downloaded && local_install_is_ready(&game_path); let installed = local_install_is_present(&game_path);
game.installed = installed; game.installed = installed;
// Size stays anchored to bundled game.db; skip expensive recalculation. // Size stays anchored to bundled game.db; skip expensive recalculation.
if installed { if downloaded {
log::debug!("Set {game} to installed");
match lanspread_db::db::read_version_from_ini(&game_path) { match lanspread_db::db::read_version_from_ini(&game_path) {
Ok(version) => { Ok(version) => {
game.local_version = version; game.local_version = version;
@@ -472,13 +374,18 @@ fn update_game_installation_state(game: &mut Game, games_root: &Path) {
} }
} else { } else {
game.local_version = None; game.local_version = None;
if downloaded { }
if installed {
log::debug!("Set {game} to installed");
}
if eti_package_exists(&game_path, &game.id) && !downloaded {
log::debug!( log::debug!(
"Game {} is downloaded but awaiting local install contents", "Game {} has archives but no version.ini sentinel; treating as not downloaded",
game.id game.id
); );
} }
}
} }
/// Left in place for potential re-enablement. Currently not invoked to avoid expensive IO. /// Left in place for potential re-enablement. Currently not invoked to avoid expensive IO.
@@ -758,6 +665,11 @@ async fn do_unrar(sidecar: Command, rar_file: &Path, dest_dir: &Path) -> eyre::R
if !out.status.success() { if !out.status.success() {
log::error!("unrar stderr: {}", String::from_utf8_lossy(&out.stderr)); 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(()); 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:?}"); 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. /// Resolve the bundled catalog database packaged with the Tauri application.
fn resolve_bundled_game_db_path(app_handle: &AppHandle) -> PathBuf { fn resolve_bundled_game_db_path(app_handle: &AppHandle) -> PathBuf {
app_handle app_handle
@@ -812,7 +714,9 @@ async fn ensure_bundled_game_db_loaded(app_handle: &AppHandle) {
if needs_load { if needs_load {
let game_db = load_bundled_game_db(app_handle).await; 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.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 tx_peer_event = app_handle.state::<PeerEventTx>().inner().0.clone();
let unpacker = Arc::new(SidecarUnpacker {
app_handle: app_handle.clone(),
});
match start_peer( match start_peer(
games_folder.to_path_buf(), games_folder.to_path_buf(),
tx_peer_event, tx_peer_event,
state.peer_game_db.clone(), state.peer_game_db.clone(),
unpacker,
state.catalog.clone(),
) { ) {
Ok(handle) => { Ok(handle) => {
let sender = handle.sender(); let sender = handle.sender();
@@ -895,7 +804,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
); );
app_handle app_handle
.state::<LanSpreadState>() .state::<LanSpreadState>()
.games_in_download .active_operations
.write() .write()
.await .await
.remove(&id); .remove(&id);
@@ -904,10 +813,10 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
log::info!("PeerEvent::DownloadGameFilesBegin received"); log::info!("PeerEvent::DownloadGameFilesBegin received");
app_handle app_handle
.state::<LanSpreadState>() .state::<LanSpreadState>()
.games_in_download .active_operations
.write() .write()
.await .await
.insert(id.clone()); .insert(id.clone(), UiOperationKind::Downloading);
emit_game_id_event( emit_game_id_event(
app_handle, app_handle,
"game-download-begin", "game-download-begin",
@@ -926,7 +835,12 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
&id, &id,
"PeerEvent::DownloadGameFilesFailed", "PeerEvent::DownloadGameFilesFailed",
); );
cleanup_failed_download(app_handle, &id).await; app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.remove(&id);
} }
PeerEvent::DownloadGameFilesAllPeersGone { id } => { PeerEvent::DownloadGameFilesAllPeersGone { id } => {
log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}"); log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}");
@@ -936,7 +850,107 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
&id, &id,
"PeerEvent::DownloadGameFilesAllPeersGone", "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) => { PeerEvent::PeerConnected(addr) => {
log::info!("Peer connected: {addr}"); log::info!("Peer connected: {addr}");
@@ -1009,41 +1023,10 @@ async fn handle_download_finished(app_handle: &AppHandle, id: String) {
app_handle app_handle
.state::<LanSpreadState>() .state::<LanSpreadState>()
.games_in_download .active_operations
.write() .write()
.await .await
.remove(&id); .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)] #[allow(clippy::missing_panics_doc)]
@@ -1071,6 +1054,7 @@ pub fn run() {
run_game, run_game,
update_game_directory, update_game_directory,
update_game, update_game,
uninstall_game,
get_peer_count, get_peer_count,
get_game_thumbnail get_game_thumbnail
]) ])
@@ -74,6 +74,29 @@ h1.align-center {
font-size: 0.9em; font-size: 0.9em;
} }
.badges {
display: flex;
min-height: 24px;
gap: 6px;
justify-content: center;
align-items: center;
padding: 0 10px 8px;
}
.badge {
border: 1px solid #4866b9;
border-radius: 4px;
color: #D5DBFE;
font-size: 12px;
line-height: 1;
padding: 5px 7px;
}
.badge.local-only {
border-color: #8b6f2a;
color: #f1d58a;
}
.desc-text { .desc-text {
text-align: left; text-align: left;
} }
@@ -127,6 +150,24 @@ h1.align-center {
transform: none; transform: none;
} }
.uninstall-button {
align-self: center;
width: 34px;
height: 34px;
margin: 6px 0 0;
border-radius: 50%;
border: 1px solid #6c2942;
background: #2a0714;
color: #ffb4c8;
font-weight: bold;
cursor: pointer;
}
.uninstall-button:hover {
border-color: #ff6d9d;
background: #4d1025;
}
@keyframes flicker { @keyframes flicker {
0% { opacity: 1; } 0% { opacity: 1; }
50% { opacity: 0.8; } 50% { opacity: 0.8; }
+123 -5
View File
@@ -23,7 +23,8 @@ enum InstallStatus {
NotInstalled = 'NotInstalled', NotInstalled = 'NotInstalled',
CheckingPeers = 'CheckingPeers', CheckingPeers = 'CheckingPeers',
Downloading = 'Downloading', Downloading = 'Downloading',
Unpacking = 'Unpacking', Installing = 'Installing',
Uninstalling = 'Uninstalling',
Installed = 'Installed', Installed = 'Installed',
} }
@@ -83,7 +84,8 @@ const GameThumbnail = ({ gameId, alt, getThumbnailUrl }: GameThumbnailProps) =>
const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([ const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
InstallStatus.CheckingPeers, InstallStatus.CheckingPeers,
InstallStatus.Downloading, InstallStatus.Downloading,
InstallStatus.Unpacking, InstallStatus.Installing,
InstallStatus.Uninstalling,
]); ]);
const isInProgressInstallStatus = (status: InstallStatus): boolean => { const isInProgressInstallStatus = (status: InstallStatus): boolean => {
@@ -378,13 +380,82 @@ const App = () => {
setGameItems(prev => prev.map(item => item.id === game_id setGameItems(prev => prev.map(item => item.id === game_id
? { ? {
...item, ...item,
install_status: InstallStatus.Unpacking, install_status: InstallStatus.Installing,
status_message: undefined, status_message: undefined,
status_level: undefined, status_level: undefined,
} }
: item)); : item));
}); });
const unlisten_game_install_begin = await listen('game-install-begin', (event) => {
const game_id = event.payload as string;
console.log(`🗲 game-install-begin ${game_id} event received`);
clearCheckingPeersTimeout(game_id);
setGameItems(prev => prev.map(item => item.id === game_id
? {
...item,
install_status: InstallStatus.Installing,
status_message: undefined,
status_level: undefined,
}
: item));
});
const unlisten_game_install_failed = await listen('game-install-failed', (event) => {
const game_id = event.payload as string;
console.log(`❌ game-install-failed ${game_id} event received`);
clearCheckingPeersTimeout(game_id);
setGameItems(prev => prev.map(item => item.id === game_id
? {
...item,
install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled,
status_message: 'Install failed. Please try again.',
status_level: 'error',
}
: item));
});
const unlisten_game_uninstall_begin = await listen('game-uninstall-begin', (event) => {
const game_id = event.payload as string;
console.log(`🗲 game-uninstall-begin ${game_id} event received`);
setGameItems(prev => prev.map(item => item.id === game_id
? {
...item,
install_status: InstallStatus.Uninstalling,
status_message: undefined,
status_level: undefined,
}
: item));
});
const unlisten_game_uninstall_finished = await listen('game-uninstall-finished', (event) => {
const game_id = event.payload as string;
console.log(`🗲 game-uninstall-finished ${game_id} event received`);
setGameItems(prev => prev.map(item => item.id === game_id
? {
...item,
installed: false,
install_status: InstallStatus.NotInstalled,
status_message: undefined,
status_level: undefined,
}
: item));
});
const unlisten_game_uninstall_failed = await listen('game-uninstall-failed', (event) => {
const game_id = event.payload as string;
console.log(`❌ game-uninstall-failed ${game_id} event received`);
setGameItems(prev => prev.map(item => item.id === game_id
? {
...item,
install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled,
status_message: 'Uninstall failed. Please try again.',
status_level: 'error',
}
: item));
});
// Initial request for games // Initial request for games
console.log('📤 Requesting initial games list'); console.log('📤 Requesting initial games list');
await invoke('request_games'); await invoke('request_games');
@@ -395,6 +466,11 @@ const App = () => {
unlisten_games(); unlisten_games();
unlisten_game_download_begin(); unlisten_game_download_begin();
unlisten_game_download_finished(); unlisten_game_download_finished();
unlisten_game_install_begin();
unlisten_game_install_failed();
unlisten_game_uninstall_begin();
unlisten_game_uninstall_finished();
unlisten_game_uninstall_failed();
}; };
} catch (error) { } catch (error) {
console.error('❌ Error in setup:', error); console.error('❌ Error in setup:', error);
@@ -479,6 +555,25 @@ const App = () => {
} }
}; };
const uninstallGame = async (id: string) => {
console.log(`🎯 Uninstalling game with id=${id}`);
try {
const success = await invoke('uninstall_game', { id });
if (success) {
setGameItems(prev => prev.map(item => item.id === id
? {
...item,
install_status: InstallStatus.Uninstalling,
status_message: undefined,
status_level: undefined,
}
: item));
}
} catch (error) {
console.error('❌ Error uninstalling game:', error);
}
};
const needsUpdate = (game: Game): boolean => { const needsUpdate = (game: Game): boolean => {
if (!game.installed) return false; if (!game.installed) return false;
@@ -507,8 +602,10 @@ const App = () => {
return 'Checking peers...'; return 'Checking peers...';
case InstallStatus.Downloading: case InstallStatus.Downloading:
return 'Downloading...'; return 'Downloading...';
case InstallStatus.Unpacking: case InstallStatus.Installing:
return 'Unpacking...'; return 'Installing...';
case InstallStatus.Uninstalling:
return 'Uninstalling...';
default: default:
return undefined; return undefined;
} }
@@ -641,6 +738,14 @@ const App = () => {
<span className="desc-text">{item.description.slice(0, 10)}</span> <span className="desc-text">{item.description.slice(0, 10)}</span>
<span className="size-text">{(item.size / 1024 / 1024 / 1024).toFixed(1)} GB</span> <span className="size-text">{(item.size / 1024 / 1024 / 1024).toFixed(1)} GB</span>
</div> </div>
<div className="badges">
{item.installed && !item.downloaded && (
<span className="badge local-only">LocalOnly</span>
)}
{!item.installed && item.downloaded && item.local_version && (
<span className="badge">v{item.local_version}</span>
)}
</div>
<div <div
className={`play-button${isUnavailable(item) ? ' unavailable' : ''}`} className={`play-button${isUnavailable(item) ? ' unavailable' : ''}`}
onClick={() => { onClick={() => {
@@ -658,6 +763,19 @@ const App = () => {
}}> }}>
{getActionLabel(item)} {getActionLabel(item)}
</div> </div>
{item.installed && !isInProgressInstallStatus(item.install_status) && (
<button
className="uninstall-button"
aria-label={`Uninstall ${item.name}`}
title="Uninstall"
onClick={(event) => {
event.stopPropagation();
uninstallGame(item.id);
}}
>
X
</button>
)}
<div className={`item-info${item.status_level ? ` ${item.status_level}` : ''}`}> <div className={`item-info${item.status_level ? ` ${item.status_level}` : ''}`}>
<div className="status-left"> <div className="status-left">
{item.status_message && item.peer_count === 0 && !item.installed && !item.downloaded ? item.status_message : ''} {item.status_message && item.peer_count === 0 && !item.installed && !item.downloaded ? item.status_message : ''}