feat(tauri): add low-disk streamed install action
NEXT_STEPS item 1 called out that streamed install was still CLI-only because the Tauri app started the peer with no stream provider. Users can now choose an explicit "Low disk install" action from the game detail modal for remote-only games instead of taking the default archive-preserving download path. The GUI command queues a normal peer detail fetch first so the peer database has the file metadata needed for source validation. A small pending handoff in Tauri routes the resulting GotGameFiles event into StreamInstallGame instead of DownloadGameFiles, and clears that pending state on no-peer or download failure events. This keeps the existing download continuation untouched for the default action. The external unrar stream provider moved from the CLI harness into lanspread-peer so CLI and Tauri use the same implementation. Tauri resolves the bundled unrar sidecar path and injects that provider at peer startup; falling back to the noop provider keeps peer startup alive if the sidecar cannot be resolved, while the streamed install operation still fails safely. Test Plan: - just fmt - just test - just frontend-test - just clippy - just build - git diff --check Refs: NEXT_STEPS.md item 1
This commit is contained in:
@@ -14,11 +14,14 @@ use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescript
|
||||
use lanspread_peer::{
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
ExternalUnrarStreamProvider,
|
||||
NoopStreamInstallProvider,
|
||||
PeerCommand,
|
||||
PeerEvent,
|
||||
PeerGameDB,
|
||||
PeerRuntimeHandle,
|
||||
PeerStartOptions,
|
||||
StreamInstallProvider,
|
||||
UnpackFuture,
|
||||
Unpacker,
|
||||
migrate_legacy_state,
|
||||
@@ -82,6 +85,7 @@ struct LanSpreadState {
|
||||
peer_runtime: Arc<RwLock<Option<PeerRuntimeHandle>>>,
|
||||
games: Arc<RwLock<GameDB>>,
|
||||
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
|
||||
pending_stream_installs: Arc<RwLock<HashSet<String>>>,
|
||||
games_folder: Arc<RwLock<String>>,
|
||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||
catalog: Arc<RwLock<GameCatalog>>,
|
||||
@@ -255,6 +259,16 @@ async fn install_game(
|
||||
log::warn!("Game already has an active operation: {id}");
|
||||
return Ok(false);
|
||||
}
|
||||
if state
|
||||
.inner()
|
||||
.pending_stream_installs
|
||||
.read()
|
||||
.await
|
||||
.contains(&id)
|
||||
{
|
||||
log::warn!("Game already has a pending streamed install: {id}");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
|
||||
let peer_ctrl = peer_ctrl_arc.read().await.clone();
|
||||
@@ -294,6 +308,77 @@ async fn install_game(
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stream_install_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);
|
||||
}
|
||||
if state
|
||||
.inner()
|
||||
.pending_stream_installs
|
||||
.read()
|
||||
.await
|
||||
.contains(&id)
|
||||
{
|
||||
log::warn!("Game already has a pending streamed install: {id}");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let Some((downloaded, installed, peer_count)) = state
|
||||
.inner()
|
||||
.games
|
||||
.read()
|
||||
.await
|
||||
.get_game_by_id(&id)
|
||||
.map(|game| (game.downloaded, game.installed, game.peer_count))
|
||||
else {
|
||||
log::warn!("Ignoring streamed install request for unknown game: {id}");
|
||||
return Ok(false);
|
||||
};
|
||||
if downloaded || installed || peer_count == 0 {
|
||||
log::warn!(
|
||||
"Ignoring streamed install request for {id}: downloaded={downloaded}, \
|
||||
installed={installed}, peer_count={peer_count}"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
|
||||
let peer_ctrl = peer_ctrl_arc.read().await.clone();
|
||||
let Some(peer_ctrl) = peer_ctrl else {
|
||||
log::warn!("Peer system not initialized yet");
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
{
|
||||
let mut pending = state.inner().pending_stream_installs.write().await;
|
||||
pending.insert(id.clone());
|
||||
}
|
||||
|
||||
if let Err(e) = peer_ctrl.send(PeerCommand::GetGame(id.clone())) {
|
||||
log::error!("Failed to send PeerCommand::GetGame for streamed install: {e:?}");
|
||||
state
|
||||
.inner()
|
||||
.pending_stream_installs
|
||||
.write()
|
||||
.await
|
||||
.remove(&id);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_game(
|
||||
id: String,
|
||||
@@ -1867,6 +1952,7 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
|
||||
let unpacker = Arc::new(SidecarUnpacker {
|
||||
app_handle: app_handle.clone(),
|
||||
});
|
||||
let stream_install_provider = stream_install_provider_for_app(app_handle);
|
||||
match start_peer_with_options(
|
||||
games_folder.to_path_buf(),
|
||||
tx_peer_event,
|
||||
@@ -1876,7 +1962,7 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
|
||||
PeerStartOptions {
|
||||
state_dir: Some(state_dir),
|
||||
active_outbound_transfers: Some(state.active_outbound_transfers.clone()),
|
||||
stream_install_provider: None,
|
||||
stream_install_provider: Some(stream_install_provider),
|
||||
},
|
||||
) {
|
||||
Ok(handle) => {
|
||||
@@ -1894,6 +1980,22 @@ async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_install_provider_for_app(app_handle: &AppHandle) -> Arc<dyn StreamInstallProvider> {
|
||||
match resolve_unrar_sidecar_program(app_handle) {
|
||||
Ok(program) => Arc::new(ExternalUnrarStreamProvider::new(program)),
|
||||
Err(err) => {
|
||||
log::error!("Failed to resolve streamed-install unrar sidecar: {err}");
|
||||
Arc::new(NoopStreamInstallProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_unrar_sidecar_program(app_handle: &AppHandle) -> eyre::Result<PathBuf> {
|
||||
let sidecar = app_handle.shell().sidecar("unrar")?;
|
||||
let command: std::process::Command = sidecar.into();
|
||||
Ok(PathBuf::from(command.get_program()))
|
||||
}
|
||||
|
||||
fn emit_game_id_event(app_handle: &AppHandle, event: &str, id: &str, label: &str) {
|
||||
if let Err(e) = app_handle.emit(event, Some(id.to_owned())) {
|
||||
log::error!("{label}: Failed to emit {event} event: {e}");
|
||||
@@ -1990,6 +2092,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
||||
}
|
||||
PeerEvent::NoPeersHaveGame { id } => {
|
||||
log::warn!("PeerEvent::NoPeersHaveGame received for {id}");
|
||||
clear_pending_stream_install(app_handle, &id).await;
|
||||
emit_game_id_event(
|
||||
app_handle,
|
||||
"game-no-peers",
|
||||
@@ -2028,6 +2131,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
||||
}
|
||||
PeerEvent::DownloadGameFilesFailed { id } => {
|
||||
log::warn!("PeerEvent::DownloadGameFilesFailed received");
|
||||
clear_pending_stream_install(app_handle, &id).await;
|
||||
emit_game_id_event(
|
||||
app_handle,
|
||||
"game-download-failed",
|
||||
@@ -2037,6 +2141,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
||||
}
|
||||
PeerEvent::DownloadGameFilesAllPeersGone { id } => {
|
||||
log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}");
|
||||
clear_pending_stream_install(app_handle, &id).await;
|
||||
emit_game_id_event(
|
||||
app_handle,
|
||||
"game-download-peers-gone",
|
||||
@@ -2175,17 +2280,27 @@ async fn handle_got_game_files(
|
||||
);
|
||||
|
||||
let state = app_handle.state::<LanSpreadState>();
|
||||
let stream_install = state.pending_stream_installs.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,
|
||||
})
|
||||
&& let Err(e) = if stream_install {
|
||||
peer_ctrl.send(PeerCommand::StreamInstallGame { id })
|
||||
} else {
|
||||
peer_ctrl.send(PeerCommand::DownloadGameFiles {
|
||||
id,
|
||||
file_descriptions,
|
||||
})
|
||||
}
|
||||
{
|
||||
log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}");
|
||||
log::error!("Failed to continue queued game transfer: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_pending_stream_install(app_handle: &AppHandle, id: &str) {
|
||||
let state = app_handle.state::<LanSpreadState>();
|
||||
state.pending_stream_installs.write().await.remove(id);
|
||||
}
|
||||
|
||||
fn handle_download_finished(app_handle: &AppHandle, id: String) {
|
||||
log::info!("PeerEvent::DownloadGameFilesFinished received");
|
||||
emit_game_id_event(
|
||||
@@ -2679,6 +2794,7 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
request_games,
|
||||
install_game,
|
||||
stream_install_game,
|
||||
run_game,
|
||||
start_server,
|
||||
game_directory_exists,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { StateChip } from '../StateChip';
|
||||
import { ActionButton } from '../ActionButton';
|
||||
|
||||
import { Game, InstallStatus } from '../../lib/types';
|
||||
import { deriveState, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
|
||||
import { canStreamInstall, deriveState, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
|
||||
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
|
||||
|
||||
interface Props {
|
||||
@@ -13,6 +13,7 @@ interface Props {
|
||||
thumbnailUrl: string | null;
|
||||
onClose: () => void;
|
||||
onPrimary: (game: Game) => void;
|
||||
onStreamInstall: (game: Game) => void;
|
||||
onUninstall: (game: Game) => void;
|
||||
onRemoveDownload: (game: Game) => void;
|
||||
onCancelDownload: (game: Game) => void;
|
||||
@@ -43,6 +44,7 @@ export const GameDetailModal = ({
|
||||
thumbnailUrl,
|
||||
onClose,
|
||||
onPrimary,
|
||||
onStreamInstall,
|
||||
onUninstall,
|
||||
onRemoveDownload,
|
||||
onCancelDownload,
|
||||
@@ -55,6 +57,7 @@ export const GameDetailModal = ({
|
||||
const canRemoveDownload = game.downloaded
|
||||
&& !game.installed
|
||||
&& !isInProgress(game.install_status);
|
||||
const showStreamInstall = canStreamInstall(game);
|
||||
const canViewFiles = game.downloaded
|
||||
|| game.installed
|
||||
|| game.install_status === InstallStatus.Downloading
|
||||
@@ -133,6 +136,17 @@ export const GameDetailModal = ({
|
||||
onClick={() => onPrimary(game)}
|
||||
onCancelDownload={onCancelDownload}
|
||||
/>
|
||||
{showStreamInstall && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
title="Install without keeping archive files"
|
||||
onClick={() => onStreamInstall(game)}
|
||||
>
|
||||
<Icon.install />
|
||||
<span>Low disk install</span>
|
||||
</button>
|
||||
)}
|
||||
{game.installed && game.can_host_server === true && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface GameActions {
|
||||
play: (id: string) => Promise<void>;
|
||||
startServer: (id: string) => Promise<void>;
|
||||
install: (id: string) => Promise<void>;
|
||||
streamInstall: (id: string) => Promise<void>;
|
||||
update: (id: string) => Promise<void>;
|
||||
uninstall: (id: string) => Promise<void>;
|
||||
removeDownload: (id: string) => Promise<void>;
|
||||
@@ -68,6 +69,15 @@ export const useGameActions = (
|
||||
}
|
||||
}, [games, settings.language, settings.username]);
|
||||
|
||||
const streamInstall = useCallback(async (id: string) => {
|
||||
try {
|
||||
const success = await invoke<boolean>('stream_install_game', { id });
|
||||
if (success) games.markChecking(id);
|
||||
} catch (err) {
|
||||
console.error('stream_install_game failed:', err);
|
||||
}
|
||||
}, [games]);
|
||||
|
||||
const update = useCallback(async (id: string) => {
|
||||
try {
|
||||
const game = games.games.find(item => item.id === id);
|
||||
@@ -129,5 +139,15 @@ export const useGameActions = (
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { play, startServer, install, update, uninstall, removeDownload, cancelDownload, viewFiles };
|
||||
return {
|
||||
play,
|
||||
startServer,
|
||||
install,
|
||||
streamInstall,
|
||||
update,
|
||||
uninstall,
|
||||
removeDownload,
|
||||
cancelDownload,
|
||||
viewFiles,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -114,6 +114,12 @@ export const needsUpdate = (game: Game): boolean => {
|
||||
return (compareVersionStamps(game.eti_game_version, game.local_version) ?? 0) > 0;
|
||||
};
|
||||
|
||||
export const canStreamInstall = (game: Game): boolean =>
|
||||
!game.downloaded
|
||||
&& !game.installed
|
||||
&& game.peer_count > 0
|
||||
&& !isInProgress(game.install_status);
|
||||
|
||||
/** What pressing the card's main action button should do, given the state. */
|
||||
export type PrimaryAction = 'play' | 'install' | 'update' | 'download' | 'busy' | 'disabled';
|
||||
|
||||
|
||||
@@ -192,6 +192,7 @@ export const MainWindow = () => {
|
||||
thumbnailUrl={thumbnails.get(openGame.id)}
|
||||
onClose={() => setOpenGameId(null)}
|
||||
onPrimary={handlePrimary}
|
||||
onStreamInstall={(g) => actions.streamInstall(g.id)}
|
||||
onUninstall={handleUninstall}
|
||||
onRemoveDownload={handleRemoveDownload}
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
actionLabel,
|
||||
activeStatusById,
|
||||
applyFilterAndSort,
|
||||
canStreamInstall,
|
||||
countByFilter,
|
||||
deriveState,
|
||||
downloadProgressPercent,
|
||||
@@ -209,3 +210,36 @@ Deno.test('download progress formatting matches the progress-bar layouts', () =>
|
||||
);
|
||||
assertEquals(formatDownloadEta(485), '8 min', 'eta format should stay compact');
|
||||
});
|
||||
|
||||
Deno.test('stream install is available only for idle remote games', () => {
|
||||
assertEquals(
|
||||
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 1 })),
|
||||
true,
|
||||
'remote-only idle games should allow streamed install',
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(game({ downloaded: true, installed: false, peer_count: 1 })),
|
||||
false,
|
||||
'downloaded games should install from local archives',
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(game({ downloaded: false, installed: true, peer_count: 1 })),
|
||||
false,
|
||||
'installed games should not expose streamed install',
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 0 })),
|
||||
false,
|
||||
'games without peers should not expose streamed install',
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(game({
|
||||
downloaded: false,
|
||||
installed: false,
|
||||
peer_count: 1,
|
||||
install_status: InstallStatus.CheckingPeers,
|
||||
})),
|
||||
false,
|
||||
'busy games should not expose streamed install',
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user