2635 lines
80 KiB
Rust
2635 lines
80 KiB
Rust
use std::{
|
|
collections::{HashMap, HashSet},
|
|
fs::{self, OpenOptions},
|
|
io::{Read as _, Seek as _, SeekFrom, Write as _},
|
|
net::SocketAddr,
|
|
path::{Component, Path, PathBuf},
|
|
sync::{Arc, Mutex, OnceLock},
|
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use eyre::bail;
|
|
use lanspread_compat::eti::get_games;
|
|
use lanspread_db::db::{Availability, Game, GameCatalog, 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},
|
|
};
|
|
use tracing::{Event, Level, Metadata, Subscriber, field::Visit};
|
|
use tracing_subscriber::{
|
|
layer::{Context, Layer},
|
|
prelude::*,
|
|
registry::LookupSpan,
|
|
};
|
|
|
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
|
|
|
type OutboundTransfers =
|
|
Arc<RwLock<std::collections::HashMap<String, Vec<(u64, tokio_util::sync::CancellationToken)>>>>;
|
|
|
|
const OUTBOUND_TRANSFER_EMIT_DEBOUNCE: Duration = Duration::from_millis(100);
|
|
|
|
#[derive(Default)]
|
|
struct OutboundTransferEmitState {
|
|
scheduled: bool,
|
|
generation: u64,
|
|
}
|
|
|
|
impl OutboundTransferEmitState {
|
|
fn record_change(&mut self) -> bool {
|
|
self.generation = self.generation.saturating_add(1);
|
|
if self.scheduled {
|
|
return false;
|
|
}
|
|
|
|
self.scheduled = true;
|
|
true
|
|
}
|
|
|
|
fn observed_generation(&self) -> u64 {
|
|
self.generation
|
|
}
|
|
|
|
fn finish_emit(&mut self, observed_generation: u64) -> bool {
|
|
if self.generation != observed_generation {
|
|
return true;
|
|
}
|
|
|
|
self.scheduled = false;
|
|
false
|
|
}
|
|
}
|
|
|
|
/// 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<GameCatalog>>,
|
|
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
|
|
state_dir: OnceLock<PathBuf>,
|
|
active_outbound_transfers: OutboundTransfers,
|
|
outbound_transfer_emit: Arc<RwLock<OutboundTransferEmitState>>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct InstallSettings {
|
|
account_name: String,
|
|
language: String,
|
|
}
|
|
|
|
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,
|
|
active_outbound_transfers: usize,
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize, 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,
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Serialize)]
|
|
struct MainLogLinePayload {
|
|
line: String,
|
|
level: String,
|
|
}
|
|
|
|
struct SidecarUnpacker {
|
|
app_handle: AppHandle,
|
|
}
|
|
|
|
const MAX_UNPACK_LOGS: usize = 20;
|
|
const UNPACK_LOGS_FILE_NAME: &str = "unpack-logs.json";
|
|
const MAIN_LOG_FILE_NAME: &str = "lanspread.log";
|
|
const MAX_MAIN_LOG_BYTES: u64 = 2 * 1024 * 1024;
|
|
const MAIN_LOG_TRIM_SLACK_BYTES: u64 = 64 * 1024;
|
|
|
|
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())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn get_main_logs(app_handle: tauri::AppHandle) -> tauri::Result<String> {
|
|
let state_dir = app_handle.path().app_data_dir()?;
|
|
fs::create_dir_all(&state_dir)?;
|
|
let path = main_log_path(&state_dir);
|
|
trim_main_log_file(&path)?;
|
|
|
|
match fs::read_to_string(&path) {
|
|
Ok(contents) => Ok(contents),
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
|
|
Err(err) => Err(err.into()),
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
language: String,
|
|
username: 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 _ = (language, username);
|
|
let handled = if let Some(peer_ctrl) = peer_ctrl {
|
|
let command = if !downloaded {
|
|
PeerCommand::GetGame(id.clone())
|
|
} else if !installed {
|
|
PeerCommand::InstallGame { id: id.clone() }
|
|
} 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:?}");
|
|
return Ok(false);
|
|
}
|
|
true
|
|
} else {
|
|
log::warn!("Peer system not initialized yet");
|
|
false
|
|
};
|
|
|
|
Ok(handled)
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn update_game(
|
|
id: String,
|
|
language: String,
|
|
username: 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();
|
|
|
|
let _ = (language, username);
|
|
if let Some(peer_ctrl) = peer_ctrl {
|
|
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id: id.clone() }) {
|
|
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),
|
|
}
|
|
}
|
|
|
|
fn install_settings(language: &str, username: &str) -> InstallSettings {
|
|
InstallSettings {
|
|
account_name: sanitize_username(username),
|
|
language: install_language(language),
|
|
}
|
|
}
|
|
|
|
fn install_language(language: &str) -> String {
|
|
match sanitize_language(language).as_str() {
|
|
"de" => "german".to_string(),
|
|
_ => "english".to_string(),
|
|
}
|
|
}
|
|
|
|
#[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()
|
|
);
|
|
}
|
|
}
|
|
|
|
apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await;
|
|
|
|
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(())
|
|
}
|
|
|
|
/// Stamp the launcher's username and language into the installed game's setting
|
|
/// files the first time it is played. Uses the same processed values the install
|
|
/// transaction used to write before this step moved to play time.
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
async fn apply_launch_settings(
|
|
state_dir: &Path,
|
|
game_path: &Path,
|
|
id: &str,
|
|
language: &str,
|
|
username: &str,
|
|
) {
|
|
let settings = install_settings(language, username);
|
|
match lanspread_peer::apply_launch_settings_once(
|
|
state_dir,
|
|
game_path,
|
|
id,
|
|
Some(&settings.account_name),
|
|
Some(&settings.language),
|
|
)
|
|
.await
|
|
{
|
|
Ok(outcome) => log::info!("launch settings for {id}: {outcome:?}"),
|
|
Err(e) => log::error!("failed to apply launch settings for {id}: {e}"),
|
|
}
|
|
}
|
|
|
|
#[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 Some(state_dir) = state.inner().state_dir.get().cloned() else {
|
|
log::error!("app state directory is not initialized; cannot start server");
|
|
return Ok(false);
|
|
};
|
|
apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await;
|
|
|
|
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 apply_peer_remote_games(game_db: &mut GameDB, peer_games: Vec<Game>) {
|
|
// Peer events update availability, but catalog metadata stays anchored to game.db.
|
|
for game in game_db.games.values_mut() {
|
|
game.peer_count = 0;
|
|
}
|
|
|
|
for peer_game in peer_games {
|
|
if let Some(existing) = game_db.get_mut_game_by_id(&peer_game.id) {
|
|
existing.peer_count = peer_game.peer_count;
|
|
} else {
|
|
log::debug!(
|
|
"Peer advertised unknown game {id}; ignoring because game.db is ground truth",
|
|
id = peer_game.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 active_transfers = state.active_outbound_transfers.read().await;
|
|
|
|
let games_to_emit = game_db
|
|
.all_games()
|
|
.into_iter()
|
|
.cloned()
|
|
.map(|game| {
|
|
let active_outbound_transfers = active_transfers.get(&game.id).map_or(0, Vec::len);
|
|
LauncherGame {
|
|
can_host_server: game_can_host_server(&games_folder, &game),
|
|
active_outbound_transfers,
|
|
game,
|
|
}
|
|
})
|
|
.collect::<Vec<LauncherGame>>();
|
|
|
|
drop(game_db);
|
|
drop(active_transfers);
|
|
|
|
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;
|
|
apply_peer_remote_games(&mut game_db, games);
|
|
}
|
|
|
|
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 = clean_terminal_log(&String::from_utf8_lossy(&out.stdout));
|
|
let stderr = clean_terminal_log(&String::from_utf8_lossy(&out.stderr));
|
|
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 entry = entry;
|
|
clean_unpack_log_entry(&mut entry);
|
|
let logs = {
|
|
let mut logs = state.inner().unpack_logs.write().await;
|
|
logs.push(entry);
|
|
trim_unpack_logs(&mut logs);
|
|
logs.clone()
|
|
};
|
|
|
|
persist_unpack_logs(app_handle, &logs).await;
|
|
|
|
if let Err(err) = app_handle.emit("unpack-logs-updated", ()) {
|
|
log::warn!("Failed to emit unpack-logs-updated event: {err}");
|
|
}
|
|
}
|
|
|
|
fn trim_unpack_logs(logs: &mut Vec<UnpackLogEntry>) {
|
|
if logs.len() > MAX_UNPACK_LOGS {
|
|
let overflow = logs.len() - MAX_UNPACK_LOGS;
|
|
logs.drain(..overflow);
|
|
}
|
|
}
|
|
|
|
fn clean_unpack_log_entry(entry: &mut UnpackLogEntry) {
|
|
let stdout = clean_terminal_log(&entry.stdout);
|
|
let stderr = clean_terminal_log(&entry.stderr);
|
|
entry.stdout = stdout;
|
|
entry.stderr = stderr;
|
|
}
|
|
|
|
fn clean_terminal_log(input: &str) -> String {
|
|
let mut output = String::new();
|
|
let mut line = String::new();
|
|
let mut chars = input.chars().peekable();
|
|
|
|
while let Some(ch) = chars.next() {
|
|
match ch {
|
|
'\r' if chars.peek() == Some(&'\n') => {
|
|
let _ = chars.next();
|
|
output.push_str(&line);
|
|
output.push('\n');
|
|
line.clear();
|
|
}
|
|
'\r' => {
|
|
line.clear();
|
|
}
|
|
'\n' => {
|
|
output.push_str(&line);
|
|
output.push('\n');
|
|
line.clear();
|
|
}
|
|
'\u{8}' => {
|
|
let _ = line.pop();
|
|
}
|
|
'\t' => line.push(ch),
|
|
ch if ch.is_control() => {}
|
|
ch => line.push(ch),
|
|
}
|
|
}
|
|
|
|
output.push_str(&line);
|
|
output
|
|
}
|
|
|
|
fn unpack_logs_path(state_dir: &Path) -> PathBuf {
|
|
state_dir.join(UNPACK_LOGS_FILE_NAME)
|
|
}
|
|
|
|
fn main_log_path(state_dir: &Path) -> PathBuf {
|
|
state_dir.join(MAIN_LOG_FILE_NAME)
|
|
}
|
|
|
|
fn trim_main_log_file(path: &Path) -> std::io::Result<()> {
|
|
trim_main_log_file_to_limit(path, MAX_MAIN_LOG_BYTES)
|
|
}
|
|
|
|
fn trim_main_log_file_to_limit(path: &Path, max_bytes: u64) -> std::io::Result<()> {
|
|
let metadata = match fs::metadata(path) {
|
|
Ok(metadata) => metadata,
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
|
Err(err) => return Err(err),
|
|
};
|
|
|
|
if metadata.len() <= max_bytes {
|
|
return Ok(());
|
|
}
|
|
|
|
if max_bytes == 0 {
|
|
fs::write(path, [])?;
|
|
return Ok(());
|
|
}
|
|
|
|
let mut file = fs::File::open(path)?;
|
|
file.seek(SeekFrom::Start(metadata.len() - max_bytes))?;
|
|
|
|
let mut bytes = Vec::with_capacity(usize::try_from(max_bytes).unwrap_or(usize::MAX));
|
|
file.read_to_end(&mut bytes)?;
|
|
let tail = valid_utf8_tail(bytes);
|
|
fs::write(path, tail.as_bytes())
|
|
}
|
|
|
|
fn valid_utf8_tail(bytes: Vec<u8>) -> String {
|
|
for offset in 0..bytes.len().min(4) {
|
|
if let Ok(tail) = std::str::from_utf8(&bytes[offset..]) {
|
|
return tail.to_string();
|
|
}
|
|
}
|
|
|
|
String::from_utf8_lossy(&bytes).into_owned()
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct MainLogSink {
|
|
app_handle: AppHandle,
|
|
path: PathBuf,
|
|
file_lock: Arc<Mutex<()>>,
|
|
}
|
|
|
|
impl MainLogSink {
|
|
fn new(app_handle: AppHandle, path: PathBuf) -> Self {
|
|
Self {
|
|
app_handle,
|
|
path,
|
|
file_lock: Arc::new(Mutex::new(())),
|
|
}
|
|
}
|
|
|
|
fn write_line(&self, line: String, level: Level) {
|
|
write_main_log_stdout(&line);
|
|
self.append_file_line(&line);
|
|
|
|
let _ = self.app_handle.emit(
|
|
"main-log-line",
|
|
MainLogLinePayload {
|
|
line,
|
|
level: level.as_str().to_string(),
|
|
},
|
|
);
|
|
}
|
|
|
|
fn append_file_line(&self, line: &str) {
|
|
let Ok(_guard) = self.file_lock.lock() else {
|
|
return;
|
|
};
|
|
|
|
if let Some(parent) = self.path.parent() {
|
|
let _ = fs::create_dir_all(parent);
|
|
}
|
|
|
|
let Ok(mut file) = OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&self.path)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
if writeln!(file, "{line}").is_err() {
|
|
return;
|
|
}
|
|
|
|
let should_trim = file
|
|
.metadata()
|
|
.is_ok_and(|metadata| {
|
|
metadata.len() > MAX_MAIN_LOG_BYTES.saturating_add(MAIN_LOG_TRIM_SLACK_BYTES)
|
|
});
|
|
|
|
if should_trim {
|
|
let _ = trim_main_log_file(&self.path);
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MainLogLayer {
|
|
sink: MainLogSink,
|
|
}
|
|
|
|
impl MainLogLayer {
|
|
fn new(sink: MainLogSink) -> Self {
|
|
Self { sink }
|
|
}
|
|
}
|
|
|
|
impl<S> Layer<S> for MainLogLayer
|
|
where
|
|
S: Subscriber + for<'span> LookupSpan<'span>,
|
|
{
|
|
fn enabled(&self, metadata: &Metadata<'_>, _ctx: Context<'_, S>) -> bool {
|
|
should_capture_main_log_metadata(metadata)
|
|
}
|
|
|
|
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
|
let metadata = event.metadata();
|
|
if !should_capture_main_log_metadata(metadata) {
|
|
return;
|
|
}
|
|
|
|
let mut visitor = MainLogFieldVisitor::default();
|
|
event.record(&mut visitor);
|
|
let target = visitor
|
|
.log_target
|
|
.clone()
|
|
.unwrap_or_else(|| metadata.target().to_string());
|
|
let message = visitor.into_message();
|
|
let (date, time) = current_main_log_timestamp();
|
|
let line = format_main_log_line_parts(
|
|
&date,
|
|
&time,
|
|
&target,
|
|
metadata.level().as_str(),
|
|
&message,
|
|
);
|
|
|
|
self.sink.write_line(line, *metadata.level());
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct MainLogFieldVisitor {
|
|
message: Option<String>,
|
|
log_target: Option<String>,
|
|
fields: Vec<String>,
|
|
}
|
|
|
|
impl MainLogFieldVisitor {
|
|
fn record_value(&mut self, field_name: &str, value: String) {
|
|
match field_name {
|
|
"message" => self.message = Some(value),
|
|
"log.target" => self.log_target = Some(value),
|
|
"log.module_path" | "log.file" | "log.line" => {}
|
|
_ => self.fields.push(format!("{field_name}={value}")),
|
|
}
|
|
}
|
|
|
|
fn into_message(self) -> String {
|
|
let mut parts = Vec::new();
|
|
if let Some(message) = self.message
|
|
&& !message.is_empty()
|
|
{
|
|
parts.push(message);
|
|
}
|
|
parts.extend(self.fields);
|
|
|
|
if parts.is_empty() {
|
|
String::from("(no message)")
|
|
} else {
|
|
normalize_main_log_message(&parts.join(" "))
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Visit for MainLogFieldVisitor {
|
|
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
|
|
self.record_value(field.name(), value.to_string());
|
|
}
|
|
|
|
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
|
|
self.record_value(field.name(), value.to_string());
|
|
}
|
|
|
|
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
|
|
self.record_value(field.name(), value.to_string());
|
|
}
|
|
|
|
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
|
self.record_value(field.name(), value.to_string());
|
|
}
|
|
|
|
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
|
self.record_value(field.name(), format!("{value:?}"));
|
|
}
|
|
}
|
|
|
|
fn should_capture_main_log_metadata(metadata: &Metadata<'_>) -> bool {
|
|
if metadata.target().starts_with("mdns_sd::service_daemon") {
|
|
return false;
|
|
}
|
|
|
|
matches!(*metadata.level(), Level::ERROR | Level::WARN | Level::INFO)
|
|
}
|
|
|
|
fn current_main_log_timestamp() -> (String, String) {
|
|
let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
|
|
let date = now.date();
|
|
let clock = now.time();
|
|
|
|
(
|
|
format!(
|
|
"{:04}-{:02}-{:02}",
|
|
date.year(),
|
|
u8::from(date.month()),
|
|
date.day()
|
|
),
|
|
format!(
|
|
"{:02}:{:02}:{:02}",
|
|
clock.hour(),
|
|
clock.minute(),
|
|
clock.second()
|
|
),
|
|
)
|
|
}
|
|
|
|
fn format_main_log_line_parts(
|
|
date: &str,
|
|
time: &str,
|
|
target: &str,
|
|
level: &str,
|
|
message: &str,
|
|
) -> String {
|
|
format!(
|
|
"[{date}][{time}][{}][{level}] {message}",
|
|
normalize_main_log_target(target)
|
|
)
|
|
}
|
|
|
|
fn normalize_main_log_target(target: &str) -> String {
|
|
target.replace(['\r', '\n'], " ")
|
|
}
|
|
|
|
fn normalize_main_log_message(message: &str) -> String {
|
|
message.replace('\r', "\\r").replace('\n', "\\n")
|
|
}
|
|
|
|
fn write_main_log_stdout(line: &str) {
|
|
let stdout = std::io::stdout();
|
|
let mut stdout = stdout.lock();
|
|
let _ = writeln!(stdout, "{line}");
|
|
}
|
|
|
|
fn init_main_logging(
|
|
app_handle: AppHandle,
|
|
path: PathBuf,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let subscriber =
|
|
tracing_subscriber::registry().with(MainLogLayer::new(MainLogSink::new(app_handle, path)));
|
|
tracing::subscriber::set_global_default(subscriber)?;
|
|
tracing_log::LogTracer::builder()
|
|
.with_max_level(log::LevelFilter::Info)
|
|
.init()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn load_unpack_logs(state_dir: &Path) -> Vec<UnpackLogEntry> {
|
|
let path = unpack_logs_path(state_dir);
|
|
let contents = match std::fs::read_to_string(&path) {
|
|
Ok(contents) => contents,
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
|
|
Err(err) => {
|
|
log::warn!("Failed to read unpack logs from {}: {err}", path.display());
|
|
return Vec::new();
|
|
}
|
|
};
|
|
|
|
let mut logs = match serde_json::from_str::<Vec<UnpackLogEntry>>(&contents) {
|
|
Ok(logs) => logs,
|
|
Err(err) => {
|
|
log::warn!("Failed to parse unpack logs from {}: {err}", path.display());
|
|
return Vec::new();
|
|
}
|
|
};
|
|
logs.iter_mut().for_each(clean_unpack_log_entry);
|
|
trim_unpack_logs(&mut logs);
|
|
logs
|
|
}
|
|
|
|
async fn persist_unpack_logs(app_handle: &AppHandle, logs: &[UnpackLogEntry]) {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
let Some(state_dir) = state.state_dir.get().cloned() else {
|
|
log::warn!("Cannot persist unpack logs before app state directory is initialized");
|
|
return;
|
|
};
|
|
let path = unpack_logs_path(&state_dir);
|
|
let contents = match serde_json::to_vec_pretty(logs) {
|
|
Ok(contents) => contents,
|
|
Err(err) => {
|
|
log::warn!(
|
|
"Failed to serialize unpack logs for {}: {err}",
|
|
path.display()
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
if let Err(err) = tokio::fs::write(&path, contents).await {
|
|
log::warn!("Failed to persist unpack logs to {}: {err}", path.display());
|
|
}
|
|
}
|
|
|
|
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 = GameCatalog::from_game_db(&game_db);
|
|
*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),
|
|
active_outbound_transfers: Some(state.active_outbound_transfers.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;
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn schedule_outbound_transfer_emit(app_handle: &AppHandle) {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
let should_spawn = {
|
|
let mut emit_state = state.outbound_transfer_emit.write().await;
|
|
emit_state.record_change()
|
|
};
|
|
|
|
if !should_spawn {
|
|
return;
|
|
}
|
|
|
|
let app_handle = app_handle.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
loop {
|
|
tokio::time::sleep(OUTBOUND_TRANSFER_EMIT_DEBOUNCE).await;
|
|
|
|
let observed_generation = {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
state
|
|
.outbound_transfer_emit
|
|
.read()
|
|
.await
|
|
.observed_generation()
|
|
};
|
|
emit_games_list(&app_handle).await;
|
|
|
|
let needs_follow_up_emit = {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
let mut emit_state = state.outbound_transfer_emit.write().await;
|
|
emit_state.finish_emit(observed_generation)
|
|
};
|
|
if !needs_follow_up_emit {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#[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::OutboundTransferCountChanged => {
|
|
log::info!("PeerEvent::OutboundTransferCountChanged received");
|
|
schedule_outbound_transfer_emit(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 unpack_log_fixture(index: usize) -> UnpackLogEntry {
|
|
let timestamp = u64::try_from(index).unwrap_or(u64::MAX);
|
|
UnpackLogEntry {
|
|
archive: format!("archive-{index}.rar"),
|
|
destination: format!("destination-{index}"),
|
|
status_code: Some(0),
|
|
stdout: format!("stdout {index}"),
|
|
stderr: String::new(),
|
|
started_at_ms: timestamp,
|
|
finished_at_ms: timestamp,
|
|
success: true,
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
fn eti_game_fixture(game_id: &str, game_version: &str) -> lanspread_compat::eti::EtiGame {
|
|
lanspread_compat::eti::EtiGame {
|
|
game_id: game_id.to_string(),
|
|
game_title: "Catalog Game".to_string(),
|
|
game_key: "catalog-game".to_string(),
|
|
game_release: "2000".to_string(),
|
|
game_publisher: "publisher".to_string(),
|
|
game_size: 1.0,
|
|
game_readme_de: "description".to_string(),
|
|
game_readme_en: "description".to_string(),
|
|
game_readme_fr: "description".to_string(),
|
|
game_maxplayers: 4,
|
|
game_master_req: 0,
|
|
genre_de: "genre".to_string(),
|
|
game_version: game_version.to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn eti_game_conversion_uses_catalog_version_as_authoritative_eti_version() {
|
|
let game = Game::from(eti_game_fixture("alpha", "20200721"));
|
|
|
|
assert_eq!(game.version, "20200721");
|
|
assert_eq!(game.eti_game_version.as_deref(), Some("20200721"));
|
|
assert_eq!(game.local_version, None);
|
|
}
|
|
|
|
#[test]
|
|
fn terminal_log_cleanup_preserves_crlf_and_collapses_redrawn_lines() {
|
|
let input = "Extracting foo 10%\rExtracting foo 80%\rExtracting foo OK\r\nAll done\r\n";
|
|
|
|
assert_eq!(clean_terminal_log(input), "Extracting foo OK\nAll done\n");
|
|
}
|
|
|
|
#[test]
|
|
fn terminal_log_cleanup_applies_backspaces() {
|
|
assert_eq!(clean_terminal_log("abc\u{8}\u{8}de\n"), "ade\n");
|
|
}
|
|
|
|
#[test]
|
|
fn terminal_log_cleanup_removes_other_controls() {
|
|
assert_eq!(clean_terminal_log("a\u{7}b\tc"), "ab\tc");
|
|
}
|
|
|
|
#[test]
|
|
fn unpack_log_retention_keeps_last_twenty_entries() {
|
|
let mut logs = (0..25).map(unpack_log_fixture).collect::<Vec<_>>();
|
|
|
|
trim_unpack_logs(&mut logs);
|
|
|
|
assert_eq!(logs.len(), MAX_UNPACK_LOGS);
|
|
assert_eq!(
|
|
logs.first().map(|entry| entry.archive.as_str()),
|
|
Some("archive-5.rar")
|
|
);
|
|
assert_eq!(
|
|
logs.last().map(|entry| entry.archive.as_str()),
|
|
Some("archive-24.rar")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unpack_logs_load_from_app_state_dir_and_apply_retention() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"lanspread-unpack-logs-test-{}",
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("clock should be after epoch")
|
|
.as_nanos()
|
|
));
|
|
std::fs::create_dir_all(&root).expect("test state dir should be created");
|
|
let logs = (0..25).map(unpack_log_fixture).collect::<Vec<_>>();
|
|
std::fs::write(
|
|
unpack_logs_path(&root),
|
|
serde_json::to_vec(&logs).expect("logs should serialize"),
|
|
)
|
|
.expect("logs should be written");
|
|
|
|
let loaded = load_unpack_logs(&root);
|
|
|
|
assert_eq!(loaded.len(), MAX_UNPACK_LOGS);
|
|
assert_eq!(
|
|
loaded.first().map(|entry| entry.archive.as_str()),
|
|
Some("archive-5.rar")
|
|
);
|
|
assert_eq!(
|
|
loaded.last().map(|entry| entry.archive.as_str()),
|
|
Some("archive-24.rar")
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(root);
|
|
}
|
|
|
|
#[test]
|
|
fn main_log_line_format_is_stable_and_single_line() {
|
|
let line = format_main_log_line_parts(
|
|
"2026-06-07",
|
|
"12:34:56",
|
|
"lanspread\napp",
|
|
"WARN",
|
|
"first line\nsecond line",
|
|
);
|
|
|
|
assert_eq!(
|
|
line,
|
|
"[2026-06-07][12:34:56][lanspread app][WARN] first line\\nsecond line"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn main_log_trim_keeps_utf8_tail_at_char_boundary() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"lanspread-main-log-test-{}",
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("clock should be after epoch")
|
|
.as_nanos()
|
|
));
|
|
std::fs::create_dir_all(&root).expect("test state dir should be created");
|
|
let path = main_log_path(&root);
|
|
std::fs::write(&path, format!("{}{}", "a".repeat(11), "é".repeat(20)))
|
|
.expect("main log should be written");
|
|
|
|
trim_main_log_file_to_limit(&path, 21).expect("main log should trim");
|
|
|
|
let trimmed = std::fs::read_to_string(&path).expect("trimmed log should remain utf-8");
|
|
assert!(trimmed.as_bytes().len() <= 21);
|
|
assert!(trimmed.starts_with('é'));
|
|
assert!(trimmed.ends_with('é'));
|
|
|
|
let _ = std::fs::remove_dir_all(root);
|
|
}
|
|
|
|
#[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 outbound_transfer_emit_state_coalesces_bursts_without_losing_updates() {
|
|
let mut state = OutboundTransferEmitState::default();
|
|
|
|
assert!(
|
|
state.record_change(),
|
|
"first change should schedule an emit"
|
|
);
|
|
assert_eq!(state.observed_generation(), 1);
|
|
assert!(
|
|
!state.record_change(),
|
|
"second change should reuse the scheduled emit"
|
|
);
|
|
assert_eq!(state.observed_generation(), 2);
|
|
|
|
assert!(
|
|
state.finish_emit(1),
|
|
"a generation observed before the latest change needs a follow-up emit"
|
|
);
|
|
assert!(
|
|
!state.finish_emit(2),
|
|
"the latest observed generation clears the scheduled emit"
|
|
);
|
|
assert!(state.record_change(), "a later burst should schedule again");
|
|
}
|
|
|
|
#[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 install_settings_use_language_file_values() {
|
|
assert_eq!(
|
|
install_settings("de", " Alice \"Ace\"%PATH%\n "),
|
|
InstallSettings {
|
|
account_name: "Alice AcePATH".to_string(),
|
|
language: "german".to_string(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
install_settings("fr", ""),
|
|
InstallSettings {
|
|
account_name: DEFAULT_USERNAME.to_string(),
|
|
language: "english".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());
|
|
}
|
|
|
|
#[test]
|
|
fn peer_remote_snapshot_updates_counts_without_overwriting_catalog_version() {
|
|
let mut alpha = game_fixture("alpha", "Catalog Alpha");
|
|
alpha.size = 999;
|
|
alpha.eti_game_version = Some("20200721".to_string());
|
|
|
|
let mut beta = game_fixture("beta", "Catalog Beta");
|
|
beta.peer_count = 2;
|
|
beta.eti_game_version = Some("20200101".to_string());
|
|
|
|
let mut game_db = GameDB::from(vec![alpha, beta]);
|
|
|
|
let mut peer_alpha = game_fixture("alpha", "Peer Alpha");
|
|
peer_alpha.size = 42;
|
|
peer_alpha.peer_count = 3;
|
|
peer_alpha.eti_game_version = Some("20990101".to_string());
|
|
|
|
let mut unknown = game_fixture("unknown", "Unknown");
|
|
unknown.peer_count = 1;
|
|
unknown.eti_game_version = Some("20990101".to_string());
|
|
|
|
apply_peer_remote_games(&mut game_db, vec![peer_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_eq!(alpha.eti_game_version.as_deref(), Some("20200721"));
|
|
|
|
let beta = game_db.get_game_by_id("beta").expect("beta remains");
|
|
assert_eq!(beta.peer_count, 0);
|
|
assert_eq!(beta.eti_game_version.as_deref(), Some("20200101"));
|
|
|
|
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() {
|
|
// 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_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,
|
|
get_main_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)?;
|
|
init_main_logging(app.handle().clone(), main_log_path(&state_dir))?;
|
|
let state = app.state::<LanSpreadState>();
|
|
let unpack_logs = load_unpack_logs(&state_dir);
|
|
tauri::async_runtime::block_on(async {
|
|
*state.unpack_logs.write().await = unpack_logs;
|
|
});
|
|
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");
|
|
}
|
|
});
|
|
}
|