Files
lanspread/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs
T
ddidderr b35755f4e6 feat(tauri): add unpack logs viewer for unrar attempts
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>
2026-05-19 17:27:59 +02:00

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");
}
});
}