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 5954c2c..ac1f791 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -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>>>, peer_runtime: Arc>>, games: Arc>, - games_in_download: Arc>>, + active_operations: Arc>>, games_folder: Arc>, peer_game_db: Arc>, + catalog: Arc>>, } struct PeerEventTx(UnboundedSender); +#[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 { - 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 { - 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::() - .inner() - .games_in_download - .write() - .await - .remove(id); - - let games_folder = app_handle - .state::() - .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 { - 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 { + 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::>(); *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::().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::() - .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::() - .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::() + .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::() + .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::() + .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::() + .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::() + .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::() + .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::() + .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::() + .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::() - .games_in_download + .active_operations .write() .await .remove(&id); - - let games_folder = app_handle - .state::() - .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 ]) diff --git a/crates/lanspread-tauri-deno-ts/src/App.css b/crates/lanspread-tauri-deno-ts/src/App.css index 2eabd48..99646b5 100644 --- a/crates/lanspread-tauri-deno-ts/src/App.css +++ b/crates/lanspread-tauri-deno-ts/src/App.css @@ -74,6 +74,29 @@ h1.align-center { 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 { text-align: left; } @@ -127,6 +150,24 @@ h1.align-center { 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 { 0% { opacity: 1; } 50% { opacity: 0.8; } diff --git a/crates/lanspread-tauri-deno-ts/src/App.tsx b/crates/lanspread-tauri-deno-ts/src/App.tsx index 820f4a4..5e91f02 100644 --- a/crates/lanspread-tauri-deno-ts/src/App.tsx +++ b/crates/lanspread-tauri-deno-ts/src/App.tsx @@ -23,7 +23,8 @@ enum InstallStatus { NotInstalled = 'NotInstalled', CheckingPeers = 'CheckingPeers', Downloading = 'Downloading', - Unpacking = 'Unpacking', + Installing = 'Installing', + Uninstalling = 'Uninstalling', Installed = 'Installed', } @@ -83,7 +84,8 @@ const GameThumbnail = ({ gameId, alt, getThumbnailUrl }: GameThumbnailProps) => const IN_PROGRESS_INSTALL_STATUSES = new Set([ InstallStatus.CheckingPeers, InstallStatus.Downloading, - InstallStatus.Unpacking, + InstallStatus.Installing, + InstallStatus.Uninstalling, ]); const isInProgressInstallStatus = (status: InstallStatus): boolean => { @@ -378,13 +380,82 @@ const App = () => { setGameItems(prev => prev.map(item => item.id === game_id ? { ...item, - install_status: InstallStatus.Unpacking, + install_status: InstallStatus.Installing, status_message: undefined, status_level: undefined, } : 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 console.log('📤 Requesting initial games list'); await invoke('request_games'); @@ -395,6 +466,11 @@ const App = () => { unlisten_games(); unlisten_game_download_begin(); 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) { 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 => { if (!game.installed) return false; @@ -507,8 +602,10 @@ const App = () => { return 'Checking peers...'; case InstallStatus.Downloading: return 'Downloading...'; - case InstallStatus.Unpacking: - return 'Unpacking...'; + case InstallStatus.Installing: + return 'Installing...'; + case InstallStatus.Uninstalling: + return 'Uninstalling...'; default: return undefined; } @@ -641,6 +738,14 @@ const App = () => { {item.description.slice(0, 10)} {(item.size / 1024 / 1024 / 1024).toFixed(1)} GB +
+ {item.installed && !item.downloaded && ( + LocalOnly + )} + {!item.installed && item.downloaded && item.local_version && ( + v{item.local_version} + )} +
{ @@ -658,6 +763,19 @@ const App = () => { }}> {getActionLabel(item)}
+ {item.installed && !isInProgressInstallStatus(item.install_status) && ( + + )}
{item.status_message && item.peer_count === 0 && !item.installed && !item.downloaded ? item.status_message : ''}