use std::{ collections::{BTreeMap, HashMap, HashSet}, fmt::Write as _, fs::{self, OpenOptions}, io::{self, Read as _, Seek as _, SeekFrom, Write as _}, path::{Component, Path, PathBuf}, sync::{ Arc, Mutex, OnceLock, Weak, atomic::{AtomicBool, AtomicU64, Ordering}, }, time::{Duration, SystemTime, UNIX_EPOCH}, }; use eyre::bail; use lanspread_compat::catalog_bundle::{LoadedCatalog, load_catalog_bundle}; #[cfg(target_os = "windows")] use lanspread_db::content_manifest::CatalogContentManifest; use lanspread_db::{ content_manifest::CatalogBundle, db::{Availability, Game, GameDB}, }; use lanspread_peer::{ ActiveOperation, ActiveOperationKind, CallToPlayLocalIntent, CallToPlayReceipt, DownloadAttemptId, DownloadAttemptKey, DownloadFailureReason, DownloadProgress, DownloadVerificationActivity, ExternalUnrarStreamProvider, LocalNetworkSharingState, NoopStreamInstallProvider, PeerCommand, PeerEvent, PeerEventReceiver, PeerEventSender, PeerGameDB, PeerIdentity, PeerIdentityDurability, PeerRuntimeHandle, PeerStartOptions, RemoteLibraryView, ScopedProcess, StreamInstallProvider, StreamInstallSettings, UnpackFuture, Unpacker, migrate_legacy_state, peer_event_channel, scoped_blocking, start_peer_with_options, }; use tauri::{AppHandle, Emitter as _, Manager}; use tauri_plugin_shell::ShellExt; use tokio::sync::{ RwLock, mpsc::{UnboundedReceiver, UnboundedSender}, oneshot, watch, }; use tokio_util::{ sync::CancellationToken, task::{TaskTracker, task_tracker::TaskTrackerToken}, }; use tracing::{Event, Level, Metadata, Subscriber, field::Visit}; use tracing_subscriber::{ layer::{Context, Layer}, prelude::*, registry::LookupSpan, }; mod sharing_policy; #[cfg(any(test, target_os = "windows"))] mod windows_launch; #[cfg(target_os = "windows")] pub fn elevated_script_worker_exit_code() -> Option { windows_launch::elevated_worker_exit_code() } // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ type OutboundTransfers = Arc>>>; const OUTBOUND_TRANSFER_EMIT_DEBOUNCE: Duration = Duration::from_millis(100); #[derive(Default)] struct OutboundTransferEmitState { scheduled: bool, generation: u64, } #[derive(Clone, Default)] struct AppTaskScope { cancel_token: CancellationToken, tasks: TaskTracker, admission: Arc>, } #[derive(Default)] struct AppTaskAdmission { closed: bool, } impl AppTaskScope { fn cancel_token(&self) -> CancellationToken { self.cancel_token.clone() } fn spawn(&self, task: F) where F: std::future::Future + Send + 'static, { let admission = self .admission .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if admission.closed { drop(admission); drop(task); return; } let runtime = tauri::async_runtime::handle(); drop(self.tasks.spawn_on(task, runtime.inner())); drop(admission); } async fn shutdown(&self) { { let mut admission = self .admission .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); admission.closed = true; self.cancel_token.cancel(); self.tasks.close(); } self.tasks.wait().await; } } /// Admission and drain scope for application-owned Tauri invokes. /// /// Tauri owns the invoke futures, so they cannot be spawned in our task scope. /// A tracker token instead makes each admitted future part of the application /// shutdown boundary: shutdown closes admission, requests peer cancellation, /// waits for every token to be dropped, and then takes the runtime to join it. The /// separate serial lock orders game-directory startup, sharing transitions, /// and sharing-gated Call-to-Play publication through their acknowledgements /// and UI commits without participating in shutdown locking. #[derive(Clone, Default)] struct AppInvokeScope { invokes: TaskTracker, admission: Arc>, peer_startup_serial: Arc>, } #[derive(Default)] struct AppInvokeAdmission { closed: bool, } #[must_use = "the guard keeps an application invoke inside the shutdown scope"] struct AppInvokeGuard { _token: TaskTrackerToken, } impl AppInvokeScope { fn try_enter(&self) -> Option { let admission = self .admission .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if admission.closed { return None; } // The admission mutex makes token creation atomic with respect to // `close_admission`: a token is either visible to its wait or rejected. let guard = AppInvokeGuard { _token: self.invokes.token(), }; drop(admission); Some(guard) } async fn serialize_peer_startup(&self) -> tokio::sync::OwnedMutexGuard<()> { Arc::clone(&self.peer_startup_serial).lock_owned().await } fn close_admission(&self) { let mut admission = self .admission .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); admission.closed = true; self.invokes.close(); } async fn wait_closed(&self) { self.invokes.wait().await; } #[cfg(test)] async fn close_and_wait(&self) { self.close_admission(); self.wait_closed().await; } } const APP_SHUTDOWN_STARTED: &str = "application shutdown has started"; fn enter_app_invoke(state: &LanSpreadState) -> tauri::Result { state.app_invokes.try_enter().ok_or_else(|| { tauri::Error::from(io::Error::new( io::ErrorKind::Interrupted, APP_SHUTDOWN_STARTED, )) }) } 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 } } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "camelCase")] enum LocalNetworkSharingPhase { WaitingForGameDirectory, Disabled, Enabling, Enabled, Disabling, } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "camelCase")] enum SharingPersistenceProblem { Load, Save, } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "camelCase")] struct LocalNetworkSharingSnapshot { revision: u64, enabled: bool, pending_target: Option, phase: LocalNetworkSharingPhase, persistence_problem: Option, } impl LocalNetworkSharingSnapshot { const fn initial( enabled: bool, persistence_problem: Option, ) -> Self { Self { revision: 1, enabled, pending_target: None, phase: if enabled { LocalNetworkSharingPhase::WaitingForGameDirectory } else { LocalNetworkSharingPhase::Disabled }, persistence_problem, } } const fn is_stable_at(self, enabled: bool) -> bool { self.pending_target.is_none() && matches!( (enabled, self.phase), (true, LocalNetworkSharingPhase::Enabled) | (false, LocalNetworkSharingPhase::Disabled) ) } fn admits_network_actions(self) -> bool { self.phase == LocalNetworkSharingPhase::Enabled && self.pending_target != Some(false) } } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "camelCase")] enum IdentityDiagnostic { Ephemeral, } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "camelCase")] struct IdentityDiagnosticSnapshot { revision: u64, diagnostic: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "camelCase")] enum GameTransferStatus { Verifying, Retrying, Exhausted, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "camelCase")] struct GameTransferStatusSnapshot { revision: u64, statuses: BTreeMap, open_attempts: BTreeMap, } impl Default for GameTransferStatusSnapshot { fn default() -> Self { Self { revision: 1, statuses: BTreeMap::new(), open_attempts: BTreeMap::new(), } } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct GameTransferAttemptReceipt { attempt_id: DownloadAttemptId, terminal: bool, } #[derive(Debug, Default)] struct GameTransferStatusStore { snapshot: GameTransferStatusSnapshot, attempts: HashMap, } impl GameTransferStatusStore { fn snapshot(&self) -> GameTransferStatusSnapshot { self.snapshot.clone() } fn begin( &mut self, attempt: &DownloadAttemptKey, ) -> Result, String> { if self .attempts .get(&attempt.id) .is_some_and(|current| current.attempt_id >= attempt.attempt_id) { return Ok(None); } let revision = self.next_revision()?; self.attempts.insert( attempt.id.clone(), GameTransferAttemptReceipt { attempt_id: attempt.attempt_id, terminal: false, }, ); self.snapshot .open_attempts .insert(attempt.id.clone(), attempt.attempt_id); self.snapshot.statuses.remove(&attempt.id); self.snapshot.revision = revision; Ok(Some(self.snapshot())) } fn activity( &mut self, attempt: &DownloadAttemptKey, activity: Option, ) -> Result, String> { if !self.is_current_open_attempt(attempt) { return Ok(None); } let status = activity.map(|activity| match activity { DownloadVerificationActivity::VerifyingDownloadedChunks => { GameTransferStatus::Verifying } DownloadVerificationActivity::RetryingInvalidSource => GameTransferStatus::Retrying, }); if self.snapshot.statuses.get(&attempt.id).copied() == status { return Ok(None); } let revision = self.next_revision()?; match status { Some(status) => { self.snapshot.statuses.insert(attempt.id.clone(), status); } None => { self.snapshot.statuses.remove(&attempt.id); } } self.snapshot.revision = revision; Ok(Some(self.snapshot())) } fn finished( &mut self, attempt: &DownloadAttemptKey, ) -> Result, String> { self.settle(attempt, None) } fn failed( &mut self, attempt: &DownloadAttemptKey, reason: DownloadFailureReason, ) -> Result, String> { let status = match reason { DownloadFailureReason::VerifiedCatalogSourcesExhausted => { Some(GameTransferStatus::Exhausted) } DownloadFailureReason::OperationFailed if !self.is_current_open_attempt(attempt) && self.snapshot.statuses.get(&attempt.id) == Some(&GameTransferStatus::Exhausted) => { // A newer preflight failure can be terminal-only. It adopts // the monotonic receipt and emits the generic failure, but it // is not one of the explicit authorities that clears an // earlier verified-source exhaustion diagnostic. Some(GameTransferStatus::Exhausted) } DownloadFailureReason::OperationFailed => None, }; self.settle(attempt, status) } fn accepts_progress(&self, progress: &DownloadProgress) -> bool { self.is_current_open_attempt(&progress.attempt) } fn clear_settled_for_local_generation( &mut self, ) -> Result, String> { let visible_settled = self .attempts .iter() .filter_map(|(id, receipt)| { (receipt.terminal && self.snapshot.statuses.contains_key(id)).then_some(id.clone()) }) .collect::>(); if visible_settled.is_empty() { return Ok(None); } let revision = self.next_revision()?; for id in visible_settled { self.snapshot.statuses.remove(&id); } self.snapshot.revision = revision; Ok(Some(self.snapshot())) } fn clear_for_root_change(&mut self) -> Result, String> { if self.attempts.is_empty() && self.snapshot.statuses.is_empty() { return Ok(None); } let revision = self.next_revision()?; for receipt in self.attempts.values_mut() { receipt.terminal = true; } self.snapshot.open_attempts.clear(); self.snapshot.statuses.clear(); self.snapshot.revision = revision; Ok(Some(self.snapshot())) } fn settle( &mut self, attempt: &DownloadAttemptKey, status: Option, ) -> Result, String> { let accepts_terminal = self.attempts.get(&attempt.id).is_none_or(|current| { current.attempt_id < attempt.attempt_id || (current.attempt_id == attempt.attempt_id && !current.terminal) }); if !accepts_terminal { return Ok(None); } let revision = self.next_revision()?; self.attempts.insert( attempt.id.clone(), GameTransferAttemptReceipt { attempt_id: attempt.attempt_id, terminal: true, }, ); self.snapshot.open_attempts.remove(&attempt.id); match status { Some(status) => { self.snapshot.statuses.insert(attempt.id.clone(), status); } None => { self.snapshot.statuses.remove(&attempt.id); } } self.snapshot.revision = revision; Ok(Some(self.snapshot())) } fn is_current_open_attempt(&self, attempt: &DownloadAttemptKey) -> bool { self.attempts .get(&attempt.id) .is_some_and(|current| current.attempt_id == attempt.attempt_id && !current.terminal) } fn next_revision(&self) -> Result { self.snapshot .revision .checked_add(1) .ok_or_else(|| "game transfer status revision overflow".to_owned()) } } #[derive(Clone, Debug, serde::Serialize)] struct UiDownloadProgress { id: String, #[serde(rename = "attemptId")] attempt_id: DownloadAttemptId, downloaded_bytes: u64, total_bytes: u64, bytes_per_second: u64, active_peer_count: usize, } impl From<&DownloadProgress> for UiDownloadProgress { fn from(progress: &DownloadProgress) -> Self { Self { id: progress.attempt.id.clone(), attempt_id: progress.attempt.attempt_id, downloaded_bytes: progress.downloaded_bytes, total_bytes: progress.total_bytes, bytes_per_second: progress.bytes_per_second, active_peer_count: progress.active_peer_count, } } } impl IdentityDiagnosticSnapshot { const INITIAL: Self = Self { revision: 1, diagnostic: None, }; } enum SharingSnapshotMutation { Begin { target: bool, }, Commit { enabled: bool, phase: Option, persistence_problem: Option, }, } enum UiStateCommand { MutateSharing { mutation: SharingSnapshotMutation, reply: oneshot::Sender>, }, SetIdentityDiagnostic { diagnostic: Option, reply: oneshot::Sender>, }, FencePeerEvents { reply: oneshot::Sender>, }, ResetGameTransferStatus { reply: oneshot::Sender>, }, } #[derive(Clone)] struct UiStateTx(UnboundedSender); struct InstallationIdentity { identity: Arc, durability: PeerIdentityDurability, } fn retain_installation_identity( slot: &mut Option>, observed_identity: Arc, observed_durability: PeerIdentityDurability, ) -> PeerIdentityDurability { if slot.is_none() { *slot = Some(InstallationIdentity { identity: observed_identity, durability: observed_durability, }); } slot.as_ref() .map_or(observed_durability, |cached| cached.durability) } /// Tauri-managed runtime state shared by commands and setup tasks. #[derive(Default)] struct LanSpreadState { peer_ctrl: Arc>>>, peer_runtime: Arc>>, local_peer_id: Arc>>, /// Exact installation identity retained for the entire application /// process. In particular, an ephemeral identity is reused across forced /// runtime restarts and changes only when the application itself restarts. installation_identity: Arc>>, games: Arc>, active_operations: Arc>>, games_folder: Arc>, peer_game_db: Arc>, /// Immutable catalog authority loaded before setup admits commands or /// starts application-owned background work. catalog_bundle: OnceLock>, unpack_logs: Arc>>, state_dir: OnceLock, main_log_sink: OnceLock, active_outbound_transfers: OutboundTransfers, outbound_transfer_emit: Arc>, /// Latest incompatibility diagnostic for the current peer-runtime /// generation. Frontends query this after registering their listener so a /// startup event cannot be lost. protocol_mismatch: Arc>, /// Backend-owned, revisioned sharing state. Setup installs revision one /// before commands are admitted; the single peer-event loop is its only /// writer afterward. local_network_sharing: OnceLock>, /// Redacted installation-identity diagnostic, also revisioned so a /// listener-first frontend query cannot lose runtime-startup publication. identity_diagnostic: OnceLock>, /// Catalog-bounded transfer UI state. Peer events and root-reset commands /// are serialized through the one peer-event loop; attempt receipts remain /// backend-only after terminal outcomes so stale events cannot affect a /// successor attempt. game_transfer_status: Arc>, background_tasks: AppTaskScope, app_invokes: AppInvokeScope, application_shutdown_started: AtomicBool, /// Cancellation controls for lexically owned unrar process workers. /// Workers themselves synchronously join their child and pipe readers. active_unrar_children: Arc>, } /// Live unrar process-worker controls 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 registry /// deliberately owns only weak references: the unpack future is the lexical /// owner responsible for killing on drop and draining the event stream before a /// normal return. The shutdown latch closes the spawn/exit-sweep race by killing /// late registrations immediately. #[derive(Default)] struct UnrarChildRegistry { shutting_down: bool, children: HashMap>, } struct UnrarWorkerControl { cancel_token: CancellationToken, } /// 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(PeerEventSender); #[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, active_operations: Vec, transfer_status: GameTransferStatusSnapshot, } #[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, 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, } #[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 UNRAR_LOG_CAPTURE_LIMIT: usize = 1024 * 1024; 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, cancel_token: CancellationToken, ) -> UnpackFuture<'a> { Box::pin(async move { if cancel_token.is_cancelled() { bail!("unrar extraction for {} was cancelled", archive.display()); } let app_handle = self.app_handle.clone(); let program = resolve_unrar_sidecar_program(&app_handle)?; do_unrar(&app_handle, &program, archive, dest, cancel_token).await }) } } #[tauri::command] async fn get_unpack_logs( state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result> { let _app_invoke = enter_app_invoke(state.inner())?; 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 { let _app_invoke = enter_app_invoke(state.inner())?; 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<()> { let _app_invoke = enter_app_invoke(state.inner())?; 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 get_protocol_mismatch( state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { let _app_invoke = enter_app_invoke(state.inner())?; Ok(current_protocol_mismatch(state.inner()).await) } async fn current_protocol_mismatch(state: &LanSpreadState) -> ProtocolMismatchSnapshot { *state.protocol_mismatch.read().await } async fn record_protocol_mismatch( state: &LanSpreadState, mismatch: ProtocolMismatch, ) -> ProtocolMismatchSnapshot { let mut diagnostic = state.protocol_mismatch.write().await; diagnostic.revision = diagnostic.revision.saturating_add(1); diagnostic.mismatch = Some(mismatch); *diagnostic } async fn clear_protocol_mismatch(state: &LanSpreadState) -> ProtocolMismatchSnapshot { let mut diagnostic = state.protocol_mismatch.write().await; diagnostic.revision = diagnostic.revision.saturating_add(1); diagnostic.mismatch = None; *diagnostic } fn local_network_sharing_watch( state: &LanSpreadState, ) -> Result<&watch::Sender, String> { state .local_network_sharing .get() .ok_or_else(|| "Local network sharing state is not initialized".to_owned()) } fn current_local_network_sharing( state: &LanSpreadState, ) -> Result { let snapshot = *local_network_sharing_watch(state)?.borrow(); Ok(snapshot) } #[tauri::command] async fn get_local_network_sharing( state: tauri::State<'_, LanSpreadState>, ) -> Result { let _app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?; current_local_network_sharing(state.inner()) } fn current_identity_diagnostic( state: &LanSpreadState, ) -> Result { let diagnostic = state .identity_diagnostic .get() .ok_or_else(|| "identity diagnostic state is not initialized".to_owned())?; let snapshot = *diagnostic.borrow(); Ok(snapshot) } #[tauri::command] async fn get_identity_diagnostic( state: tauri::State<'_, LanSpreadState>, ) -> Result { let _app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?; current_identity_diagnostic(state.inner()) } async fn mutate_local_network_sharing_snapshot( app_handle: &AppHandle, mutation: SharingSnapshotMutation, ) -> Result { let (reply, result) = oneshot::channel(); app_handle .state::() .inner() .0 .send(UiStateCommand::MutateSharing { mutation, reply }) .map_err(|error| format!("Local network sharing state loop is unavailable: {error}"))?; result .await .map_err(|error| format!("Local network sharing state reply was dropped: {error}"))? } async fn publish_identity_diagnostic( app_handle: &AppHandle, durability: PeerIdentityDurability, ) -> Result { let diagnostic = identity_diagnostic_for_durability(durability); let (reply, result) = oneshot::channel(); app_handle .state::() .inner() .0 .send(UiStateCommand::SetIdentityDiagnostic { diagnostic, reply }) .map_err(|error| format!("identity diagnostic state loop is unavailable: {error}"))?; result .await .map_err(|error| format!("identity diagnostic state reply was dropped: {error}"))? } const fn identity_diagnostic_for_durability( durability: PeerIdentityDurability, ) -> Option { match durability { PeerIdentityDurability::Ephemeral => Some(IdentityDiagnostic::Ephemeral), PeerIdentityDurability::Persistent | PeerIdentityDurability::CallerProvided => None, } } async fn fence_peer_events(app_handle: &AppHandle) -> Result { let (reply, result) = oneshot::channel(); app_handle .state::() .inner() .0 .send(UiStateCommand::FencePeerEvents { reply }) .map_err(|error| format!("peer-event fence loop is unavailable: {error}"))?; result .await .map_err(|error| format!("peer-event fence reply was dropped: {error}"))? } async fn reset_game_transfer_status_for_root( app_handle: &AppHandle, ) -> Result { let (reply, result) = oneshot::channel(); app_handle .state::() .inner() .0 .send(UiStateCommand::ResetGameTransferStatus { reply }) .map_err(|error| format!("game transfer status loop is unavailable: {error}"))?; result .await .map_err(|error| format!("game transfer status reset reply was dropped: {error}"))? } async fn fence_local_network_sharing_phase( app_handle: &AppHandle, expected: LocalNetworkSharingPhase, ) -> Result { let snapshot = fence_peer_events(app_handle).await?; if snapshot.phase != expected { return Err(format!( "Local network sharing settled as {:?}, expected {expected:?}", snapshot.phase )); } Ok(snapshot) } #[tauri::command] async fn request_call_to_play_view( state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result> { let _app_invoke = enter_app_invoke(state.inner())?; 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::GetCallToPlayView { reply: None }) .is_err() { return Ok(None); } Ok(state.inner().local_peer_id.read().await.clone()) } #[tauri::command] async fn publish_call_to_play( intent: CallToPlayLocalIntent, display_name: String, state: tauri::State<'_, LanSpreadState>, ) -> Result { let _app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?; // The same lifecycle turn orders sharing Begin/Commit. Holding it from // the admission check through the core reply means a publication either // settles wholly before an off transition begins or observes the closed // revisioned state afterward; it cannot slip between those boundaries. let _serial_peer_lifecycle = state.app_invokes.serialize_peer_startup().await; if !current_local_network_sharing(state.inner())?.admits_network_actions() { return Err("Local network sharing is not enabled".to_owned()); } 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 Err("peer system is not initialized".to_owned()); }; let (reply, result) = oneshot::channel(); peer_ctrl .send(PeerCommand::ApplyCallToPlayIntent { intent, display_name: sanitize_username(&display_name), reply, }) .map_err(|err| err.to_string())?; result.await.map_err(|err| err.to_string())? } #[tauri::command] async fn set_call_to_play_display_name( display_name: String, state: tauri::State<'_, LanSpreadState>, ) -> Result { let _app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?; let peer_ctrl = state.inner().peer_ctrl.read().await.clone(); let Some(peer_ctrl) = peer_ctrl else { return Err("peer system is not initialized".to_owned()); }; let (reply, result) = oneshot::channel(); peer_ctrl .send(PeerCommand::SetCallToPlayDisplayName { display_name: sanitize_username(&display_name), reply, }) .map_err(|error| error.to_string())?; result.await.map_err(|error| error.to_string())? } #[tauri::command] async fn install_game( id: String, language: String, username: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { let _app_invoke = enter_app_invoke(state.inner())?; 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::DownloadGameFiles { id: 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) } fn catalog_supports_streamed_install(state: &LanSpreadState, id: &str) -> Result { let catalog = state .catalog_bundle .get() .cloned() .ok_or_else(|| "catalog authority is not initialized".to_string())?; let id = id.to_string(); scoped_blocking(move || { catalog .manifest(&id) .map(|manifest| manifest.supports_streamed_install()) .map_err(|error| format!("failed to load catalog manifest for {id}: {error}")) }) } /// Lazily resolves Stream Install support from the selected game's exact /// catalog manifest. The frontend calls this only while the detail modal owns /// that game selection. #[tauri::command] async fn supports_streamed_install( id: String, state: tauri::State<'_, LanSpreadState>, ) -> Result { let _app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?; catalog_supports_streamed_install(state.inner(), &id) } #[tauri::command] async fn stream_install_game( id: String, language: String, username: String, state: tauri::State<'_, LanSpreadState>, ) -> Result { let _app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?; 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); } if !catalog_supports_streamed_install(state.inner(), &id)? { log::warn!("Ignoring streamed install request for unsupported game: {id}"); 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); }; let settings = StreamInstallSettings::sanitized(Some(&username), Some(&language), Some(&username)); if let Err(e) = peer_ctrl.send(PeerCommand::StreamInstallGame { id, settings }) { 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 { let _app_invoke = enter_app_invoke(state.inner())?; 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::DownloadGameFiles { 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 { let _app_invoke = enter_app_invoke(state.inner())?; 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 { let _app_invoke = enter_app_invoke(state.inner())?; 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 { let _app_invoke = enter_app_invoke(state.inner())?; 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 { let _app_invoke = enter_app_invoke(state.inner())?; 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 { 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(), } } /// Characters that `cmd.exe` interprets even when the argument was quoted, /// because batch scripts expand `%~4` textually into their own command lines. /// `"` would end the quoted argument and `%` starts variable expansion; the /// rest are command separators, redirection and the escape character. const CMD_UNSAFE_USERNAME_CHARS: [char; 7] = ['"', '%', '&', '|', '<', '>', '^']; #[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() && !CMD_UNSAFE_USERNAME_CHARS.contains(c)) .take(MAX_USERNAME_CHARS) .collect::(); if cleaned.is_empty() { DEFAULT_USERNAME.to_string() } else { cleaned } } #[tauri::command] async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result { let _app_invoke = enter_app_invoke(state.inner())?; 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, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { use base64::Engine; let _app_invoke = enter_app_invoke(state.inner())?; if !is_single_component_game_id(&game_id) { log::warn!("Ignoring thumbnail request for invalid game id: {game_id}"); return Err( std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid game id").into(), ); } let resource_path = app_handle.path().resolve( format!("assets/{game_id}.jpg"), tauri::path::BaseDirectory::Resource, )?; let image_data = scoped_blocking(|| 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(any(test, target_os = "windows"))] fn setup_process_exit_succeeded(exit_code: u32) -> bool { exit_code == 0 } #[derive(Debug, PartialEq, Eq)] #[cfg(any(test, target_os = "windows"))] enum SetupLaunchOutcome { Owned(Process), SettledWithoutProcess, ContractViolation, } /// Converts the documented `ShellExecuteExW` postcondition into an explicit /// ownership boundary. With `SEE_MASK_NOCLOSEPROCESS`, a null `hProcess` means /// that no process was launched; every returned process handle must instead be /// moved immediately into the caller's process owner. A non-null invalid value /// violates that Win32 contract and is kept out of the owner entirely. #[cfg(any(test, target_os = "windows"))] fn setup_launch_outcome( process: Process, process_is_null: bool, process_is_invalid: bool, ) -> SetupLaunchOutcome { if process_is_null { SetupLaunchOutcome::SettledWithoutProcess } else if process_is_invalid { SetupLaunchOutcome::ContractViolation } else { SetupLaunchOutcome::Owned(process) } } #[derive(Debug, PartialEq, Eq)] #[cfg(any(test, target_os = "windows"))] enum SetupProcessObservation { Settled, SettledWithError(String), NotSettled(String), } /// Drives an owned setup process to a proven settled state after a lifecycle /// error. This function deliberately has no fallible early return: termination /// and observation failures keep the caller inside the ownership boundary. #[cfg(any(test, target_os = "windows"))] fn settle_setup_after_wait_error( initial_error: String, mut terminate: Terminate, mut observe: Observe, mut retry: Retry, ) -> String where Terminate: FnMut() -> Result<(), String>, Observe: FnMut() -> SetupProcessObservation, Retry: FnMut(), { let mut attempts = 0_u64; let mut first_termination_error = None; let mut first_observation_error = None; loop { attempts = attempts.saturating_add(1); if let Err(error) = terminate() && first_termination_error.is_none() { first_termination_error = Some(error); } match observe() { SetupProcessObservation::Settled => break, SetupProcessObservation::SettledWithError(error) => { if first_observation_error.is_none() { first_observation_error = Some(error); } break; } SetupProcessObservation::NotSettled(error) => { if first_observation_error.is_none() { first_observation_error = Some(error); } retry(); } } } let termination = first_termination_error.map_or_else( || "termination attempts succeeded".to_string(), |error| format!("a termination attempt failed ({error})"), ); let observation = first_observation_error.map_or_else( || "settlement observation succeeded".to_string(), |error| format!("a settlement observation failed ({error})"), ); format!( "{initial_error}; process settlement required {attempts} attempt(s); {termination}; \ {observation}; elevated setup is now settled" ) } #[cfg(target_os = "windows")] fn run_as_admin_and_wait( file: &std::ffi::OsStr, params: &std::ffi::OsStr, dir: &std::ffi::OsStr, show_cmd: windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD, ) -> Result<(), String> { use std::{ffi::OsStr, os::windows::ffi::OsStrExt}; use windows::{ Win32::{ Foundation::{CloseHandle, HANDLE, WAIT_EVENT, WAIT_FAILED, WAIT_OBJECT_0}, System::Threading::{ GetExitCodeProcess, INFINITE, TerminateProcess, WaitForSingleObject, }, UI::Shell::{ SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW, }, }, core::PCWSTR, }; const STILL_ACTIVE_EXIT_CODE: u32 = 259; fn wait_failure_message(wait_result: WAIT_EVENT) -> String { if wait_result == WAIT_FAILED { let error = windows::core::Error::from_win32(); format!("WaitForSingleObject failed: {error}") } else { format!("WaitForSingleObject returned unexpected status {wait_result:?}") } } fn observe_process_settlement(handle: HANDLE) -> SetupProcessObservation { let wait_result = unsafe { WaitForSingleObject(handle, INFINITE) }; if wait_result == WAIT_OBJECT_0 { return SetupProcessObservation::Settled; } let wait_error = wait_failure_message(wait_result); let mut exit_code = STILL_ACTIVE_EXIT_CODE; match unsafe { GetExitCodeProcess(handle, &raw mut exit_code) } { Ok(()) if exit_code != STILL_ACTIVE_EXIT_CODE => { SetupProcessObservation::SettledWithError(format!( "{wait_error}; exit-status query proved termination with status {exit_code}" )) } Ok(()) => SetupProcessObservation::NotSettled(format!( "{wait_error}; exit-status query still reports an active process" )), Err(error) => SetupProcessObservation::NotSettled(format!( "{wait_error}; exit-status query also failed: {error}" )), } } struct OwnedProcessHandle { handle: HANDLE, settled: bool, } impl OwnedProcessHandle { fn new(handle: HANDLE) -> Self { Self { handle, settled: false, } } fn force_settle_after_error(&mut self, initial_error: String) -> String { let handle = self.handle; let report = settle_setup_after_wait_error( initial_error, || { unsafe { TerminateProcess(handle, 1) } .map_err(|error| format!("failed to terminate elevated setup: {error}")) }, || observe_process_settlement(handle), || std::thread::sleep(Duration::from_millis(10)), ); self.settled = true; report } fn wait_for_exit(&mut self) -> Result { let wait_result = unsafe { WaitForSingleObject(self.handle, INFINITE) }; if wait_result != WAIT_OBJECT_0 { let initial_error = wait_failure_message(wait_result); return Err(self.force_settle_after_error(initial_error)); } self.settled = true; let mut exit_code = 0; unsafe { GetExitCodeProcess(self.handle, &raw mut exit_code) } .map_err(|error| format!("failed to read elevated setup exit status: {error}"))?; Ok(exit_code) } } impl Drop for OwnedProcessHandle { fn drop(&mut self) { if !self.settled { let report = self.force_settle_after_error( "elevated setup handle reached Drop before process settlement".to_string(), ); log::error!("{report}"); } if let Err(err) = unsafe { CloseHandle(self.handle) } { log::warn!("Failed to close elevated setup process handle: {err}"); } } } let file_wide = file.encode_wide().chain(Some(0)).collect::>(); let params_wide = params.encode_wide().chain(Some(0)).collect::>(); let dir_wide = dir.encode_wide().chain(Some(0)).collect::>(); let runas_wide = OsStr::new("runas") .encode_wide() .chain(Some(0)) .collect::>(); let mut execute_info = SHELLEXECUTEINFOW { cbSize: u32::try_from(std::mem::size_of::()) .map_err(|err| format!("invalid ShellExecuteExW structure size: {err}"))?, // This invoke runs on a scoped blocking worker without a message loop. // `NOASYNC` keeps shell activation inside this call; `NOCLOSEPROCESS` // makes any newly launched process ours to join and close. fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC, lpVerb: PCWSTR::from_raw(runas_wide.as_ptr()), lpFile: PCWSTR::from_raw(file_wide.as_ptr()), lpParameters: PCWSTR::from_raw(params_wide.as_ptr()), lpDirectory: PCWSTR::from_raw(dir_wide.as_ptr()), nShow: show_cmd.0, ..Default::default() }; unsafe { ShellExecuteExW(&raw mut execute_info) } .map_err(|err| format!("failed to launch elevated setup: {err}"))?; // Do not use `HANDLE::is_invalid` here: it folds null and -1 together, // while ShellExecuteExW documents null specifically as proof that no // process was launched. Without `SEE_MASK_INVOKEIDLIST`, every documented // non-null, valid result is the newly launched process handle and is owned // here. INVALID_HANDLE_VALUE is outside that API contract and must never // reach the owner, whose Drop implementation requires a waitable handle. let process_handle = execute_info.hProcess; let mut process = match setup_launch_outcome( process_handle, process_handle.0.is_null(), process_handle.is_invalid(), ) { SetupLaunchOutcome::Owned(handle) => OwnedProcessHandle::new(handle), SetupLaunchOutcome::SettledWithoutProcess => { return Err("elevated setup completed without launching a process".to_string()); } SetupLaunchOutcome::ContractViolation => { return Err( "ShellExecuteExW succeeded but returned INVALID_HANDLE_VALUE; no process handle \ can be owned" .to_string(), ); } }; let exit_code = process.wait_for_exit()?; if !setup_process_exit_succeeded(exit_code) { return Err(format!("elevated setup exited with status {exit_code}")); } Ok(()) } #[cfg(target_os = "windows")] fn catalog_manifest_for_elevated_launch( state: &LanSpreadState, id: &str, ) -> Result, String> { let catalog = state .catalog_bundle .get() .cloned() .ok_or_else(|| "catalog authority is not initialized".to_owned())?; let game_id = id.to_owned(); let error_id = game_id.clone(); scoped_blocking(move || catalog.manifest(&game_id)) .map_err(|error| format!("failed to load catalog manifest for {error_id}: {error:#}")) } #[cfg(target_os = "windows")] fn elevated_worker_program() -> Result<(PathBuf, PathBuf), String> { let executable = std::env::current_exe() .map_err(|error| format!("failed to resolve launcher executable: {error}"))?; let working_directory = executable .parent() .ok_or_else(|| { format!( "launcher executable has no parent directory: {}", executable.display() ) })? .to_path_buf(); Ok((executable, working_directory)) } #[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 _serial_game_directory = state.inner().app_invokes.serialize_peer_startup().await; let settings = launch_settings(&language, &username); let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone()); if state .inner() .active_operations .read() .await .contains_key(&id) { log::warn!("Ignoring run request while a game operation is active: {id}"); return Ok(()); } 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 installed = state .inner() .games .read() .await .get_game_by_id(&id) .is_some_and(|game| game.installed); if !installed { log::warn!("Ignoring run request for game without an installed catalog state: {id}"); return Ok(()); } 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 manifest = match catalog_manifest_for_elevated_launch(state.inner(), &id) { Ok(manifest) => manifest, Err(error) => { log::error!("Ignoring run request without catalog authority: {error}"); return Ok(()); } }; let setup_authority = match windows_launch::catalog_script_authority( &manifest, windows_launch::ElevatedScriptRole::Setup, ) { Ok(authority) => authority, Err(error) => { log::error!("Ignoring run request with invalid setup authority: {error}"); return Ok(()); } }; let game_authority = match windows_launch::catalog_script_authority( &manifest, windows_launch::ElevatedScriptRole::Game, ) { Ok(authority) => authority, Err(error) => { log::error!("Ignoring run request with invalid game authority: {error}"); return Ok(()); } }; let (worker, worker_directory) = match elevated_worker_program() { Ok(worker) => worker, Err(error) => { log::error!("Cannot start elevated launch worker: {error}"); return Ok(()); } }; let setup_done_file = match lanspread_peer::setup_done_path(&state_dir, &id) { Ok(path) => path, Err(error) => { log::warn!("Ignoring run request for unsafe game id {id}: {error}"); return Ok(()); } }; if !setup_done_file.exists() && let Some(authority) = setup_authority { let setup_params = match windows_launch::worker_parameters( windows_launch::ElevatedScriptRole::Setup, &games_folder, &id, &settings.language, &settings.username, authority, ) { Ok(parameters) => parameters, Err(error) => { log::error!("failed to prepare {GAME_SETUP_SCRIPT}: {error}"); return Ok(()); } }; if let Err(err) = scoped_blocking(|| { run_as_admin_and_wait( worker.as_os_str(), std::ffi::OsStr::new(&setup_params), worker_directory.as_os_str(), windows::Win32::UI::WindowsAndMessaging::SW_HIDE, ) }) { log::error!("failed to complete {GAME_SETUP_SCRIPT}: {err}"); 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); if let Some(authority) = game_authority { let game_params = match windows_launch::worker_parameters( windows_launch::ElevatedScriptRole::Game, &games_folder, &id, &settings.language, &settings.username, authority, ) { Ok(parameters) => parameters, Err(error) => { log::error!("failed to prepare {GAME_START_SCRIPT}: {error}"); return Ok(()); } }; // The elevated worker transfers the verified script and path locks to // cmd.exe before it reports success. The game remains free to outlive // both the worker and this launcher. if let Err(error) = scoped_blocking(|| { run_as_admin_and_wait( worker.as_os_str(), std::ffi::OsStr::new(&game_params), worker_directory.as_os_str(), windows::Win32::UI::WindowsAndMessaging::SW_HIDE, ) }) { log::error!("failed to start {GAME_START_SCRIPT}: {error}"); } } 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))] 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), ) { 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<()> { let _app_invoke = enter_app_invoke(state.inner())?; #[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 { if !is_single_component_game_id(&id) { log::warn!("Ignoring server start request for invalid game id: {id}"); return Ok(false); } let _serial_game_directory = state.inner().app_invokes.serialize_peer_startup().await; let settings = launch_settings(&language, &username); let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone()); if state .inner() .active_operations .read() .await .contains_key(&id) { log::warn!("Ignoring server start request while a game operation is active: {id}"); return Ok(false); } 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()); let installed = state .inner() .games .read() .await .get_game_by_id(&id) .is_some_and(|game| game.installed); if !installed { log::warn!("Ignoring server start request without an installed catalog state: {id}"); return Ok(false); } let manifest = match catalog_manifest_for_elevated_launch(state.inner(), &id) { Ok(manifest) => manifest, Err(error) => { log::error!("Ignoring server start request without catalog authority: {error}"); return Ok(false); } }; let authority = match windows_launch::catalog_script_authority( &manifest, windows_launch::ElevatedScriptRole::Server, ) { Ok(Some(authority)) => authority, Ok(None) => { log::warn!("Catalog does not provide {SERVER_START_SCRIPT} for {id}"); return Ok(false); } Err(error) => { log::error!("Ignoring server start request with invalid authority: {error}"); return Ok(false); } }; let (worker, worker_directory) = match elevated_worker_program() { Ok(worker) => worker, Err(error) => { log::error!("Cannot start elevated launch worker: {error}"); return Ok(false); } }; let server_params = match windows_launch::worker_parameters( windows_launch::ElevatedScriptRole::Server, &games_folder, &id, &settings.language, &settings.username, authority, ) { Ok(parameters) => parameters, Err(error) => { log::error!("failed to prepare {SERVER_START_SCRIPT}: {error}"); return Ok(false); } }; if !game_path.is_dir() { log::warn!( "Game directory disappeared before server launch: {}", game_path.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); // The worker transfers its verification locks to the hosted command shell, // which remains detached from, and may outlive, the launcher. let result = scoped_blocking(|| { run_as_admin_and_wait( worker.as_os_str(), std::ffi::OsStr::new(&server_params), worker_directory.as_os_str(), windows::Win32::UI::WindowsAndMessaging::SW_HIDE, ) }); if let Err(error) = &result { log::error!("failed to start {SERVER_START_SCRIPT}: {error}"); } Ok(result.is_ok()) } #[tauri::command] async fn start_server( id: String, language: String, username: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { let _app_invoke = enter_app_invoke(state.inner())?; #[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) } } 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::>(); 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_view( game_db: &mut GameDB, remote_view: &RemoteLibraryView, catalog_bundle: &CatalogBundle, ) { // Remote state supplies only exact content identity and counts. All display // metadata stays anchored to the bundled game.db. for game in game_db.games.values_mut() { game.peer_count = 0; } for availability in &remote_view.games { let Some(identity) = catalog_bundle.content_identity(&availability.game_id) else { log::debug!( "Ignoring availability for unknown catalog game {}", availability.game_id ); continue; }; if identity.content_id != availability.content_id { log::debug!( "Ignoring non-catalog content {} ({})", availability.game_id, availability.content_id ); continue; } if let Some(existing) = game_db.get_mut_game_by_id(&availability.game_id) { existing.peer_count = availability.peer_count; } else { log::debug!( "Peer advertised unknown game {id}; ignoring because game.db is ground truth", id = availability.game_id ); } } } fn emit_game_transfer_status_snapshot( app_handle: &AppHandle, snapshot: &GameTransferStatusSnapshot, ) { if let Err(error) = app_handle.emit("game-transfer-status-updated", Some(snapshot.clone())) { log::error!("Failed to emit game-transfer-status-updated event: {error}"); } } async fn mutate_catalog_game_transfer_status( app_handle: &AppHandle, game_id: &str, mutation: F, ) -> Result, String> where F: FnOnce(&mut GameTransferStatusStore) -> Result, String>, { let state = app_handle.state::(); let Some(catalog_bundle) = state.catalog_bundle.get() else { return Err("bundled catalog authority is not initialized".to_owned()); }; if !catalog_bundle.catalog().contains(game_id) { log::warn!("Ignoring transfer status for unknown catalog game {game_id}"); return Ok(None); } let snapshot = { let mut store = state.game_transfer_status.write().await; mutation(&mut store)? }; if let Some(snapshot) = &snapshot { emit_game_transfer_status_snapshot(app_handle, snapshot); } Ok(snapshot) } async fn accepts_game_transfer_progress( app_handle: &AppHandle, progress: &DownloadProgress, ) -> bool { let state = app_handle.state::(); let Some(catalog_bundle) = state.catalog_bundle.get() else { log::error!("Ignoring download progress before catalog authority initialization"); return false; }; if !catalog_bundle.catalog().contains(&progress.attempt.id) { log::warn!( "Ignoring download progress for unknown catalog game {}", progress.attempt.id ); return false; } state .game_transfer_status .read() .await .accepts_progress(progress) } async fn clear_settled_game_transfer_statuses_for_local_generation( app_handle: &AppHandle, ) -> Result { let state = app_handle.state::(); let (changed, current) = { let mut store = state.game_transfer_status.write().await; let changed = store.clear_settled_for_local_generation()?; let current = changed.clone().unwrap_or_else(|| store.snapshot()); (changed, current) }; if let Some(snapshot) = &changed { emit_game_transfer_status_snapshot(app_handle, snapshot); } Ok(current) } async fn emit_games_list(app_handle: &AppHandle) { let state = app_handle.state::(); 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: game.peer_count, game, } }) .collect::>(); 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 transfer_status = state.game_transfer_status.read().await.snapshot(); let payload = GamesListPayload { games: games_to_emit, active_operations, transfer_status, }; 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, ) -> Vec { let mut snapshot = active_operations .iter() .map(|(id, operation)| UiActiveOperation { id: id.clone(), operation: *operation, }) .collect::>(); snapshot.sort_by(|left, right| left.id.cmp(&right.id)); snapshot } fn reconcile_active_operations( active_operations: &mut HashMap, 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] async fn game_directory_exists( path: String, state: tauri::State<'_, LanSpreadState>, ) -> tauri::Result { let _app_invoke = enter_app_invoke(state.inner())?; Ok(scoped_blocking(|| PathBuf::from(path).is_dir())) } fn persist_local_network_sharing_policy( state: &LanSpreadState, enabled: bool, ) -> Result<(), String> { let state_dir = state .state_dir .get() .ok_or_else(|| "app state directory is not initialized".to_owned())?; let policy_path = state_dir.join(sharing_policy::POLICY_FILE_NAME); scoped_blocking(|| sharing_policy::save(&policy_path, enabled)) .map_err(|error| error.to_string()) } #[derive(Clone, Copy)] struct NetworkAdmissionClosed; struct DisabledRuntimeTransition { command_result: Result, persistence: Result<(), String>, force_stopped: bool, } fn network_admission_closed_after( snapshot: LocalNetworkSharingSnapshot, baseline_revision: u64, ) -> Option { (snapshot.revision > baseline_revision && matches!( snapshot.phase, LocalNetworkSharingPhase::Disabling | LocalNetworkSharingPhase::Disabled )) .then_some(NetworkAdmissionClosed) } fn after_network_admission_closed( _closed: NetworkAdmissionClosed, operation: impl FnOnce() -> R, ) -> R { operation() } fn persist_disabled_runtime_policy( state: &LanSpreadState, closed: NetworkAdmissionClosed, ) -> Result<(), String> { after_network_admission_closed(closed, || { persist_local_network_sharing_policy(state, false) }) } async fn disable_runtime_then_persist( baseline_revision: u64, mut changes: watch::Receiver, command: C, force_stop: S, persist: P, ) -> DisabledRuntimeTransition where C: std::future::Future>, S: FnOnce() -> SF, SF: std::future::Future, P: FnOnce(NetworkAdmissionClosed) -> Result<(), String>, { tokio::pin!(command); let mut command_result = None; let admission_closed = loop { tokio::select! { result = &mut command => { let closed = matches!(&result, Ok(false)); command_result = Some(result); break closed; } watch_update = changes.changed() => { if watch_update.is_err() { break false; } let snapshot = *changes.borrow_and_update(); if network_admission_closed_after(snapshot, baseline_revision).is_some() { break true; } } } }; let force_stopped = !admission_closed; if force_stopped { force_stop().await; } // Either the current core generation published its synchronous admission // closure or the complete runtime has now stopped. This helper is the only // production path that begins disabled-policy I/O for a live-runtime // transition, which keeps that privacy ordering directly testable. let persistence = persist(NetworkAdmissionClosed); let command_result = match command_result { Some(result) => result, None => command.await, }; DisabledRuntimeTransition { command_result, persistence, force_stopped, } } async fn set_core_local_network_sharing( peer_ctrl: &UnboundedSender, enabled: bool, ) -> Result { let (reply, result) = oneshot::channel(); peer_ctrl .send(PeerCommand::SetLocalNetworkSharing { enabled, reply }) .map_err(|error| format!("failed to send Local network sharing command: {error}"))?; result .await .map_err(|error| format!("Local network sharing reply was dropped: {error}"))? } async fn force_stop_peer_runtime(state: &LanSpreadState) { *state.peer_ctrl.write().await = None; let handle = { state.peer_runtime.write().await.take() }; if let Some(mut handle) = handle { handle.shutdown(); handle.wait_stopped().await; } } async fn force_stop_peer_runtime_and_fence_disabled(app_handle: &AppHandle) -> Result<(), String> { let state = app_handle.state::(); force_stop_peer_runtime(state.inner()).await; fence_local_network_sharing_phase(app_handle, LocalNetworkSharingPhase::Disabled).await?; Ok(()) } async fn finish_sharing_snapshot( app_handle: &AppHandle, enabled: bool, phase: Option, persistence_problem: Option, ) -> Result { mutate_local_network_sharing_snapshot( app_handle, SharingSnapshotMutation::Commit { enabled, phase, persistence_problem, }, ) .await } async fn set_local_network_sharing_without_runtime( app_handle: &AppHandle, app_invoke: &AppInvokeGuard, requested: bool, before: LocalNetworkSharingSnapshot, ) -> Result { let state = app_handle.state::(); let expected_phase = if requested { LocalNetworkSharingPhase::WaitingForGameDirectory } else { LocalNetworkSharingPhase::Disabled }; if before.enabled == requested && before.pending_target.is_none() && before.phase == expected_phase && before.persistence_problem.is_none() { return Ok(before); } mutate_local_network_sharing_snapshot( app_handle, SharingSnapshotMutation::Begin { target: requested }, ) .await?; if requested && !state.games_folder.read().await.is_empty() { return restart_enabled_peer_from_retained_game_directory(app_handle, app_invoke).await; } let persistence = persist_local_network_sharing_policy(state.inner(), requested); match (requested, persistence) { (true, Ok(())) => { finish_sharing_snapshot(app_handle, true, Some(expected_phase), None).await } (false, Ok(())) => { finish_sharing_snapshot(app_handle, false, Some(expected_phase), None).await } (true, Err(error)) => { log::error!("Failed to save enabled Local network sharing policy: {error}"); // The atomic replacement may have completed before a later // directory-sync error. Repair false so an unacknowledged opt-in // cannot silently become enabled on the next launch. let repair_problem = persist_local_network_sharing_policy(state.inner(), false) .err() .map(|repair_error| { log::error!( "Failed to repair disabled Local network sharing policy: {repair_error}" ); SharingPersistenceProblem::Save }); finish_sharing_snapshot( app_handle, false, Some(LocalNetworkSharingPhase::Disabled), repair_problem, ) .await?; Err("Local network sharing setting could not be saved".to_owned()) } (false, Err(error)) => { log::error!("Failed to save disabled Local network sharing policy: {error}"); // Privacy is fail-closed for this process even when the disk update // cannot be proven. A later game-directory restore therefore starts // the local-only core; the UI explicitly warns that restart state is // uncertain. finish_sharing_snapshot( app_handle, false, Some(LocalNetworkSharingPhase::Disabled), Some(SharingPersistenceProblem::Save), ) .await?; Err("Local network sharing setting could not be saved".to_owned()) } } } async fn restart_enabled_peer_from_retained_game_directory( app_handle: &AppHandle, app_invoke: &AppInvokeGuard, ) -> Result { let state = app_handle.state::(); let retained = state.games_folder.read().await.clone(); if let Err(error) = ensure_peer_started(app_handle, Path::new(&retained), app_invoke, false).await { return compensate_failed_enabled_restart(app_handle, error).await; } let peer_ctrl = state.peer_ctrl.read().await.clone(); let Some(peer_ctrl) = peer_ctrl else { return compensate_failed_enabled_restart( app_handle, "peer restart did not publish its control channel".to_owned(), ) .await; }; let enable_result = set_core_local_network_sharing(&peer_ctrl, true).await; let fenced = fence_peer_events(app_handle).await; match (&enable_result, &fenced) { (Ok(true), Ok(snapshot)) if snapshot.phase == LocalNetworkSharingPhase::Enabled => { match persist_local_network_sharing_policy(state.inner(), true) { Ok(()) => { // The peer-event loop owns effective phase. In particular, // an automatic Disabled consumed while the save blocks must // not be resurrected by this policy commit. return finish_sharing_snapshot(app_handle, true, None, None).await; } Err(error) => { log::error!( "Failed to save enabled Local network sharing policy after restart: {error}" ); return compensate_failed_enabled_restart( app_handle, "Local network sharing setting could not be saved".to_owned(), ) .await; } } } (Ok(true), Ok(snapshot)) => { log::error!( "Restarted peer acknowledged enabled but settled as {:?}", snapshot.phase ); } (Ok(false), _) => log::error!("Restarted peer remained disabled after enable request"), (Err(error), _) => { log::error!("Restarted peer could not enable Local network sharing: {error}"); } (_, Err(error)) => log::error!("Restarted peer could not fence enable events: {error}"), } compensate_failed_enabled_restart( app_handle, "Local network sharing could not be enabled".to_owned(), ) .await } async fn compensate_failed_enabled_restart( app_handle: &AppHandle, cause: String, ) -> Result { let state = app_handle.state::(); force_stop_peer_runtime(state.inner()).await; if let Err(error) = fence_peer_events(app_handle).await { log::error!("Failed to fence peer events after restart compensation: {error}"); } let repair_problem = persist_local_network_sharing_policy(state.inner(), false) .err() .map(|error| { log::error!("Failed to repair disabled sharing policy after restart: {error}"); SharingPersistenceProblem::Save }); finish_sharing_snapshot( app_handle, false, Some(LocalNetworkSharingPhase::Disabled), repair_problem, ) .await?; Err(cause) } #[allow(clippy::too_many_lines)] async fn set_local_network_sharing_with_runtime( app_handle: &AppHandle, peer_ctrl: UnboundedSender, requested: bool, _before: LocalNetworkSharingSnapshot, ) -> Result { let state = app_handle.state::(); // Remove every lifecycle event queued before this serialized transition. // Begin then provides a clean revision boundary for observing the current // command's admission-closing Disabling event. let before = fence_peer_events(app_handle).await?; if before.enabled == requested && before.is_stable_at(requested) && before.persistence_problem.is_none() { // Core intentionally emits no duplicate lifecycle event for an // idempotent Set. Returning the already stable snapshot avoids waiting // forever for a revision that cannot exist. return Ok(before); } let begun = mutate_local_network_sharing_snapshot( app_handle, SharingSnapshotMutation::Begin { target: requested }, ) .await?; let effective_already_stable = before.is_stable_at(requested); if !requested { if !effective_already_stable { let transition = disable_runtime_then_persist( begun.revision, local_network_sharing_watch(state.inner())?.subscribe(), set_core_local_network_sharing(&peer_ctrl, false), || force_stop_peer_runtime(state.inner()), |closed| persist_disabled_runtime_policy(state.inner(), closed), ) .await; let force_stopped = transition.force_stopped; let persistence = transition.persistence; let result = transition.command_result; let mut fenced = fence_peer_events(app_handle).await?; if !matches!(&result, Ok(false)) || fenced.phase != LocalNetworkSharingPhase::Disabled { match &result { Ok(true) => log::error!("Peer acknowledged enabled after a disable request"), Ok(false) => log::error!( "Peer acknowledged disabled but settled as {:?}", fenced.phase ), Err(error) => { log::error!("Failed to disable Local network sharing cleanly: {error}"); } } if !force_stopped { force_stop_peer_runtime(state.inner()).await; } fenced = fence_peer_events(app_handle).await?; if fenced.phase != LocalNetworkSharingPhase::Disabled { return Err(format!( "Local network sharing stopped but settled as {:?}", fenced.phase )); } } let problem = persistence .as_ref() .err() .map(|_| SharingPersistenceProblem::Save); if let Err(error) = &persistence { log::error!("Failed to save disabled Local network sharing policy: {error}"); } let snapshot = finish_sharing_snapshot( app_handle, false, Some(LocalNetworkSharingPhase::Disabled), problem, ) .await?; return persistence.map(|()| snapshot).map_err(|_| { "Local network sharing is off, but its setting could not be saved".to_owned() }); } let persistence = persist_disabled_runtime_policy(state.inner(), NetworkAdmissionClosed); let problem = persistence .as_ref() .err() .map(|_| SharingPersistenceProblem::Save); if let Err(error) = &persistence { log::error!("Failed to save disabled Local network sharing policy: {error}"); } let snapshot = finish_sharing_snapshot( app_handle, false, Some(LocalNetworkSharingPhase::Disabled), problem, ) .await?; return persistence.map(|()| snapshot).map_err(|_| { "Local network sharing is off, but its setting could not be saved".to_owned() }); } if !effective_already_stable { let enable_result = set_core_local_network_sharing(&peer_ctrl, true).await; let fenced = fence_peer_events(app_handle).await?; match enable_result { Ok(true) if fenced.phase == LocalNetworkSharingPhase::Enabled => {} Ok(true) => { log::error!( "Peer acknowledged enabled but settled as {:?}", fenced.phase ); force_stop_peer_runtime_and_fence_disabled(app_handle).await?; finish_sharing_snapshot( app_handle, before.enabled, Some(LocalNetworkSharingPhase::Disabled), before.persistence_problem, ) .await?; return Err("Local network sharing could not be enabled".to_owned()); } Ok(false) => { log::error!("Peer acknowledged disabled after an enable request"); if fenced.phase != LocalNetworkSharingPhase::Disabled { force_stop_peer_runtime_and_fence_disabled(app_handle).await?; } finish_sharing_snapshot( app_handle, before.enabled, Some(LocalNetworkSharingPhase::Disabled), before.persistence_problem, ) .await?; return Err("Local network sharing could not be enabled".to_owned()); } Err(error) => { log::error!("Failed to enable Local network sharing: {error}"); if fenced.phase != LocalNetworkSharingPhase::Disabled { force_stop_peer_runtime_and_fence_disabled(app_handle).await?; } finish_sharing_snapshot( app_handle, before.enabled, Some(LocalNetworkSharingPhase::Disabled), before.persistence_problem, ) .await?; return Err("Local network sharing could not be enabled".to_owned()); } } } match persist_local_network_sharing_policy(state.inner(), true) { Ok(()) => finish_sharing_snapshot(app_handle, true, None, None).await, Err(error) => { log::error!("Failed to save enabled Local network sharing policy: {error}"); let disable_result = set_core_local_network_sharing(&peer_ctrl, false).await; let fenced = fence_peer_events(app_handle).await?; match disable_result { Ok(false) if fenced.phase == LocalNetworkSharingPhase::Disabled => {} Ok(false) => { log::error!( "Peer acknowledged compensation but settled as {:?}", fenced.phase ); force_stop_peer_runtime_and_fence_disabled(app_handle).await?; } Ok(true) => { log::error!("Peer stayed enabled during persistence compensation"); force_stop_peer_runtime_and_fence_disabled(app_handle).await?; } Err(disable_error) => { log::error!( "Failed to compensate enabled Local network sharing: {disable_error}" ); force_stop_peer_runtime_and_fence_disabled(app_handle).await?; } } // Publication can become durable before a later directory-sync // error. Repair false after the core is stopped rather than // assuming the failed write preserved the previous bytes. let repair_problem = persist_local_network_sharing_policy(state.inner(), false) .err() .map(|repair_error| { log::error!( "Failed to repair disabled Local network sharing policy: {repair_error}" ); SharingPersistenceProblem::Save }); finish_sharing_snapshot( app_handle, false, Some(LocalNetworkSharingPhase::Disabled), repair_problem, ) .await?; Err("Local network sharing setting could not be saved".to_owned()) } } } #[tauri::command] async fn set_local_network_sharing( app_handle: tauri::AppHandle, enabled: bool, ) -> Result { let state = app_handle.state::(); let app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?; let _serial_peer_lifecycle = state.app_invokes.serialize_peer_startup().await; let before = current_local_network_sharing(state.inner())?; let peer_ctrl = state.peer_ctrl.read().await.clone(); match peer_ctrl { Some(peer_ctrl) => { set_local_network_sharing_with_runtime(&app_handle, peer_ctrl, enabled, before).await } None => { set_local_network_sharing_without_runtime(&app_handle, &app_invoke, enabled, before) .await } } } #[tauri::command] fn get_startup_game_directory() -> Option<&'static str> { include!(concat!(env!("OUT_DIR"), "/startup-game-directory.rs")) } #[tauri::command] async fn update_game_directory( app_handle: tauri::AppHandle, path: String, ) -> Result { let state = app_handle.state::(); let app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?; let _serial_startup_invoke = state.app_invokes.serialize_peer_startup().await; log::info!("update_game_directory: {path}"); let requested_games_folder = PathBuf::from(&path); let games_folder = scoped_blocking(|| requested_games_folder.canonicalize()).map_err(|err| { let error = format!( "game directory {} is unavailable: {err}", requested_games_folder.display() ); log::error!("{error}"); error })?; if !scoped_blocking(|| games_folder.is_dir()) { let error = format!( "game directory {} is not a directory", games_folder.display() ); log::error!("{error}"); return Err(error); } let Some(requested_canonical_path) = games_folder.to_str() else { let error = format!( "game directory {} cannot be represented as UTF-8", games_folder.display() ); log::error!("{error}"); return Err(error); }; let current_path = state.games_folder.read().await.clone(); let active_ids = state .active_operations .read() .await .keys() .cloned() .collect::>(); if current_path != requested_canonical_path && !active_ids.is_empty() { let error = format!( "Rejecting game directory change to {} while UI operations are active for: {}", games_folder.display(), active_ids.join(", ") ); log::warn!("{error}"); return Err(error); } let path_changed = current_path != requested_canonical_path; let Some(state_dir) = state.state_dir.get().cloned() else { let error = "app state directory is not initialized; cannot update game directory"; log::error!("{error}"); return Err(error.to_string()); }; 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 ); } } let initial_local_network_sharing = current_local_network_sharing(state.inner())?.enabled; let accepted_games_folder = ensure_peer_started( &app_handle, &games_folder, &app_invoke, initial_local_network_sharing, ) .await .map_err(|error| { log::error!( "Failed to accept game directory {}: {error}", games_folder.display() ); error })?; let accepted_path = accepted_games_folder.to_string_lossy().into_owned(); let accepted_path_changed = current_path != accepted_path; // The peer acknowledgement is the commit point for UI state. A rejected // root leaves both the previous path and its local-game flags untouched. if accepted_path_changed { reset_game_transfer_status_for_root(&app_handle).await?; } *state.games_folder.write().await = accepted_path.clone(); // Core's accepted root transition publishes the replacement local snapshot; // the transfer-status fence above has already applied its queued events. // Keep those local flags when committing the path. ListGames refreshes only // remote availability and cannot restore a snapshot erased here. emit_games_list(&app_handle).await; if let Some(peer_ctrl) = state.peer_ctrl.read().await.as_ref() && let Err(error) = peer_ctrl.send(PeerCommand::ListGames) { log::error!("Failed to request post-commit game list: {error}"); } Ok(accepted_path) } async fn update_remote_library_view(view: RemoteLibraryView, app: AppHandle) { let state = app.state::(); let Some(catalog_bundle) = state.catalog_bundle.get() else { log::error!("Ignoring remote library view before catalog authority initialization"); return; }; { let mut game_db = state.games.write().await; apply_peer_remote_view(&mut game_db, &view, catalog_bundle); } emit_games_list(&app).await; } async fn update_local_games_in_db(local_games: Vec, app: AppHandle) { let state = app.state::(); // Clear the prior settled diagnostic before publishing any part of the new // local generation. A concurrent debounced GamesList emit may then see the // old games with the newer status fence, but never new games with stale // exhaustion. if let Err(error) = clear_settled_game_transfer_statuses_for_local_generation(&app).await { log::error!("Failed to clear settled transfer status for local generation: {error}"); } { 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, program: &Path, rar_file: &Path, dest_dir: &Path, cancel_token: CancellationToken, ) -> 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, program, &paths, started_at_ms, cancel_token).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 { 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, program: &Path, paths: &UnrarPaths, started_at_ms: u64, cancel_token: CancellationToken, ) -> eyre::Result<()> { if cancel_token.is_cancelled() { let stderr = format!( "unrar extraction for {} was cancelled", paths.archive.display() ); record_unpack_failure( app_handle, paths.archive.display().to_string(), paths.destination.display().to_string(), started_at_ms, stderr.clone(), ) .await; bail!("{stderr}"); } let registry = app_handle .state::() .active_unrar_children .clone(); let child_id = UNRAR_CHILD_SEQ.fetch_add(1, Ordering::Relaxed); let registration = RegisteredUnrarWorker::new(registry, child_id, cancel_token); let process = match ScopedProcess::spawn( program, [ std::ffi::OsString::from("x"), std::ffi::OsString::from("-p-"), // Skip symbolic links inside the archive entirely. The install // transaction additionally refuses to promote a tree containing // links, so a link can never redirect later file operations. std::ffi::OsString::from("-ol-"), paths.archive.as_os_str().to_owned(), std::ffi::OsString::from("-y"), std::ffi::OsString::from("-o"), std::ffi::OsString::from(&paths.destination_arg), ], ®istration.cancel_token(), UNRAR_LOG_CAPTURE_LIMIT, ) { Ok(process) => process, Err(err) => { let stderr = format!("failed to start unrar sidecar supervisor: {err}"); registration.complete(); record_unpack_failure( app_handle, paths.archive.display().to_string(), paths.destination.display().to_string(), started_at_ms, stderr.clone(), ) .await; bail!("{stderr}"); } }; let output = match process.wait().await { Ok(output) => output, Err(err) => { let stderr = format!("unrar sidecar failed: {err}"); registration.complete(); record_unpack_failure( app_handle, paths.archive.display().to_string(), paths.destination.display().to_string(), started_at_ms, stderr.clone(), ) .await; bail!("{stderr}"); } }; registration.complete(); let stdout = format_unrar_log_stream(&output.stdout, output.stdout_truncated, "stdout"); let stderr = format_unrar_log_stream(&output.stderr, output.stderr_truncated, "stderr"); let status_code = output.status.code(); let success = output.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(()) } fn format_unrar_log_stream(bytes: &[u8], truncated: bool, stream: &str) -> String { let mut output = clean_terminal_log(&String::from_utf8_lossy(bytes)); if truncated { if !output.is_empty() && !output.ends_with('\n') { output.push('\n'); } let _ = write!( output, "[{stream} truncated after {UNRAR_LOG_CAPTURE_LIMIT} bytes]" ); } output } /// Registers one lexical process worker with the application shutdown sweep. struct RegisteredUnrarWorker { registry: Arc>, child_id: u64, control: Arc, armed: bool, } impl RegisteredUnrarWorker { fn new( registry: Arc>, child_id: u64, operation_cancel: CancellationToken, ) -> Self { let control = Arc::new(UnrarWorkerControl { cancel_token: operation_cancel.child_token(), }); let cancel_immediately = { let mut registry_guard = lock_unrar_mutex(®istry, "child registry"); if registry_guard.shutting_down { true } else { registry_guard .children .insert(child_id, Arc::downgrade(&control)); false } }; let registered = Self { registry, child_id, control, armed: true, }; if cancel_immediately { registered.control.cancel_token.cancel(); } registered } fn cancel_token(&self) -> CancellationToken { self.control.cancel_token.clone() } fn complete(mut self) { self.armed = false; self.deregister(); } fn deregister(&self) { lock_unrar_mutex(&self.registry, "child registry") .children .remove(&self.child_id); } } impl Drop for RegisteredUnrarWorker { fn drop(&mut self) { if !self.armed { return; } self.control.cancel_token.cancel(); self.deregister(); } } fn lock_unrar_mutex<'a, T>(mutex: &'a Mutex, label: &str) -> std::sync::MutexGuard<'a, T> { mutex.lock().unwrap_or_else(|poisoned| { log::warn!("unrar {label} is poisoned; recovering it for process cleanup"); poisoned.into_inner() }) } /// Cancels every in-progress unrar worker and latches the registry so late /// registrations start cancelled. Lexical [`ScopedProcess`] owners perform the /// actual kill, wait, and reader joins before their install futures return. fn cancel_active_unrar_workers(app_handle: &AppHandle) { let state = app_handle.state::(); begin_unrar_shutdown(&state.active_unrar_children); } fn begin_unrar_shutdown(registry: &Arc>) { let children = { let mut guard = lock_unrar_mutex(registry, "child registry"); guard.shutting_down = true; guard .children .values() .filter_map(Weak::upgrade) .collect::>() }; for control in children { control.cancel_token.cancel(); } } 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::(); let mut entry = entry; clean_unpack_log_entry(&mut entry); let state_dir = state.state_dir.get().cloned(); { let mut logs = state.inner().unpack_logs.write().await; logs.push(entry); trim_unpack_logs(&mut logs); if let Some(state_dir) = state_dir { if let Err(err) = persist_unpack_logs(&state_dir, &logs) { log::warn!("Failed to persist unpack logs: {err}"); } } else { log::warn!("Cannot persist unpack logs before app state directory is initialized"); } } 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) { 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 { 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 { 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) -> 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>, } #[derive(Default)] struct MainLogFileState { file: Option, 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 { 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 { 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) -> 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 { 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 Layer 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, log_target: Option, fields: Vec, } 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> { 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 { 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::>(&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 } fn persist_unpack_logs(state_dir: &Path, logs: &[UnpackLogEntry]) -> eyre::Result<()> { let path = unpack_logs_path(state_dir); let contents = serde_json::to_vec_pretty(logs)?; scoped_blocking(|| std::fs::write(&path, contents))?; Ok(()) } 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 catalog authority packaged with the Tauri application. fn resolve_bundled_catalog_paths(app_handle: &AppHandle) -> eyre::Result<(PathBuf, PathBuf)> { let game_db_path = app_handle .path() .resolve("game.db", tauri::path::BaseDirectory::Resource) .map_err(|error| eyre::eyre!("failed to resolve game.db resource: {error}"))?; let manifests_root = app_handle .path() .resolve("manifests", tauri::path::BaseDirectory::Resource) .map_err(|error| eyre::eyre!("failed to resolve manifests resource: {error}"))?; Ok((game_db_path, manifests_root)) } /// Load the complete bundled catalog authority exactly once during setup. async fn load_bundled_catalog(app_handle: &AppHandle) -> eyre::Result { let (game_db_path, manifests_root) = resolve_bundled_catalog_paths(app_handle)?; load_catalog_bundle(&game_db_path, &manifests_root) .await .map_err(|error| { eyre::eyre!( "bundled catalog authority is missing or invalid (database {}, manifests {}): {error}", game_db_path.display(), manifests_root.display() ) }) } async fn install_bundled_catalog( state: &LanSpreadState, loaded_catalog: LoadedCatalog, ) -> eyre::Result<()> { let (game_db, catalog_bundle) = loaded_catalog.into_parts(); install_bundled_catalog_parts(state, game_db, catalog_bundle).await } async fn install_bundled_catalog_parts( state: &LanSpreadState, game_db: GameDB, catalog_bundle: Arc, ) -> eyre::Result<()> { let game_count = game_db.games.len(); let mut games = state.games.write().await; state .catalog_bundle .set(catalog_bundle) .map_err(|_| eyre::eyre!("bundled catalog authority was initialized more than once"))?; *games = game_db; log::info!("Loaded {game_count} games and their content authority"); Ok(()) } /// Acquires the runtime ownership slot before creating a runtime and publishes /// the returned owner without another await. Cancelling this future while the /// slot is contended therefore cannot create an untracked runtime. async fn start_runtime_in_slot( slot: &RwLock>, start: Start, ) -> Result>, String> where Start: FnOnce() -> Result, { let mut slot = slot.write().await; if slot.is_some() { return Err("peer runtime ownership slot is already occupied".to_string()); } // Peer startup performs finite filesystem and identity I/O before it // returns. Keep that work lexically owned by this invoke while handing // other Tokio workers off to the executor instead of blocking one of // them. `scoped_blocking` has no cancellation point, so a cancelled // invoke cannot abandon a half-created runtime in the ownership slot. *slot = Some(scoped_blocking(start)?); Ok(slot) } async fn ensure_peer_started( app_handle: &AppHandle, games_folder: &Path, _app_invoke: &AppInvokeGuard, initial_local_network_sharing: bool, ) -> Result { let state = app_handle.state::(); let mut peer_ctrl = state.peer_ctrl.write().await; if let Some(peer_sender) = peer_ctrl.as_ref().cloned() { // Do not hold the publication lock while waiting for the peer's // acknowledgement. Startup keeps the lock until publication below so // cancellation cannot strand a runtime without its control channel; // an already-published runtime needs only its cloned sender. drop(peer_ctrl); let (reply, result) = oneshot::channel(); peer_sender .send(PeerCommand::SetGameDir { path: games_folder.to_path_buf(), reply, }) .map_err(|error| format!("Failed to send PeerCommand::SetGameDir: {error}"))?; return result .await .map_err(|error| format!("PeerCommand::SetGameDir reply was dropped: {error}"))?; } let Some(state_dir) = state.state_dir.get().cloned() else { return Err("app state directory is not initialized; cannot start peer".to_string()); }; let tx_peer_event = app_handle.state::().inner().0.clone(); let unpacker = Arc::new(SidecarUnpacker { app_handle: app_handle.clone(), }); let stream_install_provider = stream_install_provider_for_app(app_handle); let catalog_bundle = state .catalog_bundle .get() .cloned() .ok_or_else(|| "bundled catalog authority is not initialized".to_string())?; // Acquire the identity publication slot before creating the runtime. Once // `start_peer_with_options` returns, both control and identity ownership are // published without another cancellation point. let mut local_peer_id_slot = state.local_peer_id.write().await; let mut installation_identity_slot = state.installation_identity.write().await; let startup_identity = installation_identity_slot .as_ref() .map(|cached| Arc::clone(&cached.identity)); let peer_runtime = start_runtime_in_slot(&state.peer_runtime, || { start_peer_with_options( games_folder.to_path_buf(), tx_peer_event, state.peer_game_db.clone(), unpacker, catalog_bundle, PeerStartOptions { state_dir: Some(state_dir), identity: startup_identity, active_outbound_transfers: Some(state.active_outbound_transfers.clone()), stream_install_provider: Some(stream_install_provider), local_network_sharing: initial_local_network_sharing, }, ) .map_err(|error| format!("Failed to initialize peer system: {error}")) }) .await?; let Some(handle) = peer_runtime.as_ref() else { return Err("peer runtime ownership handoff did not publish its handle".to_string()); }; let accepted_games_folder = handle.accepted_game_dir().to_path_buf(); let local_peer_id = handle.peer_id().to_string(); let identity_durability = retain_installation_identity( &mut installation_identity_slot, handle.identity(), handle.identity_durability(), ); let sender = handle.sender(); *peer_ctrl = Some(sender); drop(peer_ctrl); *local_peer_id_slot = Some(local_peer_id); drop(installation_identity_slot); drop(local_peer_id_slot); drop(peer_runtime); if let Err(error) = publish_identity_diagnostic(app_handle, identity_durability).await { log::error!("Failed to publish identity persistence diagnostic: {error}"); } log::info!("Peer system initialized successfully with games directory"); Ok(accepted_games_folder) } fn stream_install_provider_for_app(app_handle: &AppHandle) -> Arc { 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 { 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: PeerEventReceiver, mut rx_ui_state: UnboundedReceiver, ) { let tasks = app_handle .state::() .background_tasks .clone(); let cancel_token = tasks.cancel_token(); tasks.spawn(async move { let mut peer_events_open = true; let mut ui_state_open = true; loop { tokio::select! { () = cancel_token.cancelled() => break, event = rx_peer_event.recv(), if peer_events_open => { match event { Some(event) => handle_peer_event(&app_handle, event).await, None => peer_events_open = false, } } command = rx_ui_state.recv(), if ui_state_open => { match command { Some(command) => { handle_ui_state_command( &app_handle, command, &mut rx_peer_event, ).await; } None => ui_state_open = false, } } else => break, } } }); } fn publish_local_network_sharing_snapshot( app_handle: &AppHandle, mut next: LocalNetworkSharingSnapshot, ) -> Result { let state = app_handle.state::(); let watch = local_network_sharing_watch(state.inner())?; let current = *watch.borrow(); if (LocalNetworkSharingSnapshot { revision: current.revision, ..next }) == current { return Ok(current); } next.revision = current .revision .checked_add(1) .ok_or_else(|| "Local network sharing revision overflow".to_owned())?; let _previous = watch.send_replace(next); if let Err(error) = app_handle.emit("local-network-sharing-updated", Some(next)) { log::error!("Failed to emit local-network-sharing-updated event: {error}"); } Ok(next) } fn record_core_local_network_sharing_state( app_handle: &AppHandle, state: LocalNetworkSharingState, ) -> Result { let current = current_local_network_sharing(app_handle.state::().inner())?; let phase = local_network_sharing_phase(state); publish_local_network_sharing_snapshot( app_handle, LocalNetworkSharingSnapshot { phase, ..current }, ) } fn local_network_sharing_phase(state: LocalNetworkSharingState) -> LocalNetworkSharingPhase { match state { LocalNetworkSharingState::Disabled => LocalNetworkSharingPhase::Disabled, LocalNetworkSharingState::Enabling => LocalNetworkSharingPhase::Enabling, LocalNetworkSharingState::Enabled { .. } => LocalNetworkSharingPhase::Enabled, LocalNetworkSharingState::Disabling => LocalNetworkSharingPhase::Disabling, } } fn mutate_local_network_sharing_in_loop( app_handle: &AppHandle, mutation: SharingSnapshotMutation, ) -> Result { let current = current_local_network_sharing(app_handle.state::().inner())?; publish_local_network_sharing_snapshot(app_handle, sharing_snapshot_after(current, mutation)) } fn sharing_snapshot_after( current: LocalNetworkSharingSnapshot, mutation: SharingSnapshotMutation, ) -> LocalNetworkSharingSnapshot { match mutation { SharingSnapshotMutation::Begin { target } => LocalNetworkSharingSnapshot { pending_target: Some(target), ..current }, SharingSnapshotMutation::Commit { enabled, phase, persistence_problem, } => LocalNetworkSharingSnapshot { enabled, pending_target: None, phase: phase.unwrap_or(current.phase), persistence_problem, ..current }, } } fn set_identity_diagnostic_in_loop( app_handle: &AppHandle, diagnostic: Option, ) -> Result { let state = app_handle.state::(); let watch = state .identity_diagnostic .get() .ok_or_else(|| "identity diagnostic state is not initialized".to_owned())?; let current = *watch.borrow(); if current.diagnostic == diagnostic { return Ok(current); } let next = IdentityDiagnosticSnapshot { revision: current .revision .checked_add(1) .ok_or_else(|| "identity diagnostic revision overflow".to_owned())?, diagnostic, }; let _previous = watch.send_replace(next); if let Err(error) = app_handle.emit("identity-diagnostic-updated", Some(next)) { log::error!("Failed to emit identity-diagnostic-updated event: {error}"); } Ok(next) } #[cfg(test)] fn take_exactly_queued( receiver: &mut UnboundedReceiver, count: usize, ) -> Result, String> { let mut queued = Vec::with_capacity(count); for _ in 0..count { queued.push(receiver.try_recv().map_err(|error| { format!("peer-event queue changed while applying its fence: {error}") })?); } Ok(queued) } async fn drain_queued_peer_events( app_handle: &AppHandle, receiver: &mut PeerEventReceiver, ) -> Result<(), String> { // The core replies only after all events for the requested transition have // been enqueued. Once this UI command wins the fair select, hold the shared // producer fence while draining every event already admitted, including a // coalesced pending view. Later autonomous traffic remains for the normal // event-loop turn and cannot contaminate this transition's acknowledgement // boundary. let queued = receiver .drain_ready() .map_err(|error| format!("peer-event queue changed while applying its fence: {error}"))?; for event in queued { handle_peer_event(app_handle, event).await; } Ok(()) } async fn fence_queued_peer_events( app_handle: &AppHandle, receiver: &mut PeerEventReceiver, ) -> Result { drain_queued_peer_events(app_handle, receiver).await?; current_local_network_sharing(app_handle.state::().inner()) } async fn reset_game_transfer_status_in_loop( app_handle: &AppHandle, receiver: &mut PeerEventReceiver, ) -> Result { // A successful game-root command has already caused core to enqueue its // preceding events. Drain that bounded prefix before terminalizing the old // root's receipts; later events without a strictly newer Begin stay inert. drain_queued_peer_events(app_handle, receiver).await?; let state = app_handle.state::(); let (changed, current) = { let mut store = state.game_transfer_status.write().await; let changed = store.clear_for_root_change()?; let current = changed.clone().unwrap_or_else(|| store.snapshot()); (changed, current) }; if let Some(snapshot) = &changed { emit_game_transfer_status_snapshot(app_handle, snapshot); } Ok(current) } async fn handle_ui_state_command( app_handle: &AppHandle, command: UiStateCommand, peer_events: &mut PeerEventReceiver, ) { match command { UiStateCommand::MutateSharing { mutation, reply } => { let _ = reply.send(mutate_local_network_sharing_in_loop(app_handle, mutation)); } UiStateCommand::SetIdentityDiagnostic { diagnostic, reply } => { let _ = reply.send(set_identity_diagnostic_in_loop(app_handle, diagnostic)); } UiStateCommand::FencePeerEvents { reply } => { let _ = reply.send(fence_queued_peer_events(app_handle, peer_events).await); } UiStateCommand::ResetGameTransferStatus { reply } => { let _ = reply.send(reset_game_transfer_status_in_loop(app_handle, peer_events).await); } } } async fn schedule_outbound_transfer_emit(app_handle: &AppHandle) { let state = app_handle.state::(); let should_spawn = { let mut emit_state = state.outbound_transfer_emit.write().await; emit_state.record_change() }; if !should_spawn { return; } let tasks = state.background_tasks.clone(); let cancel_token = tasks.cancel_token(); let app_handle = app_handle.clone(); tasks.spawn(async move { loop { tokio::select! { biased; () = cancel_token.cancelled() => break, () = tokio::time::sleep(OUTBOUND_TRANSFER_EMIT_DEBOUNCE) => {} } let observed_generation = { let state = app_handle.state::(); state .outbound_transfer_emit .read() .await .observed_generation() }; emit_games_list(&app_handle).await; let needs_follow_up_emit = { let state = app_handle.state::(); 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}"); let authoritative_peer_id = app_handle .state::() .local_peer_id .read() .await .clone(); match authoritative_peer_id { Some(authoritative_peer_id) if authoritative_peer_id != peer_id => { log::error!( "LocalPeerReady identity {peer_id} did not match runtime handle identity {authoritative_peer_id}" ); } Some(_) => {} None => { log::debug!( "LocalPeerReady preceded publication of the authoritative runtime handle identity" ); } } } PeerEvent::LocalNetworkSharingStateChanged(sharing_state) => { if matches!(sharing_state, LocalNetworkSharingState::Disabled) { let state = app_handle.state::(); if current_protocol_mismatch(state.inner()) .await .mismatch .is_some() { let diagnostic = clear_protocol_mismatch(state.inner()).await; if let Err(error) = app_handle.emit("protocol-mismatch-updated", Some(diagnostic)) { log::error!("Failed to emit protocol-mismatch-updated event: {error}"); } } } if let Err(error) = record_core_local_network_sharing_state(app_handle, sharing_state) { log::error!("Failed to record Local network sharing state: {error}"); } } PeerEvent::RemoteLibraryView(view) => { log::info!("PeerEvent::RemoteLibraryView received"); update_remote_library_view(view, 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::(); { 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::CallToPlayView(view) => { if let Err(err) = app_handle.emit("call-to-play-view", Some(view)) { log::error!("Failed to emit call-to-play-view event: {err}"); } } PeerEvent::OutboundTransferCountChanged(change) => { log::info!("PeerEvent::OutboundTransferCountChanged received"); schedule_outbound_transfer_emit(app_handle).await; drop(change); } PeerEvent::DownloadGameFilesBegin { attempt } => { log::info!( "PeerEvent::DownloadGameFilesBegin received for {} attempt {}", attempt.id, attempt.attempt_id ); let id = attempt.id.clone(); if let Err(error) = mutate_catalog_game_transfer_status(app_handle, &id, |store| store.begin(&attempt)) .await { log::error!("Failed to record game transfer begin: {error}"); } } PeerEvent::DownloadGameFileChunkFinished { id, peer_id, peer_addr, content_id, relative_path, offset, length, } => { log::debug!( "PeerEvent::DownloadGameFileChunkFinished received for {id}: \ {} offset {offset} length {length} for content {content_id} \ from authenticated peer {peer_id} at {peer_addr}", relative_path.as_str() ); } PeerEvent::DownloadGameFilesProgress(progress) => { if accepts_game_transfer_progress(app_handle, &progress).await { if let Err(error) = app_handle.emit( "game-download-progress", Some(UiDownloadProgress::from(&progress)), ) { log::error!("Failed to emit game-download-progress event: {error}"); } } else { log::debug!( "Ignoring stale download progress for {} attempt {}", progress.attempt.id, progress.attempt.attempt_id ); } } PeerEvent::DownloadGameFilesActivityChanged { attempt, activity } => { let id = attempt.id.clone(); if let Err(error) = mutate_catalog_game_transfer_status(app_handle, &id, |store| { store.activity(&attempt, activity) }) .await { log::error!("Failed to record game transfer activity: {error}"); } } PeerEvent::DownloadGameFilesFinished { attempt } => { log::info!( "PeerEvent::DownloadGameFilesFinished received for {} attempt {}", attempt.id, attempt.attempt_id ); let id = attempt.id.clone(); if let Err(error) = mutate_catalog_game_transfer_status(app_handle, &id, |store| { store.finished(&attempt) }) .await { log::error!("Failed to record game transfer completion: {error}"); } } PeerEvent::DownloadGameFilesFailed { attempt, reason } => { log::warn!( "PeerEvent::DownloadGameFilesFailed received for {} attempt {}: {reason:?}", attempt.id, attempt.attempt_id ); let id = attempt.id.clone(); match mutate_catalog_game_transfer_status(app_handle, &id, |store| { store.failed(&attempt, reason) }) .await { Ok(Some(_)) if reason == DownloadFailureReason::OperationFailed => { emit_game_id_event( app_handle, "game-download-failed", &id, "PeerEvent::DownloadGameFilesFailed", ); } Ok(_) => {} Err(error) => log::error!("Failed to record game transfer failure: {error}"), } } 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::PeerDiscovered(endpoint) => { log::info!( "Peer discovered: authenticated peer {} at {}", endpoint.peer_id, endpoint.addr ); } PeerEvent::PeerLost(endpoint) => { log::info!( "Peer lost: authenticated peer {} at {}", endpoint.peer_id, endpoint.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::IncompatibleProtocolDetected { observed, expected } => { log::info!( "Nearby installation uses incompatible protocol {observed:?}; expected {expected}" ); let diagnostic = record_protocol_mismatch( app_handle.state::().inner(), ProtocolMismatch { observed, expected }, ) .await; if let Err(e) = app_handle.emit("protocol-mismatch-updated", Some(diagnostic)) { log::error!("Failed to emit protocol-mismatch-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}"); } } } } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] struct ProtocolMismatch { observed: Option, expected: u32, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] struct ProtocolMismatchSnapshot { revision: u64, mismatch: Option, } fn application_context() -> tauri::Context { // generate_context! reads these files through the proc macro's filesystem // API. Explicit includes also put them in rustc's dependency information, // so compiler caches cannot reuse an application with an older ACL. const _: &str = include_str!(concat!(env!("OUT_DIR"), "/capabilities.json")); const _: &str = include_str!(concat!(env!("OUT_DIR"), "/acl-manifests.json")); tauri::generate_context!() } #[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) = peer_event_channel(); let (tx_ui_state, rx_ui_state) = tokio::sync::mpsc::unbounded_channel::(); 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, get_protocol_mismatch, get_local_network_sharing, set_local_network_sharing, get_identity_diagnostic, request_call_to_play_view, publish_call_to_play, set_call_to_play_display_name, install_game, supports_streamed_install, stream_install_game, run_game, start_server, game_directory_exists, get_startup_game_directory, 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)) .manage(UiStateTx(tx_ui_state)) .on_window_event(|window, event| { if window.label() == "main" && matches!(event, tauri::WindowEvent::CloseRequested { .. }) { begin_application_shutdown(window.app_handle()); } }) .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::(); 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)?; if state.state_dir.set(state_dir.clone()).is_err() { log::warn!("app state directory was already initialized"); } let policy_path = state_dir.join(sharing_policy::POLICY_FILE_NAME); let (sharing_enabled, persistence_problem) = match scoped_blocking(|| { sharing_policy::load(&policy_path) }) { Ok(enabled) => (enabled, None), Err(error) => { log::error!( "Failed to load Local network sharing policy; starting disabled: {error:#}" ); (false, Some(SharingPersistenceProblem::Load)) } }; let (sharing_watch, _) = watch::channel(LocalNetworkSharingSnapshot::initial( sharing_enabled, persistence_problem, )); if state.local_network_sharing.set(sharing_watch).is_err() { log::warn!("Local network sharing state was already initialized"); } let (identity_watch, _) = watch::channel(IdentityDiagnosticSnapshot::INITIAL); if state.identity_diagnostic.set(identity_watch).is_err() { log::warn!("identity diagnostic state was already initialized"); } let loaded_catalog = tauri::async_runtime::block_on(load_bundled_catalog(app.handle())) .map_err(|error| io::Error::other(error.to_string()))?; tauri::async_runtime::block_on(install_bundled_catalog(state.inner(), loaded_catalog)) .map_err(|error| io::Error::other(error.to_string()))?; let unpack_logs = load_unpack_logs(&state_dir); tauri::async_runtime::block_on(async { *state.unpack_logs.write().await = unpack_logs; }); spawn_peer_event_loop(app.handle().clone(), rx_peer_event, rx_ui_state); Ok(()) }) .build(application_context()) .expect("error while building tauri application") .run(|app_handle, event| { if matches!(event, tauri::RunEvent::Exit) { shutdown_application(app_handle); } }); } fn shutdown_application(app_handle: &AppHandle) { begin_application_shutdown(app_handle); let state = app_handle.state::(); let peer_runtime = state.peer_runtime.clone(); let app_invokes = state.app_invokes.clone(); let background_tasks = state.background_tasks.clone(); tauri::async_runtime::block_on(async move { // Once admission is closed, every earlier application invoke must // return before the runtime can be taken. This both owns setup-process // settlement and prevents a late invoke from touching or replacing the // runtime behind this shutdown sweep. app_invokes.wait_closed().await; let handle = { peer_runtime.write().await.take() }; if let Some(mut handle) = handle { handle.shutdown(); await_with_slow_warning( handle.wait_stopped(), Duration::from_secs(2), "Peer runtime did not stop within 2s of shutdown request; continuing to wait", ) .await; } background_tasks.shutdown().await; }); } /// Starts application shutdown at the first native close boundary. /// /// The frontend close handler still drains its own asynchronous ownership before /// destroying the webview, while this synchronous boundary cancels peer work /// early enough for pending frontend invokes to observe dropped acknowledgements /// instead of waiting for the work they are acknowledging to finish naturally. fn begin_application_shutdown(app_handle: &AppHandle) { let state = app_handle.state::(); if state .application_shutdown_started .swap(true, Ordering::AcqRel) { return; } state.app_invokes.close_admission(); cancel_active_unrar_workers(app_handle); // The runtime slot is async because startup and replacement are serialized // with application invokes. Keep this request inside the application task // scope so a concurrent startup cannot leave the early signal detached. let peer_runtime = state.peer_runtime.clone(); state.background_tasks.spawn(async move { if let Some(handle) = peer_runtime.read().await.as_ref() { handle.shutdown(); } }); } async fn await_with_slow_warning(future: F, warn_after: Duration, warning: &str) -> F::Output where F: std::future::Future, { tokio::pin!(future); tokio::select! { output = &mut future => output, () = tokio::time::sleep(warn_after) => { log::warn!("{warning}"); future.await } } } #[cfg(test)] mod tests { use super::*; #[test] fn every_frontend_close_boundary_can_destroy_its_window() { // Test the application's resolved ACL, including Tauri's default // permission sets. Mocked frontend invokes cannot detect a missing // permission at the end of an otherwise successful close drain. let mut context = application_context(); let authority = context.runtime_authority_mut(); for label in ["main", "main-logs", "unpack-logs"] { for command in [ "plugin:event|listen", "plugin:event|unlisten", "plugin:window|destroy", ] { assert!( authority .resolve_access(command, label, label, &tauri::ipc::Origin::Local) .is_some(), "{label} must be allowed to call {command} for its frontend close boundary", ); } } assert!( authority .resolve_access( "plugin:window|destroy", "untrusted", "untrusted", &tauri::ipc::Origin::Local ) .is_none(), "window destruction must remain limited to the configured application windows", ); } fn download_attempt(id: &str, attempt_id: u64) -> DownloadAttemptKey { serde_json::from_value(serde_json::json!({ "id": id, "attempt_id": attempt_id.to_string(), })) .expect("test attempt key should deserialize") } fn download_progress(attempt: DownloadAttemptKey) -> DownloadProgress { DownloadProgress { attempt, downloaded_bytes: 10, total_bytes: 100, bytes_per_second: 5, active_peer_count: 1, } } #[test] fn game_transfer_attempt_fence_rejects_stale_equal_and_terminal_replay() { let mut store = GameTransferStatusStore::default(); let current = download_attempt("alpha", 20); let stale = download_attempt("alpha", 19); let successor = download_attempt("alpha", 21); assert_eq!( store .begin(¤t) .expect("begin should apply") .expect("new attempt should publish") .revision, 2 ); assert_eq!( store.snapshot.open_attempts.get("alpha"), Some(¤t.attempt_id), "the full snapshot must expose the current open attempt" ); assert!( store .activity( ¤t, Some(DownloadVerificationActivity::RetryingInvalidSource), ) .expect("activity should apply") .is_some() ); assert!( store .begin(¤t) .expect("replay should parse") .is_none() ); assert!(store.begin(&stale).expect("stale should parse").is_none()); assert!( store .finished(&stale) .expect("stale terminal should parse") .is_none() ); assert_eq!( store.snapshot.statuses.get("alpha"), Some(&GameTransferStatus::Retrying), "equal Begin and stale terminal must not erase current activity" ); assert!( store .begin(&successor) .expect("successor should apply") .is_some() ); assert!(store.snapshot.statuses.is_empty()); assert_eq!( store.snapshot.open_attempts.get("alpha"), Some(&successor.attempt_id), "a successor Begin must replace the frontend progress fence" ); assert!( !store.accepts_progress(&download_progress(current.clone())), "old progress must not cross the successor fence" ); assert!(store.accepts_progress(&download_progress(successor.clone()))); assert!( store .finished(&successor) .expect("finish should apply") .is_some() ); assert!( store.snapshot.open_attempts.is_empty(), "terminal settlement must remove the open attempt fence" ); assert!( store .failed( &successor, DownloadFailureReason::VerifiedCatalogSourcesExhausted ) .expect("terminal replay should parse") .is_none(), "an equal terminal replay must remain inert" ); } #[test] fn terminal_only_failures_are_fenced_and_exhaustion_is_sticky() { let mut store = GameTransferStatusStore::default(); let exhausted = download_attempt("alpha", 30); let preflight_failure = download_attempt("alpha", 31); let explicit_retry = download_attempt("alpha", 32); assert!( store .failed( &exhausted, DownloadFailureReason::VerifiedCatalogSourcesExhausted, ) .expect("terminal-only exhaustion should apply") .is_some() ); assert_eq!( store.snapshot.statuses.get("alpha"), Some(&GameTransferStatus::Exhausted) ); assert!( store .failed(&preflight_failure, DownloadFailureReason::OperationFailed,) .expect("newer preflight failure should apply") .is_some(), "an accepted terminal-only operation failure still drives its generic event" ); assert_eq!( store.snapshot.statuses.get("alpha"), Some(&GameTransferStatus::Exhausted), "a preflight failure without a clearing Begin must preserve sticky exhaustion" ); assert!( store .failed(&preflight_failure, DownloadFailureReason::OperationFailed,) .expect("replay should parse") .is_none(), "the retained invisible receipt must suppress duplicate generic failure" ); store .begin(&explicit_retry) .expect("explicit retry should apply") .expect("newer Begin should publish the clear"); assert!(store.snapshot.statuses.is_empty()); store .activity( &explicit_retry, Some(DownloadVerificationActivity::VerifyingDownloadedChunks), ) .expect("verification should apply") .expect("verification should publish"); store .failed(&explicit_retry, DownloadFailureReason::OperationFailed) .expect("current operation failure should apply") .expect("current operation failure should publish None"); assert!(store.snapshot.statuses.is_empty()); assert!( store .activity( &explicit_retry, Some(DownloadVerificationActivity::RetryingInvalidSource), ) .expect("late activity should parse") .is_none() ); } #[test] fn local_generation_clears_only_settled_status_and_root_terminalizes_open_receipts() { let mut store = GameTransferStatusStore::default(); let exhausted = download_attempt("alpha", 40); let open = download_attempt("bravo", 41); store .failed( &exhausted, DownloadFailureReason::VerifiedCatalogSourcesExhausted, ) .expect("exhaustion should apply") .expect("exhaustion should publish"); store .begin(&open) .expect("open attempt should apply") .expect("open attempt should publish"); store .activity( &open, Some(DownloadVerificationActivity::VerifyingDownloadedChunks), ) .expect("open activity should apply") .expect("open activity should publish"); store .clear_settled_for_local_generation() .expect("local generation should clear") .expect("visible exhaustion should publish a replacement"); assert_eq!( store.snapshot.statuses, BTreeMap::from([("bravo".to_owned(), GameTransferStatus::Verifying)]), "a local generation clears settled exhaustion but preserves an open attempt" ); assert_eq!( store.snapshot.open_attempts.get("bravo"), Some(&open.attempt_id), "local generation must preserve the exact open progress fence" ); assert!( store .failed( &exhausted, DownloadFailureReason::VerifiedCatalogSourcesExhausted, ) .expect("old terminal replay should parse") .is_none(), "local generation must retain the invisible terminal receipt" ); store .clear_for_root_change() .expect("root reset should apply") .expect("open activity should be visibly cleared"); assert!(store.snapshot.statuses.is_empty()); assert!( store.snapshot.open_attempts.is_empty(), "root replacement must clear every open progress fence" ); assert!( store .activity( &open, Some(DownloadVerificationActivity::RetryingInvalidSource), ) .expect("old root activity should parse") .is_none(), "root reset must make the old open receipt inert" ); assert!( store .finished(&open) .expect("old root terminal should parse") .is_none() ); assert!( store .begin(&download_attempt("bravo", 42)) .expect("new root attempt should apply") .is_some() ); } #[test] fn game_transfer_snapshot_is_full_replacement_and_revision_overflow_is_atomic() { let mut store = GameTransferStatusStore::default(); let exhausted = download_attempt("alpha", 50); store .failed( &exhausted, DownloadFailureReason::VerifiedCatalogSourcesExhausted, ) .expect("exhaustion should apply") .expect("exhaustion should publish"); assert_eq!( serde_json::to_value(store.snapshot()).expect("transfer snapshot should serialize"), serde_json::json!({ "revision": 2, "statuses": { "alpha": "exhausted" }, "openAttempts": {}, }) ); let before = store.snapshot(); store.snapshot.revision = u64::MAX; assert!(store.begin(&download_attempt("bravo", 51)).is_err()); assert!(!store.attempts.contains_key("bravo")); assert_eq!(store.snapshot.statuses, before.statuses); assert_eq!(store.snapshot.open_attempts, before.open_attempts); } #[test] fn transfer_progress_and_open_attempts_serialize_exact_decimal_ids() { let mut store = GameTransferStatusStore::default(); let attempt = download_attempt("alpha", u64::MAX); let snapshot = store .begin(&attempt) .expect("begin should apply") .expect("new attempt should publish"); assert_eq!( serde_json::to_value(snapshot).expect("transfer snapshot should serialize"), serde_json::json!({ "revision": 2, "statuses": {}, "openAttempts": { "alpha": "18446744073709551615" }, }) ); assert_eq!( serde_json::to_value(UiDownloadProgress::from(&download_progress(attempt))) .expect("progress should serialize"), serde_json::json!({ "id": "alpha", "attemptId": "18446744073709551615", "downloaded_bytes": 10, "total_bytes": 100, "bytes_per_second": 5, "active_peer_count": 1, }) ); } #[test] fn sharing_snapshot_distinguishes_policy_from_effective_network_state() { let waiting = LocalNetworkSharingSnapshot::initial(true, None); assert!(waiting.enabled); assert_eq!( waiting.phase, LocalNetworkSharingPhase::WaitingForGameDirectory ); assert!(!waiting.is_stable_at(true)); assert!(!waiting.admits_network_actions()); let enabled = LocalNetworkSharingSnapshot { phase: LocalNetworkSharingPhase::Enabled, ..waiting }; assert!(enabled.admits_network_actions()); assert!( !LocalNetworkSharingSnapshot { pending_target: Some(false), ..enabled } .admits_network_actions() ); let disabled = LocalNetworkSharingSnapshot::initial(false, Some(SharingPersistenceProblem::Load)); assert!(!disabled.enabled); assert_eq!(disabled.phase, LocalNetworkSharingPhase::Disabled); assert!(disabled.is_stable_at(false)); assert_eq!( serde_json::to_value(disabled).expect("snapshot should serialize"), serde_json::json!({ "revision": 1, "enabled": false, "pendingTarget": null, "phase": "disabled", "persistenceProblem": "load", }) ); } #[test] fn peer_event_fence_drains_the_captured_transition_prefix_before_commit() { let enabled = LocalNetworkSharingSnapshot { revision: 7, enabled: true, pending_target: None, phase: LocalNetworkSharingPhase::Enabled, persistence_problem: None, }; let begun = sharing_snapshot_after(enabled, SharingSnapshotMutation::Begin { target: false }); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); for state in [ LocalNetworkSharingState::Disabled, LocalNetworkSharingState::Enabling, LocalNetworkSharingState::Disabling, LocalNetworkSharingState::Disabled, ] { tx.send(state).expect("captured transition should enqueue"); } let captured = rx.len(); tx.send(LocalNetworkSharingState::Enabling) .expect("later generation should enqueue"); let fenced = take_exactly_queued(&mut rx, captured) .expect("captured transition prefix should remain available") .into_iter() .fold(begun, |current, state| LocalNetworkSharingSnapshot { phase: local_network_sharing_phase(state), ..current }); assert_eq!(fenced.phase, LocalNetworkSharingPhase::Disabled); assert_eq!(fenced.pending_target, Some(false)); assert_eq!( local_network_sharing_phase( rx.try_recv() .expect("post-fence traffic must remain queued") ), LocalNetworkSharingPhase::Enabling ); let committed = sharing_snapshot_after( fenced, SharingSnapshotMutation::Commit { enabled: false, phase: Some(LocalNetworkSharingPhase::Disabled), persistence_problem: None, }, ); assert!(!committed.enabled); assert_eq!(committed.pending_target, None); assert_eq!(committed.phase, LocalNetworkSharingPhase::Disabled); } #[test] fn policy_commit_cannot_resurrect_enabled_after_automatic_disable() { let fenced_enabled = LocalNetworkSharingSnapshot { revision: 11, enabled: false, pending_target: Some(true), phase: LocalNetworkSharingPhase::Enabled, persistence_problem: None, }; let automatically_disabled = LocalNetworkSharingSnapshot { revision: 12, phase: LocalNetworkSharingPhase::Disabled, ..fenced_enabled }; let committed = sharing_snapshot_after( automatically_disabled, SharingSnapshotMutation::Commit { enabled: true, phase: None, persistence_problem: None, }, ); assert!(committed.enabled, "desired policy should still commit"); assert_eq!(committed.pending_target, None); assert_eq!(committed.phase, LocalNetworkSharingPhase::Disabled); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn blocked_disable_save_starts_only_after_current_admission_closed_event() { use std::sync::atomic::AtomicBool; let begun_revision = 20; let initial = LocalNetworkSharingSnapshot { revision: begun_revision, enabled: true, pending_target: Some(false), phase: LocalNetworkSharingPhase::Enabled, persistence_problem: None, }; assert!(network_admission_closed_after(initial, begun_revision).is_none()); let (sharing_tx, sharing_rx) = watch::channel(initial); let (command_tx, command_rx) = oneshot::channel(); let network_accepts = Arc::new(AtomicBool::new(true)); let accepts_during_save = Arc::clone(&network_accepts); let (save_entered, entered) = std::sync::mpsc::channel(); let (release_save, release) = std::sync::mpsc::channel(); let transition = tokio::spawn(disable_runtime_then_persist( begun_revision, sharing_rx, async move { command_rx .await .map_err(|error| format!("test command reply dropped: {error}"))? }, || async { panic!("a current Disabling event should avoid force-stop fallback"); }, move |closed| { after_network_admission_closed(closed, || { assert!( !accepts_during_save.load(Ordering::SeqCst), "blocked persistence must not overlap open network admission" ); save_entered .send(()) .expect("test save should report blocking"); release.recv().expect("test should release blocked save"); }); Ok(()) }, )); tokio::task::yield_now().await; assert!( entered.try_recv().is_err(), "persistence must not start before a current closure event" ); // Core closes admission before emitting the lifecycle state that // crosses the Tauri watch. network_accepts.store(false, Ordering::SeqCst); sharing_tx.send_replace(LocalNetworkSharingSnapshot { revision: begun_revision + 1, phase: LocalNetworkSharingPhase::Disabling, ..initial }); tokio::task::spawn_blocking(move || { entered .recv_timeout(Duration::from_secs(1)) .expect("production persistence closure should start after Disabling"); }) .await .expect("save-entered waiter should join"); assert!(!network_accepts.load(Ordering::SeqCst)); release_save .send(()) .expect("blocked save should be released"); command_tx .send(Ok(false)) .expect("test command should settle disabled"); let outcome = transition.await.expect("production helper should settle"); assert_eq!(outcome.command_result, Ok(false)); assert_eq!(outcome.persistence, Ok(())); assert!(!outcome.force_stopped); } #[test] fn identity_diagnostic_exposes_only_ephemeral_durability() { assert_eq!( identity_diagnostic_for_durability(PeerIdentityDurability::Ephemeral), Some(IdentityDiagnostic::Ephemeral) ); assert_eq!( identity_diagnostic_for_durability(PeerIdentityDurability::Persistent), None ); assert_eq!( identity_diagnostic_for_durability(PeerIdentityDurability::CallerProvided), None ); assert_eq!( serde_json::to_value(IdentityDiagnosticSnapshot { revision: 2, diagnostic: Some(IdentityDiagnostic::Ephemeral), }) .expect("diagnostic should serialize"), serde_json::json!({ "revision": 2, "diagnostic": "ephemeral", }) ); } #[test] fn forced_runtime_restart_reuses_process_identity_and_original_durability() { let first_runtime_identity = Arc::new("ephemeral-one"); let mut retained = None; assert_eq!( retain_installation_identity( &mut retained, Arc::clone(&first_runtime_identity), PeerIdentityDurability::Ephemeral, ), PeerIdentityDurability::Ephemeral ); let regenerated_identity = Arc::new("must-not-replace"); assert_eq!( retain_installation_identity( &mut retained, regenerated_identity, PeerIdentityDurability::CallerProvided, ), PeerIdentityDurability::Ephemeral, "explicit restart injection must not clear the original diagnostic" ); assert!(Arc::ptr_eq( &retained .as_ref() .expect("first runtime identity should be retained") .identity, &first_runtime_identity )); } #[tokio::test] async fn protocol_mismatch_is_replayed_when_it_precedes_frontend_registration() { let state = LanSpreadState::default(); let mismatch = ProtocolMismatch { observed: Some(7), expected: 8, }; // Capture the snapshot query first, then model an event arriving while // that delayed result is still in flight. Its lower revision lets the // frontend discard it deterministically. let delayed_snapshot = current_protocol_mismatch(&state).await; let event_snapshot = record_protocol_mismatch(&state, mismatch).await; assert_eq!(delayed_snapshot.revision, 0); assert_eq!(event_snapshot.revision, 1); assert_eq!(event_snapshot.mismatch, Some(mismatch)); assert_eq!(current_protocol_mismatch(&state).await, event_snapshot); let cleared = clear_protocol_mismatch(&state).await; assert_eq!(cleared.revision, 2); assert_eq!(cleared.mismatch, None); } #[tokio::test] async fn app_task_scope_shutdown_cancels_and_joins_tracked_tasks() { let scope = AppTaskScope::default(); let cancel_token = scope.cancel_token(); let (settled_tx, settled_rx) = tokio::sync::oneshot::channel(); scope.spawn(async move { cancel_token.cancelled().await; let _ = settled_tx.send(()); }); scope.shutdown().await; settled_rx .await .expect("tracked task should settle before shutdown returns"); } #[tokio::test] async fn closed_app_task_scope_rejects_late_tasks() { let scope = AppTaskScope::default(); scope.shutdown().await; let (ran_tx, ran_rx) = tokio::sync::oneshot::channel(); scope.spawn(async move { let _ = ran_tx.send(()); }); assert!( ran_rx.await.is_err(), "late task future should be dropped instead of detached" ); } #[tokio::test] async fn app_invoke_shutdown_closes_admission_before_draining_every_invoke() { let scope = AppInvokeScope::default(); let first_guard = scope .try_enter() .expect("first application invoke should be admitted before shutdown"); let second_guard = scope .try_enter() .expect("second application invoke should be admitted before shutdown"); let mut shutdown = Box::pin(scope.close_and_wait()); assert!( tokio::time::timeout(Duration::from_millis(10), shutdown.as_mut()) .await .is_err(), "shutdown must wait for every admitted invoke guard" ); assert!( scope.try_enter().is_none(), "polling shutdown must close admission before waiting" ); drop(first_guard); assert!( tokio::time::timeout(Duration::from_millis(10), shutdown.as_mut()) .await .is_err(), "one settled invoke must not hide another live invoke" ); drop(second_guard); tokio::time::timeout(Duration::from_secs(1), shutdown) .await .expect("shutdown should finish after the admitted invoke returns"); } #[tokio::test] async fn peer_startup_invokes_are_serialized_through_ui_commit() { let scope = AppInvokeScope::default(); let _first_invoke = scope .try_enter() .expect("first startup invoke should be admitted"); let first_turn = scope.serialize_peer_startup().await; let _second_invoke = scope .try_enter() .expect("second startup invoke should be admitted before shutdown"); let mut second_turn = Box::pin(scope.serialize_peer_startup()); assert!( tokio::time::timeout(Duration::from_millis(10), second_turn.as_mut()) .await .is_err(), "a later invoke must not overtake the first invoke's UI commit" ); drop(first_turn); tokio::time::timeout(Duration::from_secs(1), second_turn) .await .expect("the next invoke should run after the prior commit releases its turn"); } #[tokio::test] async fn cancelled_runtime_slot_wait_never_creates_an_untracked_runtime() { let slot = RwLock::new(None::<&'static str>); let occupied_slot = slot.write().await; let starts = AtomicU64::new(0); let mut start = Box::pin(start_runtime_in_slot(&slot, || { starts.fetch_add(1, Ordering::Relaxed); Ok("runtime") })); assert!( tokio::time::timeout(Duration::from_millis(10), start.as_mut()) .await .is_err(), "runtime start should wait for ownership-slot admission" ); drop(start); assert_eq!( starts.load(Ordering::Relaxed), 0, "cancelling the slot wait must happen before runtime creation" ); drop(occupied_slot); assert!(slot.read().await.is_none()); let published = start_runtime_in_slot(&slot, || { starts.fetch_add(1, Ordering::Relaxed); Ok("runtime") }) .await .expect("an admitted runtime should be published synchronously"); assert_eq!(published.as_ref().copied(), Some("runtime")); assert_eq!(starts.load(Ordering::Relaxed), 1); } #[tokio::test] async fn slow_warning_keeps_waiting_for_the_owned_future() { let (release_tx, release_rx) = tokio::sync::oneshot::channel(); let mut wait = tokio::spawn(async move { await_with_slow_warning( release_rx, Duration::from_millis(5), "controlled slow future", ) .await }); assert!( tokio::time::timeout(Duration::from_millis(20), &mut wait) .await .is_err(), "warning deadline must not drop the owned future" ); release_tx .send(()) .expect("controlled future should still be waiting"); wait.await .expect("wait task should not panic") .expect("controlled future should complete"); } #[test] fn setup_marker_boundary_accepts_only_zero_process_exit() { assert!(setup_process_exit_succeeded(0)); assert!(!setup_process_exit_succeeded(1)); assert!(!setup_process_exit_succeeded(u32::MAX)); } #[test] fn setup_launch_classification_never_owns_missing_or_invalid_handle() { assert_eq!( setup_launch_outcome("process handle", false, false), SetupLaunchOutcome::Owned("process handle") ); assert_eq!( setup_launch_outcome("null handle", true, true), SetupLaunchOutcome::SettledWithoutProcess ); assert_eq!( setup_launch_outcome("invalid handle", false, true), SetupLaunchOutcome::ContractViolation ); } #[test] fn setup_wait_error_retries_until_settlement_is_proven() { let calls = Mutex::new(Vec::new()); let termination_attempt = std::cell::Cell::new(0_u8); let observation_attempt = std::cell::Cell::new(0_u8); let error = settle_setup_after_wait_error( "initial wait failed".to_string(), || { calls .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .push("terminate"); let attempt = termination_attempt.get(); termination_attempt.set(attempt.saturating_add(1)); if attempt == 0 { Err("controlled termination failure".to_string()) } else { Ok(()) } }, || { calls .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .push("observe"); let attempt = observation_attempt.get(); observation_attempt.set(attempt.saturating_add(1)); if attempt == 0 { SetupProcessObservation::NotSettled( "controlled settlement wait failure".to_string(), ) } else { SetupProcessObservation::Settled } }, || { calls .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .push("retry"); }, ); assert_eq!( *calls .lock() .unwrap_or_else(std::sync::PoisonError::into_inner), ["terminate", "observe", "retry", "terminate", "observe"] ); assert!(error.contains("initial wait failed")); assert!(error.contains("controlled termination failure")); assert!(error.contains("controlled settlement wait failure")); assert!(error.contains("2 attempt(s)")); assert!(error.contains("now settled")); } #[test] fn setup_wait_error_keeps_exit_status_proof_as_a_hard_error() { let error = settle_setup_after_wait_error( "initial wait failed".to_string(), || Ok(()), || { SetupProcessObservation::SettledWithError( "wait failed but exit status proved termination".to_string(), ) }, || panic!("a proven-settled process must not retry"), ); assert!(error.contains("initial wait failed")); assert!(error.contains("exit status proved termination")); assert!(error.contains("now settled")); } #[test] fn unrar_log_stream_marks_bounded_capture_truncation() { assert_eq!( format_unrar_log_stream(b"partial", true, "stdout"), format!("partial\n[stdout truncated after {UNRAR_LOG_CAPTURE_LIMIT} bytes]") ); } fn registered_worker(registry: Arc>) -> RegisteredUnrarWorker { RegisteredUnrarWorker::new(registry, 1, CancellationToken::new()) } fn registered_unrar_count(registry: &Arc>) -> usize { lock_unrar_mutex(registry, "test child registry") .children .len() } #[test] fn dropping_registered_unrar_worker_cancels_and_deregisters_it() { let registry = Arc::new(Mutex::new(UnrarChildRegistry::default())); let worker = registered_worker(registry.clone()); let cancel_token = worker.cancel_token(); drop(worker); assert!(cancel_token.is_cancelled()); assert_eq!(registered_unrar_count(®istry), 0); } #[test] fn unrar_shutdown_cancels_existing_and_late_registrations() { let existing_registry = Arc::new(Mutex::new(UnrarChildRegistry::default())); let existing = registered_worker(existing_registry.clone()); let existing_cancel = existing.cancel_token(); begin_unrar_shutdown(&existing_registry); assert!(existing_cancel.is_cancelled()); drop(existing); let late_registry = Arc::new(Mutex::new(UnrarChildRegistry::default())); begin_unrar_shutdown(&late_registry); let late = registered_worker(late_registry.clone()); let late_cancel = late.cancel_token(); assert!(late_cancel.is_cancelled()); drop(late); assert_eq!(registered_unrar_count(&late_registry), 0); } 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 disk_catalog_bundle_fixture( game_id: &str, version: &str, ) -> ( PathBuf, PathBuf, CatalogBundle, lanspread_db::content_manifest::ContentId, ) { use std::collections::BTreeMap; use lanspread_db::content_manifest::{ Blake3Digest, CATALOG_CONTENT_INDEX_NAME, CatalogContentIndex, CatalogContentManifest, CatalogContentManifestBody, CatalogFileEntry, write_canonical_content_index_atomic, write_canonical_manifest_atomic, }; let digest = Blake3Digest::hash(version.as_bytes()); let manifest = CatalogContentManifest::seal( CatalogContentManifestBody::new( game_id, version, vec![ CatalogFileEntry::file( "version.ini", u64::try_from(version.len()).expect("version length should fit"), digest, vec![digest], ) .expect("catalog file should validate"), ], Vec::new(), ) .expect("catalog body should validate"), ) .expect("catalog manifest should seal"); let root = std::env::temp_dir().join(format!( "lanspread-tauri-remote-view-index-test-{}-{}", std::process::id(), SystemTime::now() .duration_since(UNIX_EPOCH) .expect("clock should be after epoch") .as_nanos() )); std::fs::create_dir(&root).expect("manifest root should be created"); let manifest_path = root.join(format!("{game_id}.json")); write_canonical_manifest_atomic(&manifest_path, &manifest) .expect("fixture manifest should be written canonically"); let index = CatalogContentIndex::from_manifests([&manifest]) .expect("fixture content index should validate"); write_canonical_content_index_atomic(&root.join(CATALOG_CONTENT_INDEX_NAME), &index) .expect("fixture content index should be written canonically"); let content_id = manifest.content_id(); let bundle = CatalogBundle::new( &root, BTreeMap::from([(game_id.to_owned(), version.to_owned())]), ) .expect("disk catalog bundle should validate index coverage"); (root, manifest_path, bundle, content_id) } 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(), } } #[tokio::test] async fn bundled_catalog_is_published_once_without_partial_replacement() { use lanspread_db::content_manifest::{ CATALOG_CONTENT_INDEX_NAME, CatalogContentIdentity, CatalogContentIndex, CatalogContentIndexEntry, ContentId, write_canonical_content_index_atomic, }; let root = std::env::temp_dir().join(format!( "lanspread-tauri-catalog-test-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .expect("clock should be after epoch") .as_nanos() )); std::fs::create_dir(&root).expect("manifest root should be created"); std::fs::write(root.join("alpha.json"), b"opaque fixture\n") .expect("manifest fixture should be written"); let index = CatalogContentIndex::from_entries([CatalogContentIndexEntry { game_id: "alpha".to_string(), game_version: "20200721".to_string(), identity: CatalogContentIdentity { content_id: ContentId::from_bytes([1; 32]), supports_streamed_install: false, }, }]) .expect("catalog fixture index should validate"); write_canonical_content_index_atomic(&root.join(CATALOG_CONTENT_INDEX_NAME), &index) .expect("catalog fixture index should be written"); let make_bundle = || { Arc::new( CatalogBundle::new( &root, std::collections::BTreeMap::from([( "alpha".to_string(), "20200721".to_string(), )]), ) .expect("catalog bundle should validate fixture coverage"), ) }; let state = LanSpreadState::default(); install_bundled_catalog_parts( &state, GameDB::from(vec![game_fixture("alpha", "Catalog Alpha")]), make_bundle(), ) .await .expect("first setup publication should succeed"); let error = install_bundled_catalog_parts( &state, GameDB::from(vec![game_fixture("beta", "Replacement Beta")]), make_bundle(), ) .await .expect_err("catalog setup must reject a second authority"); assert!(error.to_string().contains("initialized more than once")); assert!(state.games.read().await.get_game_by_id("alpha").is_some()); assert!(state.games.read().await.get_game_by_id("beta").is_none()); assert_eq!( state .catalog_bundle .get() .expect("first authority should remain installed") .catalog() .expected_version("alpha"), Some("20200721") ); std::fs::remove_dir_all(root).expect("catalog fixture should be removed"); } #[test] fn streamed_install_capability_comes_from_the_exact_catalog_manifest() { use lanspread_db::content_manifest::{ Blake3Digest, CatalogContentManifest, CatalogContentManifestBody, CatalogExtractedEntry, CatalogFileEntry, }; fn manifest(id: &str, supports_streamed_install: bool) -> CatalogContentManifest { let version = "20200721"; let version_digest = Blake3Digest::hash(version.as_bytes()); let streamed_install_files = supports_streamed_install .then(|| { CatalogExtractedEntry::file("account_name.txt", 4, Blake3Digest::hash(b"stub")) .expect("streamed install fixture should validate") }) .into_iter() .collect(); CatalogContentManifest::seal( CatalogContentManifestBody::new( id, version, vec![ CatalogFileEntry::file( "version.ini", u64::try_from(version.len()).expect("version length should fit u64"), version_digest, vec![version_digest], ) .expect("version fixture should validate"), ], streamed_install_files, ) .expect("catalog body should validate"), ) .expect("catalog manifest should seal") } let state = LanSpreadState::default(); state .catalog_bundle .set(Arc::new( CatalogBundle::from_manifests([ manifest("supported", true), manifest("unsupported", false), ]) .expect("catalog fixture should validate"), )) .expect("catalog fixture should initialize once"); assert_eq!( catalog_supports_streamed_install(&state, "supported"), Ok(true) ); assert_eq!( catalog_supports_streamed_install(&state, "unsupported"), Ok(false) ); assert!(catalog_supports_streamed_install(&state, "unknown").is_err()); } #[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::>(); 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::>(); 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(), } ); // cmd.exe metacharacters are stripped even inside a quoted argument; // everything else, including spaces and non-ASCII letters, survives. assert_eq!( launch_settings("en", "Jörg & Co | x > y < z ^ !"), LaunchSettings { language: "en".to_string(), username: "Jörg Co x y z !".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 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_view_joins_only_exact_catalog_content() { use lanspread_db::content_manifest::{ Blake3Digest, CatalogContentManifest, CatalogContentManifestBody, CatalogFileEntry, ContentId, }; let version = "20200721"; let digest = Blake3Digest::hash(version.as_bytes()); let manifest = CatalogContentManifest::seal( CatalogContentManifestBody::new( "alpha", version, vec![ CatalogFileEntry::file( "version.ini", u64::try_from(version.len()).expect("version length should fit"), digest, vec![digest], ) .expect("catalog file should validate"), ], Vec::new(), ) .expect("catalog body should validate"), ) .expect("catalog manifest should seal"); let content_id = manifest.content_id(); let catalog_bundle = CatalogBundle::from_manifests([manifest]).expect("catalog fixture should validate"); 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]); apply_peer_remote_view( &mut game_db, &RemoteLibraryView { games: vec![ lanspread_peer::RemoteGameAvailability { game_id: "alpha".to_owned(), content_id, peer_count: 3, }, lanspread_peer::RemoteGameAvailability { game_id: "beta".to_owned(), content_id: ContentId::from_bytes([7; 32]), peer_count: 8, }, lanspread_peer::RemoteGameAvailability { game_id: "unknown".to_owned(), content_id: ContentId::from_bytes([9; 32]), peer_count: 1, }, ], }, &catalog_bundle, ); 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()); } #[test] fn peer_remote_view_uses_disk_index_without_loading_manifest_body() { use lanspread_db::content_manifest::ContentId; let game_id = "alpha"; let version = "20200721"; let (root, manifest_path, catalog_bundle, content_id) = disk_catalog_bundle_fixture(game_id, version); let identity = catalog_bundle .content_identity(game_id) .expect("fixture identity should be available from the compact index"); assert_eq!(identity.content_id, content_id); assert_eq!( catalog_bundle.catalog().expected_version(game_id), Some(version) ); assert!(catalog_bundle.cached_manifest(game_id).is_err()); std::fs::write(&manifest_path, b"broken after bundle construction\n") .expect("manifest body should become invalid for the adapter proof"); let mut game_db = GameDB::from(vec![game_fixture(game_id, "Catalog Alpha")]); apply_peer_remote_view( &mut game_db, &RemoteLibraryView { games: vec![lanspread_peer::RemoteGameAvailability { game_id: game_id.to_owned(), content_id: identity.content_id, peer_count: 3, }], }, &catalog_bundle, ); assert_eq!( game_db .get_game_by_id(game_id) .expect("catalog game should remain") .peer_count, 3 ); assert!(catalog_bundle.cached_manifest(game_id).is_err()); apply_peer_remote_view( &mut game_db, &RemoteLibraryView { games: vec![lanspread_peer::RemoteGameAvailability { game_id: game_id.to_owned(), content_id: ContentId::from_bytes([7; 32]), peer_count: 9, }], }, &catalog_bundle, ); assert_eq!( game_db .get_game_by_id(game_id) .expect("catalog game should remain") .peer_count, 0 ); assert!(catalog_bundle.cached_manifest(game_id).is_err()); assert!(catalog_bundle.manifest(game_id).is_err()); assert!(catalog_bundle.cached_manifest(game_id).is_err()); let _ = std::fs::remove_dir_all(root); } }