b35755f4e6
Captures stdout, stderr, exit status and start/finish timestamps for every unrar sidecar invocation and exposes them through a dedicated "Unpack Logs" window. Triggered by the need to debug why a particular game's archive failed to extract -- previously the only artifact of a failed unpack was a log line in the Tauri process stdout, which is awkward to inspect on an end-user machine. Implementation: * `LanSpreadState` gains an in-memory ring buffer (`unpack_logs`) capped at `MAX_UNPACK_LOGS` (100). The previous monolithic `do_unrar` is split into `prepare_unrar_paths` and `run_unrar_sidecar` so every failure path (mkdir failure, canonicalize failure, non-UTF-8 destination, sidecar spawn error, non-zero exit) records an `UnpackLogEntry` before bailing. * A `get_unpack_logs` Tauri command returns the current snapshot; an `unpack-logs-updated` event is emitted after every write so the viewer can refresh without polling. * The React `App` component now routes on `?view=unpack-logs` and renders a dedicated `UnpackLogsWindow`. The main window opens the viewer via `WebviewWindow` with label `unpack-logs`; an existing window is focused instead of being recreated. Capability scoping: the new window is given its own capability file (`capabilities/unpack-logs.json`) granting only `core:default`. The main capability is unchanged in window scope and only gains the two permissions the main window itself needs (`core:window:allow-set-focus` to focus an existing log window, `core:webview:allow-create-webview-window` to spawn it). Splitting the capability keeps the log window from inheriting `shell:allow-open`, `dialog:default` and `store:default`, which it has no reason to use. Known limitations (intentionally out of scope here): * Logs are process-local; they vanish on app restart. Persistence can be added later if it turns out users want to inspect failures across runs. * Entries are presented as a flat chronological list identified by archive path. No per-game grouping or filtering yet -- the archive filename is usually enough to identify the game in practice. * The `unpack-logs-updated` event carries no payload; the viewer re-fetches the full snapshot on every notification. Acceptable given the 100-entry cap, but a payload-bearing event would be cheaper if the cap grows. Test plan: * `just clippy` and `just build` are clean. * Manual: start the GUI, point it at a games directory containing at least one peer-hosted game, trigger an install, then click "Unpack Logs". The window should show one entry per unrar invocation with stdout, stderr, status code and timestamps; stderr/error lines render in the warning color. Triggering further unpacks should update the open window live via the `unpack-logs-updated` event without manual refresh. * Negative path: rename or remove the archive between handshake and extraction to force a canonicalize failure; confirm a failed entry with the corresponding stderr appears in the viewer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1354 lines
42 KiB
Rust
1354 lines
42 KiB
Rust
#[cfg(target_os = "windows")]
|
|
use std::fs::File;
|
|
use std::{
|
|
collections::{HashMap, HashSet},
|
|
net::SocketAddr,
|
|
path::{Path, PathBuf},
|
|
sync::Arc,
|
|
time::{SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use eyre::bail;
|
|
use lanspread_compat::eti::get_games;
|
|
use lanspread_db::db::{Availability, Game, GameDB, GameFileDescription};
|
|
use lanspread_peer::{
|
|
ActiveOperation,
|
|
ActiveOperationKind,
|
|
PeerCommand,
|
|
PeerEvent,
|
|
PeerGameDB,
|
|
PeerRuntimeHandle,
|
|
UnpackFuture,
|
|
Unpacker,
|
|
start_peer,
|
|
};
|
|
use tauri::{AppHandle, Emitter as _, Manager};
|
|
use tauri_plugin_shell::{ShellExt, process::Command};
|
|
use tokio::sync::{
|
|
RwLock,
|
|
mpsc::{UnboundedReceiver, UnboundedSender},
|
|
};
|
|
|
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
|
|
|
/// Tauri-managed runtime state shared by commands and setup tasks.
|
|
#[derive(Default)]
|
|
struct LanSpreadState {
|
|
peer_ctrl: Arc<RwLock<Option<UnboundedSender<PeerCommand>>>>,
|
|
peer_runtime: Arc<RwLock<Option<PeerRuntimeHandle>>>,
|
|
games: Arc<RwLock<GameDB>>,
|
|
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
|
|
games_folder: Arc<RwLock<String>>,
|
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
|
catalog: Arc<RwLock<HashSet<String>>>,
|
|
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
|
|
}
|
|
|
|
struct PeerEventTx(UnboundedSender<PeerEvent>);
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
|
enum UiOperationKind {
|
|
Downloading,
|
|
Installing,
|
|
Updating,
|
|
Uninstalling,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
|
|
struct UiActiveOperation {
|
|
id: String,
|
|
operation: UiOperationKind,
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Serialize)]
|
|
struct GamesListPayload {
|
|
games: Vec<Game>,
|
|
active_operations: Vec<UiActiveOperation>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Serialize)]
|
|
struct UnpackLogEntry {
|
|
archive: String,
|
|
destination: String,
|
|
status_code: Option<i32>,
|
|
stdout: String,
|
|
stderr: String,
|
|
started_at_ms: u64,
|
|
finished_at_ms: u64,
|
|
success: bool,
|
|
}
|
|
|
|
struct SidecarUnpacker {
|
|
app_handle: AppHandle,
|
|
}
|
|
|
|
const MAX_UNPACK_LOGS: usize = 100;
|
|
|
|
impl Unpacker for SidecarUnpacker {
|
|
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
|
Box::pin(async move {
|
|
let app_handle = self.app_handle.clone();
|
|
let sidecar = app_handle.shell().sidecar("unrar")?;
|
|
do_unrar(&app_handle, sidecar, archive, dest).await
|
|
})
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn get_unpack_logs(
|
|
state: tauri::State<'_, LanSpreadState>,
|
|
) -> tauri::Result<Vec<UnpackLogEntry>> {
|
|
Ok(state.inner().unpack_logs.read().await.clone())
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
const FIRST_START_DONE_FILE: &str = ".softlan_first_start_done";
|
|
|
|
#[tauri::command]
|
|
async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<()> {
|
|
log::debug!("request_games");
|
|
|
|
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::ListGames) {
|
|
log::error!("Failed to send message to peer: {e:?}");
|
|
}
|
|
} else {
|
|
log::warn!("Peer system not initialized yet");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn 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);
|
|
}
|
|
|
|
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
|
|
let peer_ctrl = peer_ctrl_arc.read().await.clone();
|
|
let Some((downloaded, installed)) = state
|
|
.inner()
|
|
.games
|
|
.read()
|
|
.await
|
|
.get_game_by_id(&id)
|
|
.map(|game| (game.downloaded, game.installed))
|
|
else {
|
|
log::warn!("Ignoring install request for unknown game: {id}");
|
|
return Ok(false);
|
|
};
|
|
|
|
let handled = if let Some(peer_ctrl) = peer_ctrl {
|
|
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
|
|
} else {
|
|
log::warn!("Peer system not initialized yet");
|
|
false
|
|
};
|
|
|
|
Ok(handled)
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn update_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);
|
|
}
|
|
|
|
log::info!("Starting update for game: {id}");
|
|
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::FetchLatestFromPeers { 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)
|
|
}
|
|
}
|
|
|
|
#[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)
|
|
} else {
|
|
log::warn!("Peer system not initialized yet");
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<usize> {
|
|
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 {
|
|
// Send a request to get peer count
|
|
if let Err(e) = peer_ctrl.send(PeerCommand::GetPeerCount) {
|
|
log::error!("Failed to send GetPeerCount message to peer: {e:?}");
|
|
}
|
|
}
|
|
|
|
// For now, we'll return 0 and rely on the event-based updates
|
|
// The UI will get the actual count through peer-count-updated events
|
|
Ok(0)
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn get_game_thumbnail(
|
|
game_id: String,
|
|
app_handle: tauri::AppHandle,
|
|
) -> tauri::Result<String> {
|
|
use base64::Engine;
|
|
|
|
let resource_path = app_handle.path().resolve(
|
|
format!("assets/{game_id}.jpg"),
|
|
tauri::path::BaseDirectory::Resource,
|
|
)?;
|
|
|
|
dbg!(&resource_path);
|
|
|
|
let image_data = std::fs::read(&resource_path)?;
|
|
let base64_data = base64::engine::general_purpose::STANDARD.encode(&image_data);
|
|
Ok(format!("data:image/jpeg;base64,{base64_data}"))
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn run_as_admin(file: &str, params: &str, dir: &str) -> bool {
|
|
use std::{ffi::OsStr, os::windows::ffi::OsStrExt};
|
|
|
|
use windows::{Win32::UI::Shell::ShellExecuteW, core::PCWSTR};
|
|
|
|
let file_wide: Vec<u16> = OsStr::new(file).encode_wide().chain(Some(0)).collect();
|
|
let params_wide: Vec<u16> = OsStr::new(params).encode_wide().chain(Some(0)).collect();
|
|
let dir_wide: Vec<u16> = OsStr::new(dir).encode_wide().chain(Some(0)).collect();
|
|
let runas_wide: Vec<u16> = OsStr::new("runas").encode_wide().chain(Some(0)).collect();
|
|
|
|
let result = unsafe {
|
|
ShellExecuteW(
|
|
None,
|
|
PCWSTR::from_raw(runas_wide.as_ptr()),
|
|
PCWSTR::from_raw(file_wide.as_ptr()),
|
|
PCWSTR::from_raw(params_wide.as_ptr()),
|
|
PCWSTR::from_raw(dir_wide.as_ptr()),
|
|
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
|
)
|
|
};
|
|
|
|
(result.0 as usize) > 32 // Success if greater than 32
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
async fn run_game_windows(
|
|
id: String,
|
|
state: tauri::State<'_, LanSpreadState>,
|
|
) -> tauri::Result<()> {
|
|
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);
|
|
if !games_folder.exists() {
|
|
log::error!("games_folder {} does not exist", games_folder.display());
|
|
return Ok(());
|
|
}
|
|
|
|
let game_path = games_folder.join(id.clone());
|
|
|
|
let game_setup_bin = game_path.join("game_setup.cmd");
|
|
let game_start_bin = game_path.join("game_start.cmd");
|
|
|
|
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_present(&game_path) {
|
|
log::warn!(
|
|
"local install is missing for {}; skipping game_setup",
|
|
game_path.display()
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let result = run_as_admin(
|
|
"cmd.exe",
|
|
&format!(
|
|
r#"/c "{} local {} de playername""#,
|
|
game_setup_bin.display(),
|
|
&id
|
|
),
|
|
&game_path.display().to_string(),
|
|
);
|
|
|
|
if !result {
|
|
log::error!("failed to run game_setup.cmd");
|
|
return Ok(());
|
|
}
|
|
|
|
if let Err(e) = File::create(&first_start_done_file) {
|
|
log::error!(
|
|
"failed to create first-start marker {}: {e}",
|
|
first_start_done_file.display()
|
|
);
|
|
}
|
|
}
|
|
|
|
if game_start_bin.exists() {
|
|
let result = run_as_admin(
|
|
"cmd.exe",
|
|
&format!(
|
|
r#"/c "{} local {} de playername""#,
|
|
game_start_bin.display(),
|
|
&id
|
|
),
|
|
&game_path.display().to_string(),
|
|
);
|
|
|
|
if !result {
|
|
log::error!("failed to run game_start.cmd");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn run_game(id: String, state: tauri::State<'_, LanSpreadState>) -> tauri::Result<()> {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
run_game_windows(id, state).await?;
|
|
}
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = state;
|
|
log::error!("run_game not implemented for this platform: id={id}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn local_install_is_present(game_path: &Path) -> bool {
|
|
game_path.join("local").is_dir()
|
|
}
|
|
|
|
fn clear_local_game_state(game: &mut Game) {
|
|
game.set_downloaded(false);
|
|
game.installed = false;
|
|
game.local_version = None;
|
|
}
|
|
|
|
fn has_local_game_state(game: &Game) -> bool {
|
|
game.downloaded
|
|
|| game.installed
|
|
|| game.local_version.is_some()
|
|
|| game.availability != Availability::LocalOnly
|
|
}
|
|
|
|
fn apply_peer_local_state(existing: &mut Game, local_game: &Game) {
|
|
existing.set_downloaded(local_game.downloaded);
|
|
existing.installed = local_game.installed;
|
|
existing.local_version.clone_from(&local_game.local_version);
|
|
existing.availability = local_game.normalized_availability();
|
|
}
|
|
|
|
fn apply_peer_local_games(game_db: &mut GameDB, local_games: &[Game]) {
|
|
let local_game_ids = local_games
|
|
.iter()
|
|
.map(|game| game.id.clone())
|
|
.collect::<HashSet<_>>();
|
|
|
|
for local_game in local_games {
|
|
if let Some(existing_game) = game_db.get_mut_game_by_id(&local_game.id) {
|
|
apply_peer_local_state(existing_game, local_game);
|
|
log::debug!("Updated local game status for: {}", local_game.id);
|
|
}
|
|
}
|
|
|
|
for game in game_db.games.values_mut() {
|
|
if !local_game_ids.contains(&game.id) && has_local_game_state(game) {
|
|
log::info!(
|
|
"Game {} missing from peer local snapshot; marking as unavailable locally",
|
|
game.id
|
|
);
|
|
clear_local_game_state(game);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn clear_all_local_game_states(game_db: &mut GameDB) {
|
|
for game in game_db.games.values_mut() {
|
|
clear_local_game_state(game);
|
|
}
|
|
}
|
|
|
|
async fn emit_games_list(app_handle: &AppHandle) {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
|
|
let games_db_lock = state.games.clone();
|
|
let game_db = games_db_lock.read().await;
|
|
|
|
if game_db.games.is_empty() {
|
|
log::debug!("Game database empty; skipping emit");
|
|
return;
|
|
}
|
|
|
|
let games_to_emit = game_db
|
|
.all_games()
|
|
.into_iter()
|
|
.cloned()
|
|
.collect::<Vec<Game>>();
|
|
|
|
drop(game_db);
|
|
|
|
let active_operations = {
|
|
let active_operations = state.active_operations.read().await;
|
|
ui_active_operations_from_map(&active_operations)
|
|
};
|
|
|
|
let payload = GamesListPayload {
|
|
games: games_to_emit,
|
|
active_operations,
|
|
};
|
|
|
|
if let Err(e) = app_handle.emit("games-list-updated", Some(payload)) {
|
|
log::error!("Failed to emit games-list-updated event: {e}");
|
|
} else {
|
|
log::info!("Emitted games-list-updated event");
|
|
}
|
|
}
|
|
|
|
fn ui_active_operations_from_map(
|
|
active_operations: &HashMap<String, UiOperationKind>,
|
|
) -> Vec<UiActiveOperation> {
|
|
let mut snapshot = active_operations
|
|
.iter()
|
|
.map(|(id, operation)| UiActiveOperation {
|
|
id: id.clone(),
|
|
operation: *operation,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
snapshot.sort_by(|left, right| left.id.cmp(&right.id));
|
|
snapshot
|
|
}
|
|
|
|
fn reconcile_active_operations(
|
|
active_operations: &mut HashMap<String, UiOperationKind>,
|
|
snapshot: &[ActiveOperation],
|
|
) {
|
|
active_operations.clear();
|
|
active_operations.extend(snapshot.iter().map(|operation| {
|
|
(
|
|
operation.id.clone(),
|
|
ui_operation_from_peer(operation.operation),
|
|
)
|
|
}));
|
|
}
|
|
|
|
fn ui_operation_from_peer(operation: ActiveOperationKind) -> UiOperationKind {
|
|
match operation {
|
|
ActiveOperationKind::Downloading => UiOperationKind::Downloading,
|
|
ActiveOperationKind::Installing => UiOperationKind::Installing,
|
|
ActiveOperationKind::Updating => UiOperationKind::Updating,
|
|
ActiveOperationKind::Uninstalling => UiOperationKind::Uninstalling,
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> tauri::Result<()> {
|
|
log::info!("update_game_directory: {path}");
|
|
|
|
let games_folder = PathBuf::from(&path);
|
|
if !games_folder.is_dir() {
|
|
log::error!("game dir {} does not exist", games_folder.display());
|
|
return Ok(());
|
|
}
|
|
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
let current_path = state.games_folder.read().await.clone();
|
|
let active_ids = state
|
|
.active_operations
|
|
.read()
|
|
.await
|
|
.keys()
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
if current_path != path && !active_ids.is_empty() {
|
|
log::warn!(
|
|
"Rejecting game directory change to {} while UI operations are active for: {}",
|
|
games_folder.display(),
|
|
active_ids.join(", ")
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let path_changed = current_path != path;
|
|
*state.games_folder.write().await = path;
|
|
|
|
ensure_bundled_game_db_loaded(&app_handle).await;
|
|
if path_changed {
|
|
let mut game_db = state.games.write().await;
|
|
clear_all_local_game_states(&mut game_db);
|
|
}
|
|
|
|
emit_games_list(&app_handle).await;
|
|
ensure_peer_started(&app_handle, &games_folder).await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn update_game_db(games: Vec<Game>, app: AppHandle) {
|
|
for game in &games {
|
|
log::trace!("peer event ListGames iter: {game:?}");
|
|
}
|
|
|
|
let state = app.state::<LanSpreadState>();
|
|
|
|
{
|
|
let mut game_db = state.games.write().await;
|
|
|
|
// Reset peer counts up front. Presence/metadata stay anchored to the baked game.db.
|
|
for game in game_db.games.values_mut() {
|
|
game.peer_count = 0;
|
|
}
|
|
|
|
for peer_game in games {
|
|
if let Some(existing) = game_db.get_mut_game_by_id(&peer_game.id) {
|
|
existing.peer_count = peer_game.peer_count;
|
|
|
|
if let Some(peer_version) = &peer_game.eti_game_version {
|
|
match &existing.eti_game_version {
|
|
Some(current_version) if current_version >= peer_version => {}
|
|
_ => {
|
|
existing.eti_game_version = Some(peer_version.clone());
|
|
log::debug!(
|
|
"Updated eti_game_version for {} to {} based on peer data",
|
|
peer_game.id,
|
|
peer_version
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
log::debug!(
|
|
"Peer advertised unknown game {id}; ignoring because game.db is ground truth",
|
|
id = peer_game.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
emit_games_list(&app).await;
|
|
}
|
|
|
|
async fn update_local_games_in_db(local_games: Vec<Game>, app: AppHandle) {
|
|
let state = app.state::<LanSpreadState>();
|
|
|
|
{
|
|
let mut game_db = state.games.write().await;
|
|
apply_peer_local_games(&mut game_db, &local_games);
|
|
}
|
|
|
|
emit_games_list(&app).await;
|
|
}
|
|
|
|
fn add_final_slash(path: &str) -> String {
|
|
#[cfg(target_os = "windows")]
|
|
const SLASH_CHAR: char = '\\';
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
const SLASH_CHAR: char = '/';
|
|
|
|
if path.ends_with(SLASH_CHAR) {
|
|
path.to_string()
|
|
} else {
|
|
format!("{path}{SLASH_CHAR}")
|
|
}
|
|
}
|
|
|
|
async fn do_unrar(
|
|
app_handle: &AppHandle,
|
|
sidecar: Command,
|
|
rar_file: &Path,
|
|
dest_dir: &Path,
|
|
) -> eyre::Result<()> {
|
|
let started_at_ms = now_millis();
|
|
let paths = prepare_unrar_paths(app_handle, rar_file, dest_dir, started_at_ms).await?;
|
|
|
|
log::info!(
|
|
"unrar game: {} to {}",
|
|
paths.archive.display(),
|
|
paths.destination.display()
|
|
);
|
|
|
|
run_unrar_sidecar(app_handle, sidecar, &paths, started_at_ms).await
|
|
}
|
|
|
|
struct UnrarPaths {
|
|
archive: PathBuf,
|
|
destination: PathBuf,
|
|
destination_arg: String,
|
|
}
|
|
|
|
async fn prepare_unrar_paths(
|
|
app_handle: &AppHandle,
|
|
rar_file: &Path,
|
|
dest_dir: &Path,
|
|
started_at_ms: u64,
|
|
) -> eyre::Result<UnrarPaths> {
|
|
let original_archive = rar_file.display().to_string();
|
|
let original_destination = dest_dir.display().to_string();
|
|
|
|
if let Err(err) = std::fs::create_dir_all(dest_dir) {
|
|
let stderr = format!("failed to create directory {}: {err}", dest_dir.display());
|
|
record_unpack_failure(
|
|
app_handle,
|
|
original_archive,
|
|
original_destination,
|
|
started_at_ms,
|
|
stderr.clone(),
|
|
)
|
|
.await;
|
|
bail!("{stderr}");
|
|
}
|
|
|
|
let rar_file = match rar_file.canonicalize() {
|
|
Ok(path) => path,
|
|
Err(err) => {
|
|
let stderr = format!(
|
|
"rar_file canonicalize failed for {}: {err}",
|
|
rar_file.display()
|
|
);
|
|
record_unpack_failure(
|
|
app_handle,
|
|
original_archive,
|
|
original_destination,
|
|
started_at_ms,
|
|
stderr.clone(),
|
|
)
|
|
.await;
|
|
bail!("{stderr}");
|
|
}
|
|
};
|
|
let dest_dir = match dest_dir.canonicalize() {
|
|
Ok(path) => path,
|
|
Err(err) => {
|
|
let stderr = format!(
|
|
"dest_dir canonicalize failed for {}: {err}",
|
|
dest_dir.display()
|
|
);
|
|
record_unpack_failure(
|
|
app_handle,
|
|
rar_file.display().to_string(),
|
|
original_destination,
|
|
started_at_ms,
|
|
stderr.clone(),
|
|
)
|
|
.await;
|
|
bail!("{stderr}");
|
|
}
|
|
};
|
|
let Some(dest_dir_arg) = dest_dir.to_str().map(add_final_slash) else {
|
|
let stderr = format!("failed to get str of dest_dir {}", dest_dir.display());
|
|
record_unpack_failure(
|
|
app_handle,
|
|
rar_file.display().to_string(),
|
|
dest_dir.display().to_string(),
|
|
started_at_ms,
|
|
stderr.clone(),
|
|
)
|
|
.await;
|
|
bail!("{stderr}");
|
|
};
|
|
|
|
Ok(UnrarPaths {
|
|
archive: rar_file,
|
|
destination: dest_dir,
|
|
destination_arg: dest_dir_arg,
|
|
})
|
|
}
|
|
|
|
async fn run_unrar_sidecar(
|
|
app_handle: &AppHandle,
|
|
sidecar: Command,
|
|
paths: &UnrarPaths,
|
|
started_at_ms: u64,
|
|
) -> eyre::Result<()> {
|
|
let out = match sidecar
|
|
.arg("x") // extract files
|
|
.arg(&paths.archive)
|
|
.arg("-y") // Assume Yes on all queries
|
|
.arg("-o") // Set overwrite mode
|
|
.arg(&paths.destination_arg)
|
|
.output()
|
|
.await
|
|
{
|
|
Ok(out) => out,
|
|
Err(err) => {
|
|
let stderr = format!("failed to run unrar sidecar: {err}");
|
|
record_unpack_failure(
|
|
app_handle,
|
|
paths.archive.display().to_string(),
|
|
paths.destination.display().to_string(),
|
|
started_at_ms,
|
|
stderr.clone(),
|
|
)
|
|
.await;
|
|
bail!("{stderr}");
|
|
}
|
|
};
|
|
|
|
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
|
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
|
let status_code = out.status.code();
|
|
let success = out.status.success();
|
|
|
|
record_unpack_log(
|
|
app_handle,
|
|
UnpackLogEntry {
|
|
archive: paths.archive.display().to_string(),
|
|
destination: paths.destination.display().to_string(),
|
|
status_code,
|
|
stdout: stdout.clone(),
|
|
stderr: stderr.clone(),
|
|
started_at_ms,
|
|
finished_at_ms: now_millis(),
|
|
success,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
if !success {
|
|
if !stdout.trim().is_empty() {
|
|
log::error!("unrar stdout: {stdout}");
|
|
}
|
|
if !stderr.trim().is_empty() {
|
|
log::error!("unrar stderr: {stderr}");
|
|
}
|
|
bail!("unrar failed with status {status_code:?}: {stderr}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn record_unpack_failure(
|
|
app_handle: &AppHandle,
|
|
archive: String,
|
|
destination: String,
|
|
started_at_ms: u64,
|
|
stderr: String,
|
|
) {
|
|
record_unpack_log(
|
|
app_handle,
|
|
UnpackLogEntry {
|
|
archive,
|
|
destination,
|
|
status_code: None,
|
|
stdout: String::new(),
|
|
stderr,
|
|
started_at_ms,
|
|
finished_at_ms: now_millis(),
|
|
success: false,
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn record_unpack_log(app_handle: &AppHandle, entry: UnpackLogEntry) {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
{
|
|
let mut logs = state.inner().unpack_logs.write().await;
|
|
logs.push(entry);
|
|
if logs.len() > MAX_UNPACK_LOGS {
|
|
let overflow = logs.len() - MAX_UNPACK_LOGS;
|
|
logs.drain(..overflow);
|
|
}
|
|
}
|
|
|
|
if let Err(err) = app_handle.emit("unpack-logs-updated", ()) {
|
|
log::warn!("Failed to emit unpack-logs-updated event: {err}");
|
|
}
|
|
}
|
|
|
|
fn now_millis() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map_or(0, |duration| {
|
|
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
|
})
|
|
}
|
|
|
|
/// Resolve the bundled catalog database packaged with the Tauri application.
|
|
fn resolve_bundled_game_db_path(app_handle: &AppHandle) -> PathBuf {
|
|
app_handle
|
|
.path()
|
|
.resolve("game.db", tauri::path::BaseDirectory::Resource)
|
|
.unwrap_or_else(|e| {
|
|
log::error!("Failed to resolve game.db resource: {e}");
|
|
panic!("game.db resource is required - cannot continue");
|
|
})
|
|
}
|
|
|
|
/// Load the bundled catalog into the in-memory game database used by the UI.
|
|
async fn load_bundled_game_db(app_handle: &AppHandle) -> GameDB {
|
|
let game_db_path = resolve_bundled_game_db_path(app_handle);
|
|
let eti_games = get_games(&game_db_path).await.unwrap_or_else(|e| {
|
|
log::error!("Failed to load ETI games: {e}");
|
|
panic!("game.db resource is required - cannot continue");
|
|
});
|
|
|
|
log::info!("Loaded {} ETI games from game.db", eti_games.len());
|
|
|
|
let games: Vec<Game> = eti_games.into_iter().map(Into::into).collect();
|
|
GameDB::from(games)
|
|
}
|
|
|
|
async fn ensure_bundled_game_db_loaded(app_handle: &AppHandle) {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
let needs_load = { state.games.read().await.games.is_empty() };
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
let mut peer_ctrl = state.peer_ctrl.write().await;
|
|
|
|
if let Some(peer_ctrl) = peer_ctrl.as_ref() {
|
|
if let Err(e) = peer_ctrl.send(PeerCommand::SetGameDir(games_folder.to_path_buf())) {
|
|
log::error!("Failed to send PeerCommand::SetGameDir: {e}");
|
|
}
|
|
return;
|
|
}
|
|
|
|
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();
|
|
*peer_ctrl = Some(sender.clone());
|
|
*state.peer_runtime.write().await = Some(handle);
|
|
if let Err(e) = sender.send(PeerCommand::ListGames) {
|
|
log::error!("Failed to send initial PeerCommand::ListGames: {e}");
|
|
}
|
|
log::info!("Peer system initialized successfully with games directory");
|
|
}
|
|
Err(e) => {
|
|
log::error!("Failed to initialize peer system: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
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}");
|
|
}
|
|
}
|
|
|
|
fn emit_peer_addr_event(app_handle: &AppHandle, event: &str, addr: SocketAddr) {
|
|
if let Err(e) = app_handle.emit(event, Some(addr.to_string())) {
|
|
log::error!("Failed to emit {event} event: {e}");
|
|
}
|
|
}
|
|
|
|
fn spawn_peer_event_loop(app_handle: AppHandle, mut rx_peer_event: UnboundedReceiver<PeerEvent>) {
|
|
tauri::async_runtime::spawn(async move {
|
|
while let Some(event) = rx_peer_event.recv().await {
|
|
handle_peer_event(&app_handle, event).await;
|
|
}
|
|
});
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|
match event {
|
|
PeerEvent::LocalPeerReady { peer_id, addr } => {
|
|
log::info!("Local peer ready: {peer_id} at {addr}");
|
|
if let Err(e) = app_handle.emit("peer-local-ready", Some((peer_id, addr.to_string()))) {
|
|
log::error!("Failed to emit peer-local-ready event: {e}");
|
|
}
|
|
}
|
|
PeerEvent::ListGames(games) => {
|
|
log::info!("PeerEvent::ListGames received");
|
|
update_game_db(games, app_handle.clone()).await;
|
|
}
|
|
PeerEvent::LocalLibraryChanged { games: local_games } => {
|
|
log::info!("PeerEvent::LocalLibraryChanged received");
|
|
update_local_games_in_db(local_games, app_handle.clone()).await;
|
|
}
|
|
PeerEvent::ActiveOperationsChanged { active_operations } => {
|
|
log::info!("PeerEvent::ActiveOperationsChanged received");
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
{
|
|
let mut ui_active_operations = state.active_operations.write().await;
|
|
reconcile_active_operations(&mut ui_active_operations, &active_operations);
|
|
}
|
|
emit_games_list(app_handle).await;
|
|
}
|
|
PeerEvent::GotGameFiles {
|
|
id,
|
|
file_descriptions,
|
|
} => {
|
|
handle_got_game_files(app_handle, id, file_descriptions).await;
|
|
}
|
|
PeerEvent::NoPeersHaveGame { id } => {
|
|
log::warn!("PeerEvent::NoPeersHaveGame received for {id}");
|
|
emit_game_id_event(
|
|
app_handle,
|
|
"game-no-peers",
|
|
&id,
|
|
"PeerEvent::NoPeersHaveGame",
|
|
);
|
|
}
|
|
PeerEvent::DownloadGameFilesBegin { id } => {
|
|
log::info!("PeerEvent::DownloadGameFilesBegin received");
|
|
emit_game_id_event(
|
|
app_handle,
|
|
"game-download-begin",
|
|
&id,
|
|
"PeerEvent::DownloadGameFilesBegin",
|
|
);
|
|
}
|
|
PeerEvent::DownloadGameFileChunkFinished {
|
|
id,
|
|
peer_addr,
|
|
relative_path,
|
|
offset,
|
|
length,
|
|
} => {
|
|
log::debug!(
|
|
"PeerEvent::DownloadGameFileChunkFinished received for {id}: \
|
|
{relative_path} offset {offset} length {length} from {peer_addr}"
|
|
);
|
|
}
|
|
PeerEvent::DownloadGameFilesFinished { id } => {
|
|
handle_download_finished(app_handle, id);
|
|
}
|
|
PeerEvent::DownloadGameFilesFailed { id } => {
|
|
log::warn!("PeerEvent::DownloadGameFilesFailed received");
|
|
emit_game_id_event(
|
|
app_handle,
|
|
"game-download-failed",
|
|
&id,
|
|
"PeerEvent::DownloadGameFilesFailed",
|
|
);
|
|
}
|
|
PeerEvent::DownloadGameFilesAllPeersGone { id } => {
|
|
log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}");
|
|
emit_game_id_event(
|
|
app_handle,
|
|
"game-download-peers-gone",
|
|
&id,
|
|
"PeerEvent::DownloadGameFilesAllPeersGone",
|
|
);
|
|
}
|
|
PeerEvent::InstallGameBegin { id, operation } => {
|
|
let operation_name: &'static str = (&operation).into();
|
|
log::info!("PeerEvent::InstallGameBegin received for {id}: {operation_name}");
|
|
emit_game_id_event(
|
|
app_handle,
|
|
"game-install-begin",
|
|
&id,
|
|
"PeerEvent::InstallGameBegin",
|
|
);
|
|
}
|
|
PeerEvent::InstallGameFinished { id } => {
|
|
log::info!("PeerEvent::InstallGameFinished received for {id}");
|
|
emit_game_id_event(
|
|
app_handle,
|
|
"game-install-finished",
|
|
&id,
|
|
"PeerEvent::InstallGameFinished",
|
|
);
|
|
}
|
|
PeerEvent::InstallGameFailed { id } => {
|
|
log::warn!("PeerEvent::InstallGameFailed received for {id}");
|
|
emit_game_id_event(
|
|
app_handle,
|
|
"game-install-failed",
|
|
&id,
|
|
"PeerEvent::InstallGameFailed",
|
|
);
|
|
}
|
|
PeerEvent::UninstallGameBegin { id } => {
|
|
log::info!("PeerEvent::UninstallGameBegin received for {id}");
|
|
emit_game_id_event(
|
|
app_handle,
|
|
"game-uninstall-begin",
|
|
&id,
|
|
"PeerEvent::UninstallGameBegin",
|
|
);
|
|
}
|
|
PeerEvent::UninstallGameFinished { id } => {
|
|
log::info!("PeerEvent::UninstallGameFinished received for {id}");
|
|
emit_game_id_event(
|
|
app_handle,
|
|
"game-uninstall-finished",
|
|
&id,
|
|
"PeerEvent::UninstallGameFinished",
|
|
);
|
|
}
|
|
PeerEvent::UninstallGameFailed { id } => {
|
|
log::warn!("PeerEvent::UninstallGameFailed received for {id}");
|
|
emit_game_id_event(
|
|
app_handle,
|
|
"game-uninstall-failed",
|
|
&id,
|
|
"PeerEvent::UninstallGameFailed",
|
|
);
|
|
}
|
|
PeerEvent::PeerConnected(addr) => {
|
|
log::info!("Peer connected: {addr}");
|
|
emit_peer_addr_event(app_handle, "peer-connected", addr);
|
|
}
|
|
PeerEvent::PeerDisconnected(addr) => {
|
|
log::info!("Peer disconnected: {addr}");
|
|
emit_peer_addr_event(app_handle, "peer-disconnected", addr);
|
|
}
|
|
PeerEvent::PeerDiscovered(addr) => {
|
|
log::info!("Peer discovered: {addr}");
|
|
emit_peer_addr_event(app_handle, "peer-discovered", addr);
|
|
}
|
|
PeerEvent::PeerLost(addr) => {
|
|
log::info!("Peer lost: {addr}");
|
|
emit_peer_addr_event(app_handle, "peer-lost", addr);
|
|
}
|
|
PeerEvent::PeerCountUpdated(count) => {
|
|
log::info!("Peer count updated: {count}");
|
|
if let Err(e) = app_handle.emit("peer-count-updated", Some(count)) {
|
|
log::error!("Failed to emit peer-count-updated event: {e}");
|
|
}
|
|
}
|
|
PeerEvent::RuntimeFailed { component, error } => {
|
|
let component_name: &'static str = (&component).into();
|
|
log::error!("Peer runtime component {component_name} failed: {error}");
|
|
if let Err(e) = app_handle.emit(
|
|
"peer-runtime-failed",
|
|
Some((component_name.to_string(), error)),
|
|
) {
|
|
log::error!("Failed to emit peer-runtime-failed event: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_got_game_files(
|
|
app_handle: &AppHandle,
|
|
id: String,
|
|
file_descriptions: Vec<GameFileDescription>,
|
|
) {
|
|
log::info!("PeerEvent::GotGameFiles received");
|
|
emit_game_id_event(
|
|
app_handle,
|
|
"game-download-pre",
|
|
&id,
|
|
"PeerEvent::GotGameFiles",
|
|
);
|
|
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
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,
|
|
})
|
|
{
|
|
log::error!("Failed to send PeerCommand::DownloadGameFiles: {e}");
|
|
}
|
|
}
|
|
|
|
fn handle_download_finished(app_handle: &AppHandle, id: String) {
|
|
log::info!("PeerEvent::DownloadGameFilesFinished received");
|
|
emit_game_id_event(
|
|
app_handle,
|
|
"game-download-finished",
|
|
&id,
|
|
"PeerEvent::DownloadGameFilesFinished",
|
|
);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn game_fixture(id: &str, name: &str) -> Game {
|
|
Game {
|
|
id: id.to_string(),
|
|
name: name.to_string(),
|
|
description: format!("{name} description"),
|
|
release_year: "2000".to_string(),
|
|
publisher: "publisher".to_string(),
|
|
max_players: 4,
|
|
version: "1.0".to_string(),
|
|
genre: "genre".to_string(),
|
|
size: 123,
|
|
downloaded: false,
|
|
installed: false,
|
|
availability: Availability::LocalOnly,
|
|
eti_game_version: None,
|
|
local_version: None,
|
|
peer_count: 0,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn active_operation_reconciliation_replaces_stale_ui_history() {
|
|
let mut active_operations = HashMap::from([
|
|
("stale".to_string(), UiOperationKind::Installing),
|
|
("keep".to_string(), UiOperationKind::Downloading),
|
|
]);
|
|
let snapshot = vec![
|
|
ActiveOperation {
|
|
id: "keep".to_string(),
|
|
operation: ActiveOperationKind::Updating,
|
|
},
|
|
ActiveOperation {
|
|
id: "new".to_string(),
|
|
operation: ActiveOperationKind::Uninstalling,
|
|
},
|
|
];
|
|
|
|
reconcile_active_operations(&mut active_operations, &snapshot);
|
|
|
|
assert_eq!(active_operations.len(), 2);
|
|
assert_eq!(
|
|
active_operations.get("keep"),
|
|
Some(&UiOperationKind::Updating)
|
|
);
|
|
assert_eq!(
|
|
active_operations.get("new"),
|
|
Some(&UiOperationKind::Uninstalling)
|
|
);
|
|
assert!(
|
|
!active_operations.contains_key("stale"),
|
|
"snapshot reconciliation should drop stale UI operations"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn local_snapshot_without_finish_clears_stale_ui_operation() {
|
|
let mut active_operations =
|
|
HashMap::from([("game".to_string(), UiOperationKind::Downloading)]);
|
|
|
|
reconcile_active_operations(&mut active_operations, &[]);
|
|
|
|
assert!(
|
|
active_operations.is_empty(),
|
|
"an authoritative snapshot without the game should clear a missed finish event"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn local_snapshot_without_begin_restores_active_ui_operation() {
|
|
let mut active_operations = HashMap::new();
|
|
let snapshot = vec![ActiveOperation {
|
|
id: "game".to_string(),
|
|
operation: ActiveOperationKind::Installing,
|
|
}];
|
|
|
|
reconcile_active_operations(&mut active_operations, &snapshot);
|
|
|
|
assert_eq!(
|
|
active_operations.get("game"),
|
|
Some(&UiOperationKind::Installing),
|
|
"an authoritative snapshot should recover a missed begin event"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn active_operation_payload_is_sorted_for_stable_ui_updates() {
|
|
let active_operations = HashMap::from([
|
|
("zeta".to_string(), UiOperationKind::Downloading),
|
|
("alpha".to_string(), UiOperationKind::Installing),
|
|
]);
|
|
|
|
let snapshot = ui_active_operations_from_map(&active_operations);
|
|
|
|
assert_eq!(
|
|
snapshot,
|
|
vec![
|
|
UiActiveOperation {
|
|
id: "alpha".to_string(),
|
|
operation: UiOperationKind::Installing,
|
|
},
|
|
UiActiveOperation {
|
|
id: "zeta".to_string(),
|
|
operation: UiOperationKind::Downloading,
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn peer_local_snapshot_replaces_local_state_without_overwriting_catalog_metadata() {
|
|
let mut alpha = game_fixture("alpha", "Catalog Alpha");
|
|
alpha.size = 999;
|
|
alpha.peer_count = 3;
|
|
|
|
let mut beta = game_fixture("beta", "Catalog Beta");
|
|
beta.set_downloaded(true);
|
|
beta.installed = true;
|
|
beta.local_version = Some("20240101".to_string());
|
|
|
|
let mut game_db = GameDB::from(vec![alpha, beta]);
|
|
|
|
let mut local_alpha = game_fixture("alpha", "Peer Alpha");
|
|
local_alpha.size = 42;
|
|
local_alpha.set_downloaded(true);
|
|
local_alpha.local_version = Some("20240202".to_string());
|
|
|
|
let mut unknown = game_fixture("unknown", "Unknown");
|
|
unknown.set_downloaded(true);
|
|
unknown.installed = true;
|
|
|
|
apply_peer_local_games(&mut game_db, &[local_alpha, unknown]);
|
|
|
|
let alpha = game_db.get_game_by_id("alpha").expect("alpha remains");
|
|
assert_eq!(alpha.name, "Catalog Alpha");
|
|
assert_eq!(alpha.size, 999);
|
|
assert_eq!(alpha.peer_count, 3);
|
|
assert!(alpha.downloaded);
|
|
assert!(!alpha.installed);
|
|
assert_eq!(alpha.availability, Availability::Ready);
|
|
assert_eq!(alpha.local_version.as_deref(), Some("20240202"));
|
|
|
|
let beta = game_db.get_game_by_id("beta").expect("beta remains");
|
|
assert!(!beta.downloaded);
|
|
assert!(!beta.installed);
|
|
assert_eq!(beta.availability, Availability::LocalOnly);
|
|
assert_eq!(beta.local_version, None);
|
|
|
|
assert!(game_db.get_game_by_id("unknown").is_none());
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::missing_panics_doc)]
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
let tauri_logger_builder = tauri_plugin_log::Builder::new()
|
|
.clear_targets()
|
|
.target(tauri_plugin_log::Target::new(
|
|
tauri_plugin_log::TargetKind::Stdout,
|
|
))
|
|
.level(log::LevelFilter::Info)
|
|
.level_for("mdns_sd::service_daemon", log::LevelFilter::Off);
|
|
|
|
// channel to receive events from the peer
|
|
let (tx_peer_event, rx_peer_event) = tokio::sync::mpsc::unbounded_channel::<PeerEvent>();
|
|
|
|
tauri::Builder::default()
|
|
.plugin(tauri_plugin_store::Builder::new().build())
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.plugin(tauri_logger_builder.build())
|
|
.plugin(tauri_plugin_shell::init())
|
|
.invoke_handler(tauri::generate_handler![
|
|
request_games,
|
|
install_game,
|
|
run_game,
|
|
update_game_directory,
|
|
update_game,
|
|
uninstall_game,
|
|
get_peer_count,
|
|
get_game_thumbnail,
|
|
get_unpack_logs
|
|
])
|
|
.manage(LanSpreadState::default())
|
|
.manage(PeerEventTx(tx_peer_event))
|
|
.setup(move |app| {
|
|
spawn_peer_event_loop(app.handle().clone(), rx_peer_event);
|
|
Ok(())
|
|
})
|
|
.build(tauri::generate_context!())
|
|
.expect("error while building tauri application")
|
|
.run(|app_handle, event| {
|
|
if matches!(event, tauri::RunEvent::Exit) {
|
|
shutdown_peer_runtime(app_handle);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn shutdown_peer_runtime(app_handle: &AppHandle) {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
let peer_runtime = state.peer_runtime.clone();
|
|
|
|
tauri::async_runtime::block_on(async move {
|
|
let Some(mut handle) = peer_runtime.write().await.take() else {
|
|
return;
|
|
};
|
|
handle.shutdown();
|
|
if tokio::time::timeout(std::time::Duration::from_secs(2), handle.wait_stopped())
|
|
.await
|
|
.is_err()
|
|
{
|
|
log::warn!("Peer runtime did not stop within 2s of shutdown request");
|
|
}
|
|
});
|
|
}
|