Files
lanspread/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs
T
ddidderr 4b7725db16 fix(call-to-play): compact terminal histories
The 4,096-event store retained every completed call forever and local commands
only reported that they reached the queue. Once the bound was reached, GUI
actions could therefore fail with no user-visible result. The CLI snapshot wait
could also be satisfied by an unrelated live event.

Keep the complete event and chat history for every active call so late joiners
receive full context. When the creator starts or cancels a call, replace its
history with a single terminal tombstone; this bounds retained payload while
still healing peers that missed the live terminal action. Publish commands now
reply with the actual store result, and CLI snapshots use a direct reply.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed, including full-cap terminal compaction
- `just build` -- passed
- `just peer-cli-tests S48` -- passed
- `git diff --cached --check` -- passed
2026-07-21 22:48:19 +02:00

2955 lines
91 KiB
Rust

use std::{
collections::{HashMap, HashSet},
fs::{self, OpenOptions},
io::{self, Read as _, Seek as _, SeekFrom, Write as _},
path::{Component, Path, PathBuf},
sync::{
Arc,
Mutex,
OnceLock,
atomic::{AtomicU64, Ordering},
},
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,
CallToPlayEvent,
ExternalUnrarStreamProvider,
NoopStreamInstallProvider,
PeerCommand,
PeerEvent,
PeerGameDB,
PeerRuntimeHandle,
PeerStartOptions,
StreamInstallProvider,
UnpackFuture,
Unpacker,
migrate_legacy_state,
start_peer_with_options,
};
use tauri::{AppHandle, Emitter as _, Manager};
use tauri_plugin_shell::{
ShellExt,
process::{Command, CommandChild, CommandEvent},
};
use tokio::sync::{
RwLock,
mpsc::{UnboundedReceiver, UnboundedSender},
oneshot,
};
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>>>,
local_peer_id: Arc<RwLock<Option<String>>>,
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>,
main_log_sink: OnceLock<MainLogSink>,
active_outbound_transfers: OutboundTransfers,
outbound_transfer_emit: Arc<RwLock<OutboundTransferEmitState>>,
/// Live unrar sidecar processes, so they can be killed if the launcher exits
/// mid-unpack. The shell plugin does not kill spawned children on app exit,
/// and the OS does not cascade-kill child processes, so without this an
/// in-progress unrar keeps running after the launcher closes.
active_unrar_children: Arc<Mutex<UnrarChildRegistry>>,
}
/// Live unrar sidecar children plus a shutdown latch.
///
/// Children are keyed by a monotonic id (not pid) so a finishing install never
/// deregisters another install's child after a pid is recycled. The
/// `shutting_down` latch closes a time-of-check/time-of-use hole: the exit kill
/// sweep drains the map only once, so a child registered *after* the sweep (an
/// install task caught between `spawn()` and registration, or a later archive in
/// a multi-archive install — `unpack_archives` does not observe the shutdown
/// token) would be orphaned. Once the latch is set under this mutex, registration
/// kills the child immediately instead of inserting it where nothing will reap it.
#[derive(Default)]
struct UnrarChildRegistry {
shutting_down: bool,
children: HashMap<u64, CommandChild>,
}
/// Monotonic id source for [`UnrarChildRegistry`] entries.
static UNRAR_CHILD_SEQ: AtomicU64 = AtomicU64::new(0);
#[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,
installed_peer_count: u32,
}
#[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,
sequence: Option<u64>,
}
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct MainLogHistoryPayload {
contents: String,
last_sequence: u64,
}
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,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<MainLogHistoryPayload> {
if let Some(sink) = state.inner().main_log_sink.get() {
return Ok(sink.read_history()?);
}
let state_dir = app_handle.path().app_data_dir()?;
fs::create_dir_all(&state_dir)?;
let path = main_log_path(&state_dir);
match read_main_log_file_to_limit(&path, MAX_MAIN_LOG_BYTES) {
Ok(contents) => Ok(MainLogHistoryPayload {
contents,
last_sequence: 0,
}),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(MainLogHistoryPayload {
contents: String::new(),
last_sequence: 0,
}),
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 request_call_to_play_events(
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<Option<String>> {
let peer_ctrl = state.inner().peer_ctrl.read().await.clone();
let Some(peer_ctrl) = peer_ctrl else {
log::warn!("Peer system not initialized yet");
return Ok(None);
};
if peer_ctrl
.send(PeerCommand::GetCallToPlayEvents { reply: None })
.is_err()
{
return Ok(None);
}
Ok(state.inner().local_peer_id.read().await.clone())
}
#[tauri::command]
async fn publish_call_to_play(
event: CallToPlayEvent,
state: tauri::State<'_, LanSpreadState>,
) -> Result<bool, String> {
let peer_ctrl = state.inner().peer_ctrl.read().await.clone();
let Some(peer_ctrl) = peer_ctrl else {
log::warn!("Peer system not initialized yet");
return Ok(false);
};
let (reply, result) = oneshot::channel();
peer_ctrl
.send(PeerCommand::PublishCallToPlay { event, reply })
.map_err(|err| err.to_string())?;
result.await.map_err(|err| err.to_string())?.map(|()| true)
}
#[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 stream_install_game(
id: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
if state
.inner()
.active_operations
.read()
.await
.contains_key(&id)
{
log::warn!("Game already has an active operation: {id}");
return Ok(false);
}
let Some((downloaded, installed, peer_count)) = state
.inner()
.games
.read()
.await
.get_game_by_id(&id)
.map(|game| (game.downloaded, game.installed, game.peer_count))
else {
log::warn!("Ignoring streamed install request for unknown game: {id}");
return Ok(false);
};
if downloaded || installed || peer_count == 0 {
log::warn!(
"Ignoring streamed install request for {id}: downloaded={downloaded}, \
installed={installed}, peer_count={peer_count}"
);
return Ok(false);
}
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
let peer_ctrl = peer_ctrl_arc.read().await.clone();
let Some(peer_ctrl) = peer_ctrl else {
log::warn!("Peer system not initialized yet");
return Ok(false);
};
if let Err(e) = peer_ctrl.send(PeerCommand::StreamInstallGame { id }) {
log::error!("Failed to send PeerCommand::StreamInstallGame: {e:?}");
return Ok(false);
}
Ok(true)
}
#[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 installed_peer_counts = state
.peer_game_db
.read()
.await
.peer_snapshots()
.into_iter()
.flat_map(|peer| peer.games)
.filter(|game| game.installed)
.fold(HashMap::<String, u32>::new(), |mut counts, game| {
*counts.entry(game.id).or_default() += 1;
counts
});
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,
installed_peer_count: installed_peer_counts.get(&game.id).copied().unwrap_or(0),
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<()> {
// Spawn (instead of `.output()`) so we keep a killable handle. The shell
// plugin's `output()` drops the `CommandChild` immediately and only drains
// the event channel, leaving the unrar process orphaned if the launcher
// exits before extraction finishes.
let (mut events, child) = 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)
.spawn()
{
Ok(spawned) => spawned,
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}");
}
};
// Register the live child so a launcher exit can kill it, and deregister it
// automatically on every exit path via the RAII guard.
let registry = app_handle
.state::<LanSpreadState>()
.active_unrar_children
.clone();
let child_id = UNRAR_CHILD_SEQ.fetch_add(1, Ordering::Relaxed);
register_unrar_child(&registry, child_id, child);
let _child_guard = UnrarChildGuard { registry, child_id };
let mut stdout_bytes = Vec::new();
let mut stderr_bytes = Vec::new();
let mut status_code = None;
while let Some(event) = events.recv().await {
match event {
CommandEvent::Stdout(line) => {
stdout_bytes.extend(line);
stdout_bytes.push(b'\n');
}
CommandEvent::Stderr(line) => {
stderr_bytes.extend(line);
stderr_bytes.push(b'\n');
}
CommandEvent::Terminated(payload) => {
status_code = payload.code;
}
CommandEvent::Error(err) => {
log::warn!("unrar sidecar event error: {err}");
}
_ => {}
}
}
let stdout = clean_terminal_log(&String::from_utf8_lossy(&stdout_bytes));
let stderr = clean_terminal_log(&String::from_utf8_lossy(&stderr_bytes));
let success = status_code == Some(0);
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(())
}
/// Tracks a spawned unrar sidecar so the launcher can kill it on shutdown.
///
/// If shutdown has already begun (or the registry is poisoned), the child is
/// killed immediately instead of inserted, since the exit kill sweep has already
/// run and would never reap a late registration.
fn register_unrar_child(
registry: &Arc<Mutex<UnrarChildRegistry>>,
child_id: u64,
child: CommandChild,
) {
let Ok(mut guard) = registry.lock() else {
// A poisoned registry means we can no longer guarantee the child is
// killed on exit, so kill it now rather than risk orphaning it.
let pid = child.pid();
log::warn!("unrar child registry is poisoned; killing pid {pid} immediately");
if let Err(err) = child.kill() {
log::warn!("Failed to kill untracked unrar child (pid {pid}): {err}");
}
return;
};
if guard.shutting_down {
drop(guard);
let pid = child.pid();
log::info!("Killing unrar child (pid {pid}) that spawned during shutdown");
if let Err(err) = child.kill() {
log::warn!("Failed to kill unrar child (pid {pid}) spawned during shutdown: {err}");
}
return;
}
guard.children.insert(child_id, child);
}
/// Removes an unrar sidecar from the registry when its `run_unrar_sidecar` call
/// returns, regardless of success, error, or early bail.
struct UnrarChildGuard {
registry: Arc<Mutex<UnrarChildRegistry>>,
child_id: u64,
}
impl Drop for UnrarChildGuard {
fn drop(&mut self) {
if let Ok(mut guard) = self.registry.lock() {
guard.children.remove(&self.child_id);
}
}
}
/// Kills every in-progress unrar sidecar and latches the registry into shutdown
/// so any install task that spawns unrar after this point kills it on
/// registration. Called on app exit so a game install that is mid-extraction does
/// not leave `unrar` running after the launcher closes.
fn kill_active_unrar_children(app_handle: &AppHandle) {
let state = app_handle.state::<LanSpreadState>();
let children = {
let Ok(mut guard) = state.active_unrar_children.lock() else {
log::warn!("unrar child registry is poisoned; cannot kill children on shutdown");
return;
};
guard.shutting_down = true;
guard.children.drain().collect::<Vec<_>>()
};
for (_child_id, child) in children {
let pid = child.pid();
match child.kill() {
Ok(()) => log::info!("Killed in-progress unrar child (pid {pid}) on shutdown"),
Err(err) => log::warn!("Failed to kill unrar child (pid {pid}) on shutdown: {err}"),
}
}
}
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)
}
#[cfg(test)]
fn trim_main_log_file_to_limit(path: &Path, max_bytes: u64) -> io::Result<()> {
let mut file = match OpenOptions::new().read(true).write(true).open(path) {
Ok(file) => file,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err),
};
trim_main_log_file_to_limit_with_file(&mut file, max_bytes)
}
fn trim_main_log_file_to_limit_with_file(file: &mut fs::File, max_bytes: u64) -> io::Result<()> {
let metadata = file.metadata()?;
if metadata.len() <= max_bytes {
file.seek(SeekFrom::End(0))?;
return Ok(());
}
let tail = if max_bytes == 0 {
String::new()
} else {
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)?;
valid_utf8_tail(bytes)
};
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
file.write_all(tail.as_bytes())?;
file.seek(SeekFrom::End(0))?;
Ok(())
}
fn read_main_log_file_to_limit(path: &Path, max_bytes: u64) -> io::Result<String> {
let mut file = fs::File::open(path)?;
read_main_log_file_to_limit_with_file(&mut file, max_bytes)
}
fn read_main_log_file_to_limit_with_file(
file: &mut fs::File,
max_bytes: u64,
) -> io::Result<String> {
let metadata = file.metadata()?;
if metadata.len() == 0 || max_bytes == 0 {
file.seek(SeekFrom::End(0))?;
return Ok(String::new());
}
let start = metadata.len().saturating_sub(max_bytes);
file.seek(SeekFrom::Start(start))?;
let capacity = usize::try_from(metadata.len() - start).unwrap_or(usize::MAX);
let mut bytes = Vec::with_capacity(capacity);
file.read_to_end(&mut bytes)?;
file.seek(SeekFrom::End(0))?;
if start == 0 {
Ok(String::from_utf8_lossy(&bytes).into_owned())
} else {
Ok(valid_utf8_tail(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_state: Arc<Mutex<MainLogFileState>>,
}
#[derive(Default)]
struct MainLogFileState {
file: Option<fs::File>,
last_sequence: u64,
}
impl MainLogSink {
fn new(app_handle: AppHandle, path: PathBuf) -> Self {
Self {
app_handle,
path,
file_state: Arc::new(Mutex::new(MainLogFileState::default())),
}
}
fn write_line(&self, line: String, level: Level) {
write_main_log_stdout(&line);
let sequence = self.append_file_line(&line);
let _ = self.app_handle.emit(
"main-log-line",
MainLogLinePayload {
line,
level: level.as_str().to_string(),
sequence,
},
);
}
fn read_history(&self) -> io::Result<MainLogHistoryPayload> {
let mut file_state = self
.file_state
.lock()
.map_err(|_| io::Error::other("main log file lock poisoned"))?;
if file_state.file.is_none() && !self.path.exists() {
return Ok(MainLogHistoryPayload {
contents: String::new(),
last_sequence: file_state.last_sequence,
});
}
let contents = {
let file = self.cached_file(&mut file_state.file)?;
trim_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES)?;
read_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES)?
};
Ok(MainLogHistoryPayload {
contents,
last_sequence: file_state.last_sequence,
})
}
fn append_file_line(&self, line: &str) -> Option<u64> {
let Ok(mut file_state) = self.file_state.lock() else {
return None;
};
let write_result = self.cached_file(&mut file_state.file).and_then(|file| {
file.seek(SeekFrom::End(0))
.and_then(|_| writeln!(file, "{line}"))
});
if write_result.is_err() {
file_state.file = None;
return None;
}
file_state.last_sequence = file_state.last_sequence.saturating_add(1);
let sequence = file_state.last_sequence;
let should_trim = file_state.file.as_ref().is_some_and(|file| {
file.metadata().is_ok_and(|metadata| {
metadata.len() > MAX_MAIN_LOG_BYTES.saturating_add(MAIN_LOG_TRIM_SLACK_BYTES)
})
});
if should_trim && let Some(file) = file_state.file.as_mut() {
let _ = trim_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES);
}
Some(sequence)
}
fn cached_file<'a>(&self, file: &'a mut Option<fs::File>) -> io::Result<&'a mut fs::File> {
if file.is_none() {
*file = Some(open_main_log_file(&self.path)?);
}
file.as_mut()
.ok_or_else(|| io::Error::other("main log file was not opened"))
}
}
fn open_main_log_file(path: &Path) -> io::Result<fs::File> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(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}] {}",
normalize_main_log_target(target),
normalize_main_log_message(message)
)
}
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(sink: MainLogSink) -> Result<(), Box<dyn std::error::Error>> {
let subscriber = tracing_subscriber::registry().with(MainLogLayer::new(sink));
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(),
});
let stream_install_provider = stream_install_provider_for_app(app_handle);
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()),
stream_install_provider: Some(stream_install_provider),
},
) {
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 stream_install_provider_for_app(app_handle: &AppHandle) -> Arc<dyn StreamInstallProvider> {
match resolve_unrar_sidecar_program(app_handle) {
Ok(program) => Arc::new(ExternalUnrarStreamProvider::new(program)),
Err(err) => {
log::error!("Failed to resolve streamed-install unrar sidecar: {err}");
Arc::new(NoopStreamInstallProvider)
}
}
}
fn resolve_unrar_sidecar_program(app_handle: &AppHandle) -> eyre::Result<PathBuf> {
let sidecar = app_handle.shell().sidecar("unrar")?;
let command: std::process::Command = sidecar.into();
Ok(PathBuf::from(command.get_program()))
}
fn emit_game_id_event(app_handle: &AppHandle, event: &str, id: &str, label: &str) {
if let Err(e) = app_handle.emit(event, Some(id.to_owned())) {
log::error!("{label}: Failed to emit {event} event: {e}");
}
}
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}");
*app_handle
.state::<LanSpreadState>()
.local_peer_id
.write()
.await = Some(peer_id);
}
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::CallToPlayEvents(events) => {
if let Err(err) = app_handle.emit("call-to-play-events", Some(events)) {
log::error!("Failed to emit call-to-play-events event: {err}");
}
}
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 for {id}");
}
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 } => {
log::info!("PeerEvent::DownloadGameFilesFinished received for {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::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::UninstallGameFinished { id } => {
log::info!("PeerEvent::UninstallGameFinished received for {id}");
}
PeerEvent::UninstallGameFailed { id } => {
log::warn!("PeerEvent::UninstallGameFailed received for {id}");
emit_game_id_event(
app_handle,
"game-uninstall-failed",
&id,
"PeerEvent::UninstallGameFailed",
);
}
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}");
}
PeerEvent::PeerDisconnected(addr) => {
log::info!("Peer disconnected: {addr}");
}
PeerEvent::PeerDiscovered(addr) => {
log::info!("Peer discovered: {addr}");
}
PeerEvent::PeerLost(addr) => {
log::info!("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");
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 continue queued game transfer: {e}");
}
}
#[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,
request_call_to_play_events,
publish_call_to_play,
install_game,
stream_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)?;
let main_log_sink = MainLogSink::new(app.handle().clone(), main_log_path(&state_dir));
let state = app.state::<LanSpreadState>();
if state.main_log_sink.set(main_log_sink.clone()).is_err() {
log::warn!("main log sink was already initialized");
}
init_main_logging(main_log_sink)?;
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) {
// Kill unrar first: an in-progress extraction would otherwise keep
// running after the launcher closes, and killing it lets the
// install task unwind so the peer runtime can stop promptly.
kill_active_unrar_children(app_handle);
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");
}
});
}
#[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.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());
}
}