A native WM_DELETE_WINDOW reproduction completed the frontend drain and then failed with "window.destroy not allowed". All three windows lacked the destroy permission used by Tauri's onCloseRequested wrapper. Removing the listener had previously let a second click bypass that denied IPC; retaining the listener made every click fail. The frontend was not stuck waiting on its own listener. Grant window destruction to the three configured application windows. Keep the existing frontend drains, early peer cancellation, and final runtime/task joins. Test the application's actual generated RuntimeAuthority, including expanded plugin defaults, instead of assuming a mocked successful destroy proves access. Explicitly include generated capabilities and ACL manifests in the context constructor's rustc dependencies. The configured compiler cache reused the old library after a permission-only edit because Tauri's macro reads these files without recording compiler dependencies. A remove/restore native probe now rebuilds with the correct permission in both directions. Document the complete ownership chain and failure evidence. Add a PID-checked X11 WM_DELETE_WINDOW helper for repeatable close-button probes without killing the process or bypassing the frontend boundary. Test Plan: - Native baseline: destroy denied and process remained alive after one request. - Resolved-ACL regression failed before the permission fix and passes afterward. - Native main/companion close probes: one request per window; normal process exit. - Permission-only rebuild with compiler cache: normal exit in 197 ms. - Production executable: normal exit in 43 ms; QUIC port released and rebound. - just test: 797 passed on unchanged rerun after one initial subprocess fixture startup-marker timeout, before that test exercised cancellation. - just frontend-test: 99 passed. - just fmt, just clippy, just build, and git diff --cached --check: passed. - Native helper compiled with -Wall -Wextra -Werror. - Native probes ran on Linux X11/XWayland; Windows/macOS were not measured.
6608 lines
222 KiB
Rust
6608 lines
222 KiB
Rust
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<i32> {
|
|
windows_launch::elevated_worker_exit_code()
|
|
}
|
|
|
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
|
|
|
type OutboundTransfers =
|
|
Arc<RwLock<std::collections::HashMap<String, Vec<(u64, tokio_util::sync::CancellationToken)>>>>;
|
|
|
|
const OUTBOUND_TRANSFER_EMIT_DEBOUNCE: Duration = Duration::from_millis(100);
|
|
|
|
#[derive(Default)]
|
|
struct OutboundTransferEmitState {
|
|
scheduled: bool,
|
|
generation: u64,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct AppTaskScope {
|
|
cancel_token: CancellationToken,
|
|
tasks: TaskTracker,
|
|
admission: Arc<Mutex<AppTaskAdmission>>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct AppTaskAdmission {
|
|
closed: bool,
|
|
}
|
|
|
|
impl AppTaskScope {
|
|
fn cancel_token(&self) -> CancellationToken {
|
|
self.cancel_token.clone()
|
|
}
|
|
|
|
fn spawn<F>(&self, task: F)
|
|
where
|
|
F: std::future::Future<Output = ()> + 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<Mutex<AppInvokeAdmission>>,
|
|
peer_startup_serial: Arc<tokio::sync::Mutex<()>>,
|
|
}
|
|
|
|
#[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<AppInvokeGuard> {
|
|
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<AppInvokeGuard> {
|
|
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<bool>,
|
|
phase: LocalNetworkSharingPhase,
|
|
persistence_problem: Option<SharingPersistenceProblem>,
|
|
}
|
|
|
|
impl LocalNetworkSharingSnapshot {
|
|
const fn initial(
|
|
enabled: bool,
|
|
persistence_problem: Option<SharingPersistenceProblem>,
|
|
) -> 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<IdentityDiagnostic>,
|
|
}
|
|
|
|
#[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<String, GameTransferStatus>,
|
|
open_attempts: BTreeMap<String, DownloadAttemptId>,
|
|
}
|
|
|
|
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<String, GameTransferAttemptReceipt>,
|
|
}
|
|
|
|
impl GameTransferStatusStore {
|
|
fn snapshot(&self) -> GameTransferStatusSnapshot {
|
|
self.snapshot.clone()
|
|
}
|
|
|
|
fn begin(
|
|
&mut self,
|
|
attempt: &DownloadAttemptKey,
|
|
) -> Result<Option<GameTransferStatusSnapshot>, 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<DownloadVerificationActivity>,
|
|
) -> Result<Option<GameTransferStatusSnapshot>, 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<Option<GameTransferStatusSnapshot>, String> {
|
|
self.settle(attempt, None)
|
|
}
|
|
|
|
fn failed(
|
|
&mut self,
|
|
attempt: &DownloadAttemptKey,
|
|
reason: DownloadFailureReason,
|
|
) -> Result<Option<GameTransferStatusSnapshot>, 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<Option<GameTransferStatusSnapshot>, String> {
|
|
let visible_settled = self
|
|
.attempts
|
|
.iter()
|
|
.filter_map(|(id, receipt)| {
|
|
(receipt.terminal && self.snapshot.statuses.contains_key(id)).then_some(id.clone())
|
|
})
|
|
.collect::<Vec<_>>();
|
|
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<Option<GameTransferStatusSnapshot>, 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<GameTransferStatus>,
|
|
) -> Result<Option<GameTransferStatusSnapshot>, 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<u64, String> {
|
|
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<LocalNetworkSharingPhase>,
|
|
persistence_problem: Option<SharingPersistenceProblem>,
|
|
},
|
|
}
|
|
|
|
enum UiStateCommand {
|
|
MutateSharing {
|
|
mutation: SharingSnapshotMutation,
|
|
reply: oneshot::Sender<Result<LocalNetworkSharingSnapshot, String>>,
|
|
},
|
|
SetIdentityDiagnostic {
|
|
diagnostic: Option<IdentityDiagnostic>,
|
|
reply: oneshot::Sender<Result<IdentityDiagnosticSnapshot, String>>,
|
|
},
|
|
FencePeerEvents {
|
|
reply: oneshot::Sender<Result<LocalNetworkSharingSnapshot, String>>,
|
|
},
|
|
ResetGameTransferStatus {
|
|
reply: oneshot::Sender<Result<GameTransferStatusSnapshot, String>>,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct UiStateTx(UnboundedSender<UiStateCommand>);
|
|
|
|
struct InstallationIdentity<T = PeerIdentity> {
|
|
identity: Arc<T>,
|
|
durability: PeerIdentityDurability,
|
|
}
|
|
|
|
fn retain_installation_identity<T>(
|
|
slot: &mut Option<InstallationIdentity<T>>,
|
|
observed_identity: Arc<T>,
|
|
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<RwLock<Option<UnboundedSender<PeerCommand>>>>,
|
|
peer_runtime: Arc<RwLock<Option<PeerRuntimeHandle>>>,
|
|
local_peer_id: Arc<RwLock<Option<String>>>,
|
|
/// 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<RwLock<Option<InstallationIdentity>>>,
|
|
games: Arc<RwLock<GameDB>>,
|
|
active_operations: Arc<RwLock<HashMap<String, UiOperationKind>>>,
|
|
games_folder: Arc<RwLock<String>>,
|
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
|
/// Immutable catalog authority loaded before setup admits commands or
|
|
/// starts application-owned background work.
|
|
catalog_bundle: OnceLock<Arc<CatalogBundle>>,
|
|
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
|
|
state_dir: OnceLock<PathBuf>,
|
|
main_log_sink: OnceLock<MainLogSink>,
|
|
active_outbound_transfers: OutboundTransfers,
|
|
outbound_transfer_emit: Arc<RwLock<OutboundTransferEmitState>>,
|
|
/// 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<RwLock<ProtocolMismatchSnapshot>>,
|
|
/// 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<watch::Sender<LocalNetworkSharingSnapshot>>,
|
|
/// Redacted installation-identity diagnostic, also revisioned so a
|
|
/// listener-first frontend query cannot lose runtime-startup publication.
|
|
identity_diagnostic: OnceLock<watch::Sender<IdentityDiagnosticSnapshot>>,
|
|
/// 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<RwLock<GameTransferStatusStore>>,
|
|
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<Mutex<UnrarChildRegistry>>,
|
|
}
|
|
|
|
/// 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<u64, Weak<UnrarWorkerControl>>,
|
|
}
|
|
|
|
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<LauncherGame>,
|
|
active_operations: Vec<UiActiveOperation>,
|
|
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<i32>,
|
|
stdout: String,
|
|
stderr: String,
|
|
started_at_ms: u64,
|
|
finished_at_ms: u64,
|
|
success: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Serialize)]
|
|
struct MainLogLinePayload {
|
|
line: String,
|
|
level: String,
|
|
sequence: Option<u64>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct MainLogHistoryPayload {
|
|
contents: String,
|
|
last_sequence: u64,
|
|
}
|
|
|
|
struct SidecarUnpacker {
|
|
app_handle: AppHandle,
|
|
}
|
|
|
|
const MAX_UNPACK_LOGS: usize = 20;
|
|
const 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<Vec<UnpackLogEntry>> {
|
|
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<MainLogHistoryPayload> {
|
|
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<ProtocolMismatchSnapshot> {
|
|
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<LocalNetworkSharingSnapshot>, 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<LocalNetworkSharingSnapshot, String> {
|
|
let snapshot = *local_network_sharing_watch(state)?.borrow();
|
|
Ok(snapshot)
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn get_local_network_sharing(
|
|
state: tauri::State<'_, LanSpreadState>,
|
|
) -> Result<LocalNetworkSharingSnapshot, String> {
|
|
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<IdentityDiagnosticSnapshot, String> {
|
|
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<IdentityDiagnosticSnapshot, String> {
|
|
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<LocalNetworkSharingSnapshot, String> {
|
|
let (reply, result) = oneshot::channel();
|
|
app_handle
|
|
.state::<UiStateTx>()
|
|
.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<IdentityDiagnosticSnapshot, String> {
|
|
let diagnostic = identity_diagnostic_for_durability(durability);
|
|
let (reply, result) = oneshot::channel();
|
|
app_handle
|
|
.state::<UiStateTx>()
|
|
.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<IdentityDiagnostic> {
|
|
match durability {
|
|
PeerIdentityDurability::Ephemeral => Some(IdentityDiagnostic::Ephemeral),
|
|
PeerIdentityDurability::Persistent | PeerIdentityDurability::CallerProvided => None,
|
|
}
|
|
}
|
|
|
|
async fn fence_peer_events(app_handle: &AppHandle) -> Result<LocalNetworkSharingSnapshot, String> {
|
|
let (reply, result) = oneshot::channel();
|
|
app_handle
|
|
.state::<UiStateTx>()
|
|
.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<GameTransferStatusSnapshot, String> {
|
|
let (reply, result) = oneshot::channel();
|
|
app_handle
|
|
.state::<UiStateTx>()
|
|
.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<LocalNetworkSharingSnapshot, String> {
|
|
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<Option<String>> {
|
|
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<CallToPlayReceipt, String> {
|
|
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<bool, String> {
|
|
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<bool> {
|
|
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<bool, String> {
|
|
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<bool, String> {
|
|
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<bool, String> {
|
|
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<bool> {
|
|
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<bool> {
|
|
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<bool> {
|
|
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<bool> {
|
|
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<bool> {
|
|
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<PathBuf> {
|
|
if !is_single_component_game_id(id) {
|
|
log::warn!("Ignoring file viewer request for invalid game id: {id}");
|
|
return None;
|
|
}
|
|
if state
|
|
.inner()
|
|
.games
|
|
.read()
|
|
.await
|
|
.get_game_by_id(id)
|
|
.is_none()
|
|
{
|
|
log::warn!("Ignoring file viewer request for unknown game: {id}");
|
|
return None;
|
|
}
|
|
|
|
let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone());
|
|
let Ok(root) = games_folder.canonicalize() else {
|
|
log::warn!(
|
|
"Cannot open files because game directory is unavailable: {}",
|
|
games_folder.display()
|
|
);
|
|
return None;
|
|
};
|
|
let target = root.join(id);
|
|
let Ok(target) = target.canonicalize() else {
|
|
log::warn!(
|
|
"Cannot open files because game root is unavailable: {}",
|
|
target.display()
|
|
);
|
|
return None;
|
|
};
|
|
if !target.is_dir() || !target.starts_with(&root) {
|
|
log::warn!(
|
|
"Refusing to open file viewer outside game directory: {}",
|
|
target.display()
|
|
);
|
|
return None;
|
|
}
|
|
|
|
Some(target)
|
|
}
|
|
|
|
fn is_single_component_game_id(id: &str) -> bool {
|
|
let mut components = Path::new(id).components();
|
|
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
struct LaunchSettings {
|
|
language: String,
|
|
username: String,
|
|
}
|
|
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
fn launch_settings(language: &str, username: &str) -> LaunchSettings {
|
|
LaunchSettings {
|
|
language: sanitize_language(language),
|
|
username: sanitize_username(username),
|
|
}
|
|
}
|
|
|
|
fn install_settings(language: &str, username: &str) -> InstallSettings {
|
|
InstallSettings {
|
|
account_name: sanitize_username(username),
|
|
language: install_language(language),
|
|
}
|
|
}
|
|
|
|
fn install_language(language: &str) -> String {
|
|
match sanitize_language(language).as_str() {
|
|
"de" => "german".to_string(),
|
|
_ => "english".to_string(),
|
|
}
|
|
}
|
|
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
fn sanitize_language(language: &str) -> String {
|
|
match language.trim().to_ascii_lowercase().as_str() {
|
|
"de" => "de".to_string(),
|
|
"en" => "en".to_string(),
|
|
_ => DEFAULT_LANGUAGE.to_string(),
|
|
}
|
|
}
|
|
|
|
/// 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::<String>();
|
|
|
|
if cleaned.is_empty() {
|
|
DEFAULT_USERNAME.to_string()
|
|
} else {
|
|
cleaned
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<usize> {
|
|
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<String> {
|
|
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<Process> {
|
|
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,
|
|
process_is_null: bool,
|
|
process_is_invalid: bool,
|
|
) -> SetupLaunchOutcome<Process> {
|
|
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<Terminate, Observe, Retry>(
|
|
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<u32, String> {
|
|
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::<Vec<_>>();
|
|
let params_wide = params.encode_wide().chain(Some(0)).collect::<Vec<_>>();
|
|
let dir_wide = dir.encode_wide().chain(Some(0)).collect::<Vec<_>>();
|
|
let runas_wide = OsStr::new("runas")
|
|
.encode_wide()
|
|
.chain(Some(0))
|
|
.collect::<Vec<_>>();
|
|
let mut execute_info = SHELLEXECUTEINFOW {
|
|
cbSize: u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>())
|
|
.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<Arc<CatalogContentManifest>, 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<bool> {
|
|
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<bool> {
|
|
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::<HashSet<_>>();
|
|
|
|
for local_game in local_games {
|
|
if let Some(existing_game) = game_db.get_mut_game_by_id(&local_game.id) {
|
|
apply_peer_local_state(existing_game, local_game);
|
|
log::debug!("Updated local game status for: {}", local_game.id);
|
|
}
|
|
}
|
|
|
|
for game in game_db.games.values_mut() {
|
|
if !local_game_ids.contains(&game.id) && has_local_game_state(game) {
|
|
log::info!(
|
|
"Game {} missing from peer local snapshot; marking as unavailable locally",
|
|
game.id
|
|
);
|
|
clear_local_game_state(game);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn apply_peer_remote_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<F>(
|
|
app_handle: &AppHandle,
|
|
game_id: &str,
|
|
mutation: F,
|
|
) -> Result<Option<GameTransferStatusSnapshot>, String>
|
|
where
|
|
F: FnOnce(&mut GameTransferStatusStore) -> Result<Option<GameTransferStatusSnapshot>, String>,
|
|
{
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
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::<LanSpreadState>();
|
|
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<GameTransferStatusSnapshot, String> {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
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::<LanSpreadState>();
|
|
|
|
let games_db_lock = state.games.clone();
|
|
let game_db = games_db_lock.read().await;
|
|
let games_folder = state.games_folder.read().await.clone();
|
|
|
|
if game_db.games.is_empty() {
|
|
log::debug!("Game database empty; skipping emit");
|
|
return;
|
|
}
|
|
|
|
let active_transfers = state.active_outbound_transfers.read().await;
|
|
|
|
let games_to_emit = game_db
|
|
.all_games()
|
|
.into_iter()
|
|
.cloned()
|
|
.map(|game| {
|
|
let active_outbound_transfers = active_transfers.get(&game.id).map_or(0, Vec::len);
|
|
LauncherGame {
|
|
can_host_server: game_can_host_server(&games_folder, &game),
|
|
active_outbound_transfers,
|
|
installed_peer_count: game.peer_count,
|
|
game,
|
|
}
|
|
})
|
|
.collect::<Vec<LauncherGame>>();
|
|
|
|
drop(game_db);
|
|
drop(active_transfers);
|
|
|
|
let active_operations = {
|
|
let active_operations = state.active_operations.read().await;
|
|
ui_active_operations_from_map(&active_operations)
|
|
};
|
|
let 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<String, UiOperationKind>,
|
|
) -> Vec<UiActiveOperation> {
|
|
let mut snapshot = active_operations
|
|
.iter()
|
|
.map(|(id, operation)| UiActiveOperation {
|
|
id: id.clone(),
|
|
operation: *operation,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
snapshot.sort_by(|left, right| left.id.cmp(&right.id));
|
|
snapshot
|
|
}
|
|
|
|
fn reconcile_active_operations(
|
|
active_operations: &mut HashMap<String, UiOperationKind>,
|
|
snapshot: &[ActiveOperation],
|
|
) {
|
|
active_operations.clear();
|
|
active_operations.extend(snapshot.iter().map(|operation| {
|
|
(
|
|
operation.id.clone(),
|
|
ui_operation_from_peer(operation.operation),
|
|
)
|
|
}));
|
|
}
|
|
|
|
fn ui_operation_from_peer(operation: ActiveOperationKind) -> UiOperationKind {
|
|
match operation {
|
|
ActiveOperationKind::Downloading => UiOperationKind::Downloading,
|
|
ActiveOperationKind::Installing => UiOperationKind::Installing,
|
|
ActiveOperationKind::Updating => UiOperationKind::Updating,
|
|
ActiveOperationKind::Uninstalling => UiOperationKind::Uninstalling,
|
|
ActiveOperationKind::RemovingDownload => UiOperationKind::RemovingDownload,
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn game_directory_exists(
|
|
path: String,
|
|
state: tauri::State<'_, LanSpreadState>,
|
|
) -> tauri::Result<bool> {
|
|
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<bool, String>,
|
|
persistence: Result<(), String>,
|
|
force_stopped: bool,
|
|
}
|
|
|
|
fn network_admission_closed_after(
|
|
snapshot: LocalNetworkSharingSnapshot,
|
|
baseline_revision: u64,
|
|
) -> Option<NetworkAdmissionClosed> {
|
|
(snapshot.revision > baseline_revision
|
|
&& matches!(
|
|
snapshot.phase,
|
|
LocalNetworkSharingPhase::Disabling | LocalNetworkSharingPhase::Disabled
|
|
))
|
|
.then_some(NetworkAdmissionClosed)
|
|
}
|
|
|
|
fn after_network_admission_closed<R>(
|
|
_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<C, S, SF, P>(
|
|
baseline_revision: u64,
|
|
mut changes: watch::Receiver<LocalNetworkSharingSnapshot>,
|
|
command: C,
|
|
force_stop: S,
|
|
persist: P,
|
|
) -> DisabledRuntimeTransition
|
|
where
|
|
C: std::future::Future<Output = Result<bool, String>>,
|
|
S: FnOnce() -> SF,
|
|
SF: std::future::Future<Output = ()>,
|
|
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<PeerCommand>,
|
|
enabled: bool,
|
|
) -> Result<bool, String> {
|
|
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::<LanSpreadState>();
|
|
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<LocalNetworkSharingPhase>,
|
|
persistence_problem: Option<SharingPersistenceProblem>,
|
|
) -> Result<LocalNetworkSharingSnapshot, String> {
|
|
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<LocalNetworkSharingSnapshot, String> {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
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<LocalNetworkSharingSnapshot, String> {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
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<LocalNetworkSharingSnapshot, String> {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
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<PeerCommand>,
|
|
requested: bool,
|
|
_before: LocalNetworkSharingSnapshot,
|
|
) -> Result<LocalNetworkSharingSnapshot, String> {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
// 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<LocalNetworkSharingSnapshot, String> {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
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<String, String> {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
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::<Vec<_>>();
|
|
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::<LanSpreadState>();
|
|
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<Game>, app: AppHandle) {
|
|
let state = app.state::<LanSpreadState>();
|
|
|
|
// 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<UnrarPaths> {
|
|
let original_archive = rar_file.display().to_string();
|
|
let original_destination = dest_dir.display().to_string();
|
|
|
|
if let Err(err) = std::fs::create_dir_all(dest_dir) {
|
|
let stderr = format!("failed to create directory {}: {err}", dest_dir.display());
|
|
record_unpack_failure(
|
|
app_handle,
|
|
original_archive,
|
|
original_destination,
|
|
started_at_ms,
|
|
stderr.clone(),
|
|
)
|
|
.await;
|
|
bail!("{stderr}");
|
|
}
|
|
|
|
let rar_file = match rar_file.canonicalize() {
|
|
Ok(path) => path,
|
|
Err(err) => {
|
|
let stderr = format!(
|
|
"rar_file canonicalize failed for {}: {err}",
|
|
rar_file.display()
|
|
);
|
|
record_unpack_failure(
|
|
app_handle,
|
|
original_archive,
|
|
original_destination,
|
|
started_at_ms,
|
|
stderr.clone(),
|
|
)
|
|
.await;
|
|
bail!("{stderr}");
|
|
}
|
|
};
|
|
let dest_dir = match dest_dir.canonicalize() {
|
|
Ok(path) => path,
|
|
Err(err) => {
|
|
let stderr = format!(
|
|
"dest_dir canonicalize failed for {}: {err}",
|
|
dest_dir.display()
|
|
);
|
|
record_unpack_failure(
|
|
app_handle,
|
|
rar_file.display().to_string(),
|
|
original_destination,
|
|
started_at_ms,
|
|
stderr.clone(),
|
|
)
|
|
.await;
|
|
bail!("{stderr}");
|
|
}
|
|
};
|
|
let Some(dest_dir_arg) = dest_dir.to_str().map(add_final_slash) else {
|
|
let stderr = format!("failed to get str of dest_dir {}", dest_dir.display());
|
|
record_unpack_failure(
|
|
app_handle,
|
|
rar_file.display().to_string(),
|
|
dest_dir.display().to_string(),
|
|
started_at_ms,
|
|
stderr.clone(),
|
|
)
|
|
.await;
|
|
bail!("{stderr}");
|
|
};
|
|
|
|
Ok(UnrarPaths {
|
|
archive: rar_file,
|
|
destination: dest_dir,
|
|
destination_arg: dest_dir_arg,
|
|
})
|
|
}
|
|
|
|
async fn run_unrar_sidecar(
|
|
app_handle: &AppHandle,
|
|
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::<LanSpreadState>()
|
|
.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<Mutex<UnrarChildRegistry>>,
|
|
child_id: u64,
|
|
control: Arc<UnrarWorkerControl>,
|
|
armed: bool,
|
|
}
|
|
|
|
impl RegisteredUnrarWorker {
|
|
fn new(
|
|
registry: Arc<Mutex<UnrarChildRegistry>>,
|
|
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<T>, 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::<LanSpreadState>();
|
|
begin_unrar_shutdown(&state.active_unrar_children);
|
|
}
|
|
|
|
fn begin_unrar_shutdown(registry: &Arc<Mutex<UnrarChildRegistry>>) {
|
|
let children = {
|
|
let mut guard = lock_unrar_mutex(registry, "child registry");
|
|
guard.shutting_down = true;
|
|
guard
|
|
.children
|
|
.values()
|
|
.filter_map(Weak::upgrade)
|
|
.collect::<Vec<_>>()
|
|
};
|
|
|
|
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::<LanSpreadState>();
|
|
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<UnpackLogEntry>) {
|
|
if logs.len() > MAX_UNPACK_LOGS {
|
|
let overflow = logs.len() - MAX_UNPACK_LOGS;
|
|
logs.drain(..overflow);
|
|
}
|
|
}
|
|
|
|
fn clean_unpack_log_entry(entry: &mut UnpackLogEntry) {
|
|
let stdout = clean_terminal_log(&entry.stdout);
|
|
let stderr = clean_terminal_log(&entry.stderr);
|
|
entry.stdout = stdout;
|
|
entry.stderr = stderr;
|
|
}
|
|
|
|
fn clean_terminal_log(input: &str) -> String {
|
|
let mut output = String::new();
|
|
let mut line = String::new();
|
|
let mut chars = input.chars().peekable();
|
|
|
|
while let Some(ch) = chars.next() {
|
|
match ch {
|
|
'\r' if chars.peek() == Some(&'\n') => {
|
|
let _ = chars.next();
|
|
output.push_str(&line);
|
|
output.push('\n');
|
|
line.clear();
|
|
}
|
|
'\r' => {
|
|
line.clear();
|
|
}
|
|
'\n' => {
|
|
output.push_str(&line);
|
|
output.push('\n');
|
|
line.clear();
|
|
}
|
|
'\u{8}' => {
|
|
let _ = line.pop();
|
|
}
|
|
'\t' => line.push(ch),
|
|
ch if ch.is_control() => {}
|
|
ch => line.push(ch),
|
|
}
|
|
}
|
|
|
|
output.push_str(&line);
|
|
output
|
|
}
|
|
|
|
fn unpack_logs_path(state_dir: &Path) -> PathBuf {
|
|
state_dir.join(UNPACK_LOGS_FILE_NAME)
|
|
}
|
|
|
|
fn main_log_path(state_dir: &Path) -> PathBuf {
|
|
state_dir.join(MAIN_LOG_FILE_NAME)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn trim_main_log_file_to_limit(path: &Path, max_bytes: u64) -> io::Result<()> {
|
|
let mut file = match OpenOptions::new().read(true).write(true).open(path) {
|
|
Ok(file) => file,
|
|
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
|
|
Err(err) => return Err(err),
|
|
};
|
|
|
|
trim_main_log_file_to_limit_with_file(&mut file, max_bytes)
|
|
}
|
|
|
|
fn trim_main_log_file_to_limit_with_file(file: &mut fs::File, max_bytes: u64) -> io::Result<()> {
|
|
let metadata = file.metadata()?;
|
|
|
|
if metadata.len() <= max_bytes {
|
|
file.seek(SeekFrom::End(0))?;
|
|
return Ok(());
|
|
}
|
|
|
|
let tail = if max_bytes == 0 {
|
|
String::new()
|
|
} else {
|
|
file.seek(SeekFrom::Start(metadata.len() - max_bytes))?;
|
|
|
|
let mut bytes = Vec::with_capacity(usize::try_from(max_bytes).unwrap_or(usize::MAX));
|
|
file.read_to_end(&mut bytes)?;
|
|
valid_utf8_tail(bytes)
|
|
};
|
|
|
|
file.set_len(0)?;
|
|
file.seek(SeekFrom::Start(0))?;
|
|
file.write_all(tail.as_bytes())?;
|
|
file.seek(SeekFrom::End(0))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn read_main_log_file_to_limit(path: &Path, max_bytes: u64) -> io::Result<String> {
|
|
let mut file = fs::File::open(path)?;
|
|
read_main_log_file_to_limit_with_file(&mut file, max_bytes)
|
|
}
|
|
|
|
fn read_main_log_file_to_limit_with_file(
|
|
file: &mut fs::File,
|
|
max_bytes: u64,
|
|
) -> io::Result<String> {
|
|
let metadata = file.metadata()?;
|
|
if metadata.len() == 0 || max_bytes == 0 {
|
|
file.seek(SeekFrom::End(0))?;
|
|
return Ok(String::new());
|
|
}
|
|
|
|
let start = metadata.len().saturating_sub(max_bytes);
|
|
file.seek(SeekFrom::Start(start))?;
|
|
|
|
let capacity = usize::try_from(metadata.len() - start).unwrap_or(usize::MAX);
|
|
let mut bytes = Vec::with_capacity(capacity);
|
|
file.read_to_end(&mut bytes)?;
|
|
file.seek(SeekFrom::End(0))?;
|
|
|
|
if start == 0 {
|
|
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
|
} else {
|
|
Ok(valid_utf8_tail(bytes))
|
|
}
|
|
}
|
|
|
|
fn valid_utf8_tail(bytes: Vec<u8>) -> String {
|
|
for offset in 0..bytes.len().min(4) {
|
|
if let Ok(tail) = std::str::from_utf8(&bytes[offset..]) {
|
|
return tail.to_string();
|
|
}
|
|
}
|
|
|
|
String::from_utf8_lossy(&bytes).into_owned()
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct MainLogSink {
|
|
app_handle: AppHandle,
|
|
path: PathBuf,
|
|
file_state: Arc<Mutex<MainLogFileState>>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct MainLogFileState {
|
|
file: Option<fs::File>,
|
|
last_sequence: u64,
|
|
}
|
|
|
|
impl MainLogSink {
|
|
fn new(app_handle: AppHandle, path: PathBuf) -> Self {
|
|
Self {
|
|
app_handle,
|
|
path,
|
|
file_state: Arc::new(Mutex::new(MainLogFileState::default())),
|
|
}
|
|
}
|
|
|
|
fn write_line(&self, line: String, level: Level) {
|
|
write_main_log_stdout(&line);
|
|
let sequence = self.append_file_line(&line);
|
|
|
|
let _ = self.app_handle.emit(
|
|
"main-log-line",
|
|
MainLogLinePayload {
|
|
line,
|
|
level: level.as_str().to_string(),
|
|
sequence,
|
|
},
|
|
);
|
|
}
|
|
|
|
fn read_history(&self) -> io::Result<MainLogHistoryPayload> {
|
|
let mut file_state = self
|
|
.file_state
|
|
.lock()
|
|
.map_err(|_| io::Error::other("main log file lock poisoned"))?;
|
|
|
|
if file_state.file.is_none() && !self.path.exists() {
|
|
return Ok(MainLogHistoryPayload {
|
|
contents: String::new(),
|
|
last_sequence: file_state.last_sequence,
|
|
});
|
|
}
|
|
|
|
let contents = {
|
|
let file = self.cached_file(&mut file_state.file)?;
|
|
trim_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES)?;
|
|
read_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES)?
|
|
};
|
|
|
|
Ok(MainLogHistoryPayload {
|
|
contents,
|
|
last_sequence: file_state.last_sequence,
|
|
})
|
|
}
|
|
|
|
fn append_file_line(&self, line: &str) -> Option<u64> {
|
|
let Ok(mut file_state) = self.file_state.lock() else {
|
|
return None;
|
|
};
|
|
|
|
let write_result = self.cached_file(&mut file_state.file).and_then(|file| {
|
|
file.seek(SeekFrom::End(0))
|
|
.and_then(|_| writeln!(file, "{line}"))
|
|
});
|
|
|
|
if write_result.is_err() {
|
|
file_state.file = None;
|
|
return None;
|
|
}
|
|
|
|
file_state.last_sequence = file_state.last_sequence.saturating_add(1);
|
|
let sequence = file_state.last_sequence;
|
|
|
|
let should_trim = file_state.file.as_ref().is_some_and(|file| {
|
|
file.metadata().is_ok_and(|metadata| {
|
|
metadata.len() > MAX_MAIN_LOG_BYTES.saturating_add(MAIN_LOG_TRIM_SLACK_BYTES)
|
|
})
|
|
});
|
|
|
|
if should_trim && let Some(file) = file_state.file.as_mut() {
|
|
let _ = trim_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES);
|
|
}
|
|
|
|
Some(sequence)
|
|
}
|
|
|
|
fn cached_file<'a>(&self, file: &'a mut Option<fs::File>) -> io::Result<&'a mut fs::File> {
|
|
if file.is_none() {
|
|
*file = Some(open_main_log_file(&self.path)?);
|
|
}
|
|
|
|
file.as_mut()
|
|
.ok_or_else(|| io::Error::other("main log file was not opened"))
|
|
}
|
|
}
|
|
|
|
fn open_main_log_file(path: &Path) -> io::Result<fs::File> {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
OpenOptions::new()
|
|
.create(true)
|
|
.truncate(false)
|
|
.read(true)
|
|
.write(true)
|
|
.open(path)
|
|
}
|
|
|
|
struct MainLogLayer {
|
|
sink: MainLogSink,
|
|
}
|
|
|
|
impl MainLogLayer {
|
|
fn new(sink: MainLogSink) -> Self {
|
|
Self { sink }
|
|
}
|
|
}
|
|
|
|
impl<S> Layer<S> for MainLogLayer
|
|
where
|
|
S: Subscriber + for<'span> LookupSpan<'span>,
|
|
{
|
|
fn enabled(&self, metadata: &Metadata<'_>, _ctx: Context<'_, S>) -> bool {
|
|
should_capture_main_log_metadata(metadata)
|
|
}
|
|
|
|
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
|
let metadata = event.metadata();
|
|
if !should_capture_main_log_metadata(metadata) {
|
|
return;
|
|
}
|
|
|
|
let mut visitor = MainLogFieldVisitor::default();
|
|
event.record(&mut visitor);
|
|
let target = visitor
|
|
.log_target
|
|
.clone()
|
|
.unwrap_or_else(|| metadata.target().to_string());
|
|
let message = visitor.into_message();
|
|
let (date, time) = current_main_log_timestamp();
|
|
let line =
|
|
format_main_log_line_parts(&date, &time, &target, metadata.level().as_str(), &message);
|
|
|
|
self.sink.write_line(line, *metadata.level());
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct MainLogFieldVisitor {
|
|
message: Option<String>,
|
|
log_target: Option<String>,
|
|
fields: Vec<String>,
|
|
}
|
|
|
|
impl MainLogFieldVisitor {
|
|
fn record_value(&mut self, field_name: &str, value: String) {
|
|
match field_name {
|
|
"message" => self.message = Some(value),
|
|
"log.target" => self.log_target = Some(value),
|
|
"log.module_path" | "log.file" | "log.line" => {}
|
|
_ => self.fields.push(format!("{field_name}={value}")),
|
|
}
|
|
}
|
|
|
|
fn into_message(self) -> String {
|
|
let mut parts = Vec::new();
|
|
if let Some(message) = self.message
|
|
&& !message.is_empty()
|
|
{
|
|
parts.push(message);
|
|
}
|
|
parts.extend(self.fields);
|
|
|
|
if parts.is_empty() {
|
|
String::from("(no message)")
|
|
} else {
|
|
normalize_main_log_message(&parts.join(" "))
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Visit for MainLogFieldVisitor {
|
|
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
|
|
self.record_value(field.name(), value.to_string());
|
|
}
|
|
|
|
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
|
|
self.record_value(field.name(), value.to_string());
|
|
}
|
|
|
|
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
|
|
self.record_value(field.name(), value.to_string());
|
|
}
|
|
|
|
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
|
self.record_value(field.name(), value.to_string());
|
|
}
|
|
|
|
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
|
self.record_value(field.name(), format!("{value:?}"));
|
|
}
|
|
}
|
|
|
|
fn should_capture_main_log_metadata(metadata: &Metadata<'_>) -> bool {
|
|
if metadata.target().starts_with("mdns_sd::service_daemon") {
|
|
return false;
|
|
}
|
|
|
|
matches!(*metadata.level(), Level::ERROR | Level::WARN | Level::INFO)
|
|
}
|
|
|
|
fn current_main_log_timestamp() -> (String, String) {
|
|
let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
|
|
let date = now.date();
|
|
let clock = now.time();
|
|
|
|
(
|
|
format!(
|
|
"{:04}-{:02}-{:02}",
|
|
date.year(),
|
|
u8::from(date.month()),
|
|
date.day()
|
|
),
|
|
format!(
|
|
"{:02}:{:02}:{:02}",
|
|
clock.hour(),
|
|
clock.minute(),
|
|
clock.second()
|
|
),
|
|
)
|
|
}
|
|
|
|
fn format_main_log_line_parts(
|
|
date: &str,
|
|
time: &str,
|
|
target: &str,
|
|
level: &str,
|
|
message: &str,
|
|
) -> String {
|
|
format!(
|
|
"[{date}][{time}][{}][{level}] {}",
|
|
normalize_main_log_target(target),
|
|
normalize_main_log_message(message)
|
|
)
|
|
}
|
|
|
|
fn normalize_main_log_target(target: &str) -> String {
|
|
target.replace(['\r', '\n'], " ")
|
|
}
|
|
|
|
fn normalize_main_log_message(message: &str) -> String {
|
|
message.replace('\r', "\\r").replace('\n', "\\n")
|
|
}
|
|
|
|
fn write_main_log_stdout(line: &str) {
|
|
let stdout = std::io::stdout();
|
|
let mut stdout = stdout.lock();
|
|
let _ = writeln!(stdout, "{line}");
|
|
}
|
|
|
|
fn init_main_logging(sink: MainLogSink) -> Result<(), Box<dyn std::error::Error>> {
|
|
let subscriber = tracing_subscriber::registry().with(MainLogLayer::new(sink));
|
|
tracing::subscriber::set_global_default(subscriber)?;
|
|
tracing_log::LogTracer::builder()
|
|
.with_max_level(log::LevelFilter::Info)
|
|
.init()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn load_unpack_logs(state_dir: &Path) -> Vec<UnpackLogEntry> {
|
|
let path = unpack_logs_path(state_dir);
|
|
let contents = match std::fs::read_to_string(&path) {
|
|
Ok(contents) => contents,
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
|
|
Err(err) => {
|
|
log::warn!("Failed to read unpack logs from {}: {err}", path.display());
|
|
return Vec::new();
|
|
}
|
|
};
|
|
|
|
let mut logs = match serde_json::from_str::<Vec<UnpackLogEntry>>(&contents) {
|
|
Ok(logs) => logs,
|
|
Err(err) => {
|
|
log::warn!("Failed to parse unpack logs from {}: {err}", path.display());
|
|
return Vec::new();
|
|
}
|
|
};
|
|
logs.iter_mut().for_each(clean_unpack_log_entry);
|
|
trim_unpack_logs(&mut logs);
|
|
logs
|
|
}
|
|
|
|
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<LoadedCatalog> {
|
|
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<CatalogBundle>,
|
|
) -> 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<Runtime, Start>(
|
|
slot: &RwLock<Option<Runtime>>,
|
|
start: Start,
|
|
) -> Result<tokio::sync::RwLockWriteGuard<'_, Option<Runtime>>, String>
|
|
where
|
|
Start: FnOnce() -> Result<Runtime, String>,
|
|
{
|
|
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<PathBuf, String> {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
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::<PeerEventTx>().inner().0.clone();
|
|
let unpacker = Arc::new(SidecarUnpacker {
|
|
app_handle: app_handle.clone(),
|
|
});
|
|
let stream_install_provider = stream_install_provider_for_app(app_handle);
|
|
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<dyn StreamInstallProvider> {
|
|
match resolve_unrar_sidecar_program(app_handle) {
|
|
Ok(program) => Arc::new(ExternalUnrarStreamProvider::new(program)),
|
|
Err(err) => {
|
|
log::error!("Failed to resolve streamed-install unrar sidecar: {err}");
|
|
Arc::new(NoopStreamInstallProvider)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn resolve_unrar_sidecar_program(app_handle: &AppHandle) -> eyre::Result<PathBuf> {
|
|
let sidecar = app_handle.shell().sidecar("unrar")?;
|
|
let command: std::process::Command = sidecar.into();
|
|
Ok(PathBuf::from(command.get_program()))
|
|
}
|
|
|
|
fn emit_game_id_event(app_handle: &AppHandle, event: &str, id: &str, label: &str) {
|
|
if let Err(e) = app_handle.emit(event, Some(id.to_owned())) {
|
|
log::error!("{label}: Failed to emit {event} event: {e}");
|
|
}
|
|
}
|
|
|
|
fn spawn_peer_event_loop(
|
|
app_handle: AppHandle,
|
|
mut rx_peer_event: PeerEventReceiver,
|
|
mut rx_ui_state: UnboundedReceiver<UiStateCommand>,
|
|
) {
|
|
let tasks = app_handle
|
|
.state::<LanSpreadState>()
|
|
.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<LocalNetworkSharingSnapshot, String> {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
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<LocalNetworkSharingSnapshot, String> {
|
|
let current = current_local_network_sharing(app_handle.state::<LanSpreadState>().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<LocalNetworkSharingSnapshot, String> {
|
|
let current = current_local_network_sharing(app_handle.state::<LanSpreadState>().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<IdentityDiagnostic>,
|
|
) -> Result<IdentityDiagnosticSnapshot, String> {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
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<T>(
|
|
receiver: &mut UnboundedReceiver<T>,
|
|
count: usize,
|
|
) -> Result<Vec<T>, 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<LocalNetworkSharingSnapshot, String> {
|
|
drain_queued_peer_events(app_handle, receiver).await?;
|
|
current_local_network_sharing(app_handle.state::<LanSpreadState>().inner())
|
|
}
|
|
|
|
async fn reset_game_transfer_status_in_loop(
|
|
app_handle: &AppHandle,
|
|
receiver: &mut PeerEventReceiver,
|
|
) -> Result<GameTransferStatusSnapshot, String> {
|
|
// 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::<LanSpreadState>();
|
|
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::<LanSpreadState>();
|
|
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::<LanSpreadState>();
|
|
state
|
|
.outbound_transfer_emit
|
|
.read()
|
|
.await
|
|
.observed_generation()
|
|
};
|
|
emit_games_list(&app_handle).await;
|
|
|
|
let needs_follow_up_emit = {
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
let mut emit_state = state.outbound_transfer_emit.write().await;
|
|
emit_state.finish_emit(observed_generation)
|
|
};
|
|
if !needs_follow_up_emit {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|
match event {
|
|
PeerEvent::LocalPeerReady { peer_id, addr } => {
|
|
log::info!("Local peer ready: {peer_id} at {addr}");
|
|
let authoritative_peer_id = app_handle
|
|
.state::<LanSpreadState>()
|
|
.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::<LanSpreadState>();
|
|
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::<LanSpreadState>();
|
|
{
|
|
let mut ui_active_operations = state.active_operations.write().await;
|
|
reconcile_active_operations(&mut ui_active_operations, &active_operations);
|
|
}
|
|
emit_games_list(app_handle).await;
|
|
}
|
|
PeerEvent::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::<LanSpreadState>().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<u32>,
|
|
expected: u32,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
|
struct ProtocolMismatchSnapshot {
|
|
revision: u64,
|
|
mismatch: Option<ProtocolMismatch>,
|
|
}
|
|
|
|
fn application_context() -> tauri::Context<tauri::Wry> {
|
|
// 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::<UiStateCommand>();
|
|
|
|
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::<LanSpreadState>();
|
|
if state.main_log_sink.set(main_log_sink.clone()).is_err() {
|
|
log::warn!("main log sink was already initialized");
|
|
}
|
|
init_main_logging(main_log_sink)?;
|
|
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::<LanSpreadState>();
|
|
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::<LanSpreadState>();
|
|
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<F>(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<Mutex<UnrarChildRegistry>>) -> RegisteredUnrarWorker {
|
|
RegisteredUnrarWorker::new(registry, 1, CancellationToken::new())
|
|
}
|
|
|
|
fn registered_unrar_count(registry: &Arc<Mutex<UnrarChildRegistry>>) -> 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::<Vec<_>>();
|
|
|
|
trim_unpack_logs(&mut logs);
|
|
|
|
assert_eq!(logs.len(), MAX_UNPACK_LOGS);
|
|
assert_eq!(
|
|
logs.first().map(|entry| entry.archive.as_str()),
|
|
Some("archive-5.rar")
|
|
);
|
|
assert_eq!(
|
|
logs.last().map(|entry| entry.archive.as_str()),
|
|
Some("archive-24.rar")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unpack_logs_load_from_app_state_dir_and_apply_retention() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"lanspread-unpack-logs-test-{}",
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("clock should be after epoch")
|
|
.as_nanos()
|
|
));
|
|
std::fs::create_dir_all(&root).expect("test state dir should be created");
|
|
let logs = (0..25).map(unpack_log_fixture).collect::<Vec<_>>();
|
|
std::fs::write(
|
|
unpack_logs_path(&root),
|
|
serde_json::to_vec(&logs).expect("logs should serialize"),
|
|
)
|
|
.expect("logs should be written");
|
|
|
|
let loaded = load_unpack_logs(&root);
|
|
|
|
assert_eq!(loaded.len(), MAX_UNPACK_LOGS);
|
|
assert_eq!(
|
|
loaded.first().map(|entry| entry.archive.as_str()),
|
|
Some("archive-5.rar")
|
|
);
|
|
assert_eq!(
|
|
loaded.last().map(|entry| entry.archive.as_str()),
|
|
Some("archive-24.rar")
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(root);
|
|
}
|
|
|
|
#[test]
|
|
fn main_log_line_format_is_stable_and_single_line() {
|
|
let line = format_main_log_line_parts(
|
|
"2026-06-07",
|
|
"12:34:56",
|
|
"lanspread\napp",
|
|
"WARN",
|
|
"first line\nsecond line",
|
|
);
|
|
|
|
assert_eq!(
|
|
line,
|
|
"[2026-06-07][12:34:56][lanspread app][WARN] first line\\nsecond line"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn main_log_trim_keeps_utf8_tail_at_char_boundary() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"lanspread-main-log-test-{}",
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("clock should be after epoch")
|
|
.as_nanos()
|
|
));
|
|
std::fs::create_dir_all(&root).expect("test state dir should be created");
|
|
let path = main_log_path(&root);
|
|
std::fs::write(&path, format!("{}{}", "a".repeat(11), "é".repeat(20)))
|
|
.expect("main log should be written");
|
|
|
|
trim_main_log_file_to_limit(&path, 21).expect("main log should trim");
|
|
|
|
let trimmed = std::fs::read_to_string(&path).expect("trimmed log should remain utf-8");
|
|
assert!(trimmed.len() <= 21);
|
|
assert!(trimmed.starts_with('é'));
|
|
assert!(trimmed.ends_with('é'));
|
|
|
|
let _ = std::fs::remove_dir_all(root);
|
|
}
|
|
|
|
#[test]
|
|
fn active_operation_reconciliation_replaces_stale_ui_history() {
|
|
let mut active_operations = HashMap::from([
|
|
("stale".to_string(), UiOperationKind::Installing),
|
|
("keep".to_string(), UiOperationKind::Downloading),
|
|
]);
|
|
let snapshot = vec![
|
|
ActiveOperation {
|
|
id: "keep".to_string(),
|
|
operation: ActiveOperationKind::Updating,
|
|
},
|
|
ActiveOperation {
|
|
id: "new".to_string(),
|
|
operation: ActiveOperationKind::Uninstalling,
|
|
},
|
|
];
|
|
|
|
reconcile_active_operations(&mut active_operations, &snapshot);
|
|
|
|
assert_eq!(active_operations.len(), 2);
|
|
assert_eq!(
|
|
active_operations.get("keep"),
|
|
Some(&UiOperationKind::Updating)
|
|
);
|
|
assert_eq!(
|
|
active_operations.get("new"),
|
|
Some(&UiOperationKind::Uninstalling)
|
|
);
|
|
assert!(
|
|
!active_operations.contains_key("stale"),
|
|
"snapshot reconciliation should drop stale UI operations"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn local_snapshot_without_finish_clears_stale_ui_operation() {
|
|
let mut active_operations =
|
|
HashMap::from([("game".to_string(), UiOperationKind::Downloading)]);
|
|
|
|
reconcile_active_operations(&mut active_operations, &[]);
|
|
|
|
assert!(
|
|
active_operations.is_empty(),
|
|
"an authoritative snapshot without the game should clear a missed finish event"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn local_snapshot_without_begin_restores_active_ui_operation() {
|
|
let mut active_operations = HashMap::new();
|
|
let snapshot = vec![ActiveOperation {
|
|
id: "game".to_string(),
|
|
operation: ActiveOperationKind::Installing,
|
|
}];
|
|
|
|
reconcile_active_operations(&mut active_operations, &snapshot);
|
|
|
|
assert_eq!(
|
|
active_operations.get("game"),
|
|
Some(&UiOperationKind::Installing),
|
|
"an authoritative snapshot should recover a missed begin event"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn active_operation_payload_is_sorted_for_stable_ui_updates() {
|
|
let active_operations = HashMap::from([
|
|
("zeta".to_string(), UiOperationKind::Downloading),
|
|
("alpha".to_string(), UiOperationKind::Installing),
|
|
]);
|
|
|
|
let snapshot = ui_active_operations_from_map(&active_operations);
|
|
|
|
assert_eq!(
|
|
snapshot,
|
|
vec![
|
|
UiActiveOperation {
|
|
id: "alpha".to_string(),
|
|
operation: UiOperationKind::Installing,
|
|
},
|
|
UiActiveOperation {
|
|
id: "zeta".to_string(),
|
|
operation: UiOperationKind::Downloading,
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn outbound_transfer_emit_state_coalesces_bursts_without_losing_updates() {
|
|
let mut state = OutboundTransferEmitState::default();
|
|
|
|
assert!(
|
|
state.record_change(),
|
|
"first change should schedule an emit"
|
|
);
|
|
assert_eq!(state.observed_generation(), 1);
|
|
assert!(
|
|
!state.record_change(),
|
|
"second change should reuse the scheduled emit"
|
|
);
|
|
assert_eq!(state.observed_generation(), 2);
|
|
|
|
assert!(
|
|
state.finish_emit(1),
|
|
"a generation observed before the latest change needs a follow-up emit"
|
|
);
|
|
assert!(
|
|
!state.finish_emit(2),
|
|
"the latest observed generation clears the scheduled emit"
|
|
);
|
|
assert!(state.record_change(), "a later burst should schedule again");
|
|
}
|
|
|
|
#[test]
|
|
fn game_file_viewer_ids_must_be_single_path_components() {
|
|
assert!(is_single_component_game_id("game"));
|
|
assert!(is_single_component_game_id("game.v1"));
|
|
assert!(!is_single_component_game_id(""));
|
|
assert!(!is_single_component_game_id("../game"));
|
|
assert!(!is_single_component_game_id("nested/game"));
|
|
assert!(!is_single_component_game_id("/game"));
|
|
}
|
|
|
|
#[test]
|
|
fn launch_settings_sanitize_script_arguments() {
|
|
assert_eq!(
|
|
launch_settings("DE", " Alice \"Ace\"%PATH%\n "),
|
|
LaunchSettings {
|
|
language: "de".to_string(),
|
|
username: "Alice AcePATH".to_string(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
launch_settings("fr", ""),
|
|
LaunchSettings {
|
|
language: DEFAULT_LANGUAGE.to_string(),
|
|
username: DEFAULT_USERNAME.to_string(),
|
|
}
|
|
);
|
|
// 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);
|
|
}
|
|
}
|