e688fd3016
Validate the persisted game directory before sending it to the backend or showing library content for it. When the saved path no longer exists, the launcher keeps the top bar visible but shows the folder picker empty state and labels the Game Folder button as an unset folder. This keeps stale local data from being presented as the active library when an old path is deleted or disconnected. Test Plan: - git diff --check - just frontend-test - just build
1860 lines
57 KiB
Rust
1860 lines
57 KiB
Rust
use std::{
|
|
collections::{HashMap, HashSet},
|
|
net::SocketAddr,
|
|
path::{Component, Path, PathBuf},
|
|
sync::{Arc, OnceLock},
|
|
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,
|
|
PeerStartOptions,
|
|
UnpackFuture,
|
|
Unpacker,
|
|
migrate_legacy_state,
|
|
start_peer_with_options,
|
|
};
|
|
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>>>,
|
|
state_dir: OnceLock<PathBuf>,
|
|
}
|
|
|
|
struct PeerEventTx(UnboundedSender<PeerEvent>);
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
|
enum UiOperationKind {
|
|
Downloading,
|
|
Installing,
|
|
Updating,
|
|
Uninstalling,
|
|
RemovingDownload,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
|
|
struct UiActiveOperation {
|
|
id: String,
|
|
operation: UiOperationKind,
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Serialize)]
|
|
struct GamesListPayload {
|
|
games: Vec<LauncherGame>,
|
|
active_operations: Vec<UiActiveOperation>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Serialize)]
|
|
struct LauncherGame {
|
|
#[serde(flatten)]
|
|
game: Game,
|
|
can_host_server: bool,
|
|
}
|
|
|
|
#[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_attr(not(target_os = "windows"), allow(dead_code))]
|
|
const GAME_SETUP_SCRIPT: &str = "game_setup.cmd";
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
const GAME_START_SCRIPT: &str = "game_start.cmd";
|
|
const SERVER_START_SCRIPT: &str = "server_start.cmd";
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
const DEFAULT_LANGUAGE: &str = "en";
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
const DEFAULT_USERNAME: &str = "Commander";
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
const MAX_USERNAME_CHARS: usize = 24;
|
|
|
|
#[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 remove_downloaded_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 Some((downloaded, installed)) = state
|
|
.inner()
|
|
.games
|
|
.read()
|
|
.await
|
|
.get_game_by_id(&id)
|
|
.map(|game| (game.downloaded, game.installed))
|
|
else {
|
|
log::warn!("Ignoring downloaded-file removal for unknown game: {id}");
|
|
return Ok(false);
|
|
};
|
|
if !downloaded || installed {
|
|
log::warn!(
|
|
"Ignoring downloaded-file removal for {id}: downloaded={downloaded}, installed={installed}"
|
|
);
|
|
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::RemoveDownloadedGame { 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 cancel_download(
|
|
id: String,
|
|
state: tauri::State<'_, LanSpreadState>,
|
|
) -> tauri::Result<bool> {
|
|
let is_active_download = {
|
|
let active_operations = state.inner().active_operations.read().await;
|
|
matches!(
|
|
active_operations.get(&id),
|
|
Some(UiOperationKind::Downloading)
|
|
)
|
|
};
|
|
if !is_active_download {
|
|
log::warn!("Ignoring cancel request for inactive download: {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::CancelDownload { 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 open_game_files(
|
|
id: String,
|
|
app_handle: AppHandle,
|
|
state: tauri::State<'_, LanSpreadState>,
|
|
) -> tauri::Result<bool> {
|
|
let Some(target) = resolve_game_root_for_open(&id, &state).await else {
|
|
return Ok(false);
|
|
};
|
|
|
|
#[allow(deprecated)]
|
|
if let Err(e) = app_handle.shell().open(target.display().to_string(), None) {
|
|
log::error!("Failed to open file viewer for {}: {e}", target.display());
|
|
return Ok(false);
|
|
}
|
|
|
|
Ok(true)
|
|
}
|
|
|
|
async fn resolve_game_root_for_open(
|
|
id: &str,
|
|
state: &tauri::State<'_, LanSpreadState>,
|
|
) -> Option<PathBuf> {
|
|
if !is_single_component_game_id(id) {
|
|
log::warn!("Ignoring file viewer request for invalid game id: {id}");
|
|
return None;
|
|
}
|
|
if state
|
|
.inner()
|
|
.games
|
|
.read()
|
|
.await
|
|
.get_game_by_id(id)
|
|
.is_none()
|
|
{
|
|
log::warn!("Ignoring file viewer request for unknown game: {id}");
|
|
return None;
|
|
}
|
|
|
|
let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone());
|
|
let Ok(root) = games_folder.canonicalize() else {
|
|
log::warn!(
|
|
"Cannot open files because game directory is unavailable: {}",
|
|
games_folder.display()
|
|
);
|
|
return None;
|
|
};
|
|
let target = root.join(id);
|
|
let Ok(target) = target.canonicalize() else {
|
|
log::warn!(
|
|
"Cannot open files because game root is unavailable: {}",
|
|
target.display()
|
|
);
|
|
return None;
|
|
};
|
|
if !target.is_dir() || !target.starts_with(&root) {
|
|
log::warn!(
|
|
"Refusing to open file viewer outside game directory: {}",
|
|
target.display()
|
|
);
|
|
return None;
|
|
}
|
|
|
|
Some(target)
|
|
}
|
|
|
|
fn is_single_component_game_id(id: &str) -> bool {
|
|
let mut components = Path::new(id).components();
|
|
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
struct LaunchSettings {
|
|
language: String,
|
|
username: String,
|
|
}
|
|
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
fn launch_settings(language: &str, username: &str) -> LaunchSettings {
|
|
LaunchSettings {
|
|
language: sanitize_language(language),
|
|
username: sanitize_username(username),
|
|
}
|
|
}
|
|
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
fn sanitize_language(language: &str) -> String {
|
|
match language.trim().to_ascii_lowercase().as_str() {
|
|
"de" => "de".to_string(),
|
|
"en" => "en".to_string(),
|
|
_ => DEFAULT_LANGUAGE.to_string(),
|
|
}
|
|
}
|
|
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
fn sanitize_username(username: &str) -> String {
|
|
let cleaned = username
|
|
.trim()
|
|
.chars()
|
|
.filter(|c| !c.is_control() && *c != '"' && *c != '%')
|
|
.take(MAX_USERNAME_CHARS)
|
|
.collect::<String>();
|
|
|
|
if cleaned.is_empty() {
|
|
DEFAULT_USERNAME.to_string()
|
|
} else {
|
|
cleaned
|
|
}
|
|
}
|
|
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
fn script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String {
|
|
script_params_with_mode("/c", script_path, id, settings)
|
|
}
|
|
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
fn server_script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String {
|
|
script_params_with_mode("/k", script_path, id, settings)
|
|
}
|
|
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
fn script_params_with_mode(
|
|
cmd_mode: &str,
|
|
script_path: &Path,
|
|
id: &str,
|
|
settings: &LaunchSettings,
|
|
) -> String {
|
|
format!(
|
|
r#"/d /s {cmd_mode} ""{}" "local" "{}" "{}" "{}"""#,
|
|
script_path.display(),
|
|
id,
|
|
settings.language,
|
|
settings.username,
|
|
)
|
|
}
|
|
|
|
#[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,
|
|
show_cmd: windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD,
|
|
) -> 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()),
|
|
show_cmd,
|
|
)
|
|
};
|
|
|
|
(result.0 as usize) > 32 // Success if greater than 32
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
async fn run_game_windows(
|
|
id: String,
|
|
language: String,
|
|
username: String,
|
|
state: tauri::State<'_, LanSpreadState>,
|
|
) -> tauri::Result<()> {
|
|
if !is_single_component_game_id(&id) {
|
|
log::warn!("Ignoring run request for invalid game id: {id}");
|
|
return Ok(());
|
|
}
|
|
|
|
let settings = launch_settings(&language, &username);
|
|
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_SCRIPT);
|
|
let game_start_bin = game_path.join(GAME_START_SCRIPT);
|
|
let Some(state_dir) = state.inner().state_dir.get().cloned() else {
|
|
log::error!("app state directory is not initialized; cannot run game");
|
|
return Ok(());
|
|
};
|
|
|
|
let setup_done_file = lanspread_peer::setup_done_path(&state_dir, &id);
|
|
if !setup_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",
|
|
&script_params(&game_setup_bin, &id, &settings),
|
|
&game_path.display().to_string(),
|
|
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
|
);
|
|
|
|
if !result {
|
|
log::error!("failed to run {GAME_SETUP_SCRIPT}");
|
|
return Ok(());
|
|
}
|
|
|
|
if let Some(parent) = setup_done_file.parent()
|
|
&& let Err(e) = std::fs::create_dir_all(parent)
|
|
{
|
|
log::error!(
|
|
"failed to create setup marker directory {}: {e}",
|
|
parent.display()
|
|
);
|
|
}
|
|
|
|
if let Err(e) = std::fs::File::create(&setup_done_file) {
|
|
log::error!(
|
|
"failed to create setup marker {}: {e}",
|
|
setup_done_file.display()
|
|
);
|
|
}
|
|
}
|
|
|
|
if game_start_bin.exists() {
|
|
let result = run_as_admin(
|
|
"cmd.exe",
|
|
&script_params(&game_start_bin, &id, &settings),
|
|
&game_path.display().to_string(),
|
|
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
|
);
|
|
|
|
if !result {
|
|
log::error!("failed to run {GAME_START_SCRIPT}");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn run_game(
|
|
id: String,
|
|
language: String,
|
|
username: String,
|
|
state: tauri::State<'_, LanSpreadState>,
|
|
) -> tauri::Result<()> {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
run_game_windows(id, language, username, state).await?;
|
|
}
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = (state, language, username);
|
|
log::error!("run_game not implemented for this platform: id={id}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
async fn start_server_windows(
|
|
id: String,
|
|
language: String,
|
|
username: String,
|
|
state: tauri::State<'_, LanSpreadState>,
|
|
) -> tauri::Result<bool> {
|
|
if !is_single_component_game_id(&id) {
|
|
log::warn!("Ignoring server start request for invalid game id: {id}");
|
|
return Ok(false);
|
|
}
|
|
|
|
let settings = launch_settings(&language, &username);
|
|
let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone());
|
|
if !games_folder.exists() {
|
|
log::error!("games_folder {} does not exist", games_folder.display());
|
|
return Ok(false);
|
|
}
|
|
|
|
let game_path = games_folder.join(id.clone());
|
|
if !local_install_is_present(&game_path) {
|
|
log::warn!(
|
|
"local install is missing for {}; skipping {SERVER_START_SCRIPT}",
|
|
game_path.display()
|
|
);
|
|
return Ok(false);
|
|
}
|
|
|
|
let server_start_bin = game_path.join(SERVER_START_SCRIPT);
|
|
if !server_start_bin.is_file() {
|
|
log::warn!(
|
|
"server start script is missing for {}: {}",
|
|
id,
|
|
server_start_bin.display()
|
|
);
|
|
return Ok(false);
|
|
}
|
|
|
|
let result = run_as_admin(
|
|
"cmd.exe",
|
|
&server_script_params(&server_start_bin, &id, &settings),
|
|
&game_path.display().to_string(),
|
|
windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL,
|
|
);
|
|
|
|
if !result {
|
|
log::error!("failed to run {SERVER_START_SCRIPT}");
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn start_server(
|
|
id: String,
|
|
language: String,
|
|
username: String,
|
|
state: tauri::State<'_, LanSpreadState>,
|
|
) -> tauri::Result<bool> {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
start_server_windows(id, language, username, state).await
|
|
}
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = (state, language, username);
|
|
log::error!("start_server not implemented for this platform: id={id}");
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
#[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;
|
|
let games_folder = state.games_folder.read().await.clone();
|
|
|
|
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()
|
|
.map(|game| LauncherGame {
|
|
can_host_server: game_can_host_server(&games_folder, &game),
|
|
game,
|
|
})
|
|
.collect::<Vec<LauncherGame>>();
|
|
|
|
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 game_can_host_server(games_folder: &str, game: &Game) -> bool {
|
|
if !game.installed || games_folder.is_empty() || !is_single_component_game_id(&game.id) {
|
|
return false;
|
|
}
|
|
|
|
PathBuf::from(games_folder)
|
|
.join(&game.id)
|
|
.join(SERVER_START_SCRIPT)
|
|
.is_file()
|
|
}
|
|
|
|
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,
|
|
ActiveOperationKind::RemovingDownload => UiOperationKind::RemovingDownload,
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn game_directory_exists(path: String) -> bool {
|
|
PathBuf::from(path).is_dir()
|
|
}
|
|
|
|
#[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;
|
|
let Some(state_dir) = state.state_dir.get().cloned() else {
|
|
log::error!("app state directory is not initialized; cannot update game directory");
|
|
return Ok(());
|
|
};
|
|
|
|
if path_changed || state.peer_ctrl.read().await.is_none() {
|
|
let migration = migrate_legacy_state(&games_folder, &state_dir).await;
|
|
if migration.failures > 0 {
|
|
log::warn!(
|
|
"Legacy state migration completed with {} failure(s)",
|
|
migration.failures
|
|
);
|
|
}
|
|
}
|
|
|
|
*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 Some(state_dir) = state.state_dir.get().cloned() else {
|
|
log::error!("app state directory is not initialized; cannot start peer");
|
|
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_with_options(
|
|
games_folder.to_path_buf(),
|
|
tx_peer_event,
|
|
state.peer_game_db.clone(),
|
|
unpacker,
|
|
state.catalog.clone(),
|
|
PeerStartOptions {
|
|
state_dir: Some(state_dir),
|
|
},
|
|
) {
|
|
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::DownloadGameFilesProgress(progress) => {
|
|
if let Err(e) = app_handle.emit("game-download-progress", Some(progress)) {
|
|
log::error!("Failed to emit game-download-progress event: {e}");
|
|
}
|
|
}
|
|
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::RemoveDownloadedGameBegin { id } => {
|
|
log::info!("PeerEvent::RemoveDownloadedGameBegin received for {id}");
|
|
emit_game_id_event(
|
|
app_handle,
|
|
"game-remove-download-begin",
|
|
&id,
|
|
"PeerEvent::RemoveDownloadedGameBegin",
|
|
);
|
|
}
|
|
PeerEvent::RemoveDownloadedGameFinished { id } => {
|
|
log::info!("PeerEvent::RemoveDownloadedGameFinished received for {id}");
|
|
emit_game_id_event(
|
|
app_handle,
|
|
"game-remove-download-finished",
|
|
&id,
|
|
"PeerEvent::RemoveDownloadedGameFinished",
|
|
);
|
|
}
|
|
PeerEvent::RemoveDownloadedGameFailed { id } => {
|
|
log::warn!("PeerEvent::RemoveDownloadedGameFailed received for {id}");
|
|
emit_game_id_event(
|
|
app_handle,
|
|
"game-remove-download-failed",
|
|
&id,
|
|
"PeerEvent::RemoveDownloadedGameFailed",
|
|
);
|
|
}
|
|
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 game_file_viewer_ids_must_be_single_path_components() {
|
|
assert!(is_single_component_game_id("game"));
|
|
assert!(is_single_component_game_id("game.v1"));
|
|
assert!(!is_single_component_game_id(""));
|
|
assert!(!is_single_component_game_id("../game"));
|
|
assert!(!is_single_component_game_id("nested/game"));
|
|
assert!(!is_single_component_game_id("/game"));
|
|
}
|
|
|
|
#[test]
|
|
fn launch_settings_sanitize_script_arguments() {
|
|
assert_eq!(
|
|
launch_settings("DE", " Alice \"Ace\"%PATH%\n "),
|
|
LaunchSettings {
|
|
language: "de".to_string(),
|
|
username: "Alice AcePATH".to_string(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
launch_settings("fr", ""),
|
|
LaunchSettings {
|
|
language: DEFAULT_LANGUAGE.to_string(),
|
|
username: DEFAULT_USERNAME.to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn script_params_use_common_argument_shape() {
|
|
let start_params = script_params(
|
|
Path::new("C:/Games/My Game")
|
|
.join(GAME_START_SCRIPT)
|
|
.as_path(),
|
|
"my-game",
|
|
&LaunchSettings {
|
|
language: "en".to_string(),
|
|
username: "Alice".to_string(),
|
|
},
|
|
);
|
|
|
|
assert_eq!(
|
|
start_params,
|
|
r#"/d /s /c ""C:/Games/My Game/game_start.cmd" "local" "my-game" "en" "Alice"""#
|
|
);
|
|
|
|
let server_params = server_script_params(
|
|
Path::new("C:/Games/My Game")
|
|
.join(SERVER_START_SCRIPT)
|
|
.as_path(),
|
|
"my-game",
|
|
&LaunchSettings {
|
|
language: "en".to_string(),
|
|
username: "Alice".to_string(),
|
|
},
|
|
);
|
|
|
|
assert_eq!(
|
|
server_params,
|
|
r#"/d /s /k ""C:/Games/My Game/server_start.cmd" "local" "my-game" "en" "Alice"""#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn server_host_capability_requires_installed_game_with_script() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"lanspread-server-test-{}",
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("clock should be after epoch")
|
|
.as_nanos()
|
|
));
|
|
let game_root = root.join("game");
|
|
std::fs::create_dir_all(&game_root).expect("game root should be created");
|
|
std::fs::write(game_root.join(SERVER_START_SCRIPT), b"").expect("script should be written");
|
|
|
|
let mut game = game_fixture("game", "Game");
|
|
assert!(!game_can_host_server(
|
|
root.to_string_lossy().as_ref(),
|
|
&game
|
|
));
|
|
|
|
game.installed = true;
|
|
assert!(game_can_host_server(root.to_string_lossy().as_ref(), &game));
|
|
|
|
let _ = std::fs::remove_dir_all(root);
|
|
}
|
|
|
|
#[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,
|
|
start_server,
|
|
game_directory_exists,
|
|
update_game_directory,
|
|
update_game,
|
|
uninstall_game,
|
|
remove_downloaded_game,
|
|
cancel_download,
|
|
open_game_files,
|
|
get_peer_count,
|
|
get_game_thumbnail,
|
|
get_unpack_logs
|
|
])
|
|
.manage(LanSpreadState::default())
|
|
.manage(PeerEventTx(tx_peer_event))
|
|
.setup(move |app| {
|
|
let state_dir = app.path().app_data_dir()?;
|
|
std::fs::create_dir_all(&state_dir)?;
|
|
let state = app.state::<LanSpreadState>();
|
|
if state.state_dir.set(state_dir).is_err() {
|
|
log::warn!("app state directory was already initialized");
|
|
}
|
|
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");
|
|
}
|
|
});
|
|
}
|