|
|
|
@@ -1,5 +1,6 @@
|
|
|
|
|
use std::{
|
|
|
|
|
collections::{HashMap, HashSet},
|
|
|
|
|
collections::{BTreeMap, HashMap, HashSet},
|
|
|
|
|
fmt::Write as _,
|
|
|
|
|
fs::{self, OpenOptions},
|
|
|
|
|
io::{self, Read as _, Seek as _, SeekFrom, Write as _},
|
|
|
|
|
path::{Component, Path, PathBuf},
|
|
|
|
@@ -7,40 +8,59 @@ use std::{
|
|
|
|
|
Arc,
|
|
|
|
|
Mutex,
|
|
|
|
|
OnceLock,
|
|
|
|
|
Weak,
|
|
|
|
|
atomic::{AtomicU64, Ordering},
|
|
|
|
|
},
|
|
|
|
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
use eyre::bail;
|
|
|
|
|
use lanspread_compat::eti::get_games;
|
|
|
|
|
use lanspread_db::db::{Availability, Game, GameCatalog, GameDB};
|
|
|
|
|
use lanspread_compat::catalog_bundle::{LoadedCatalog, load_catalog_bundle};
|
|
|
|
|
use lanspread_db::{
|
|
|
|
|
content_manifest::CatalogBundle,
|
|
|
|
|
db::{Availability, Game, GameDB},
|
|
|
|
|
};
|
|
|
|
|
use lanspread_peer::{
|
|
|
|
|
ActiveOperation,
|
|
|
|
|
ActiveOperationKind,
|
|
|
|
|
CallToPlayEvent,
|
|
|
|
|
CallToPlayLocalIntent,
|
|
|
|
|
CallToPlayReceipt,
|
|
|
|
|
DownloadAttemptId,
|
|
|
|
|
DownloadAttemptKey,
|
|
|
|
|
DownloadFailureReason,
|
|
|
|
|
DownloadProgress,
|
|
|
|
|
DownloadVerificationActivity,
|
|
|
|
|
ExternalUnrarStreamProvider,
|
|
|
|
|
LocalNetworkSharingState,
|
|
|
|
|
NoopStreamInstallProvider,
|
|
|
|
|
PeerCommand,
|
|
|
|
|
PeerEvent,
|
|
|
|
|
PeerGameDB,
|
|
|
|
|
PeerIdentity,
|
|
|
|
|
PeerIdentityDurability,
|
|
|
|
|
PeerRuntimeHandle,
|
|
|
|
|
PeerStartOptions,
|
|
|
|
|
RemoteLibraryView,
|
|
|
|
|
ScopedProcess,
|
|
|
|
|
StreamInstallProvider,
|
|
|
|
|
StreamInstallSettings,
|
|
|
|
|
UnpackFuture,
|
|
|
|
|
Unpacker,
|
|
|
|
|
migrate_legacy_state,
|
|
|
|
|
scoped_blocking,
|
|
|
|
|
start_peer_with_options,
|
|
|
|
|
};
|
|
|
|
|
use tauri::{AppHandle, Emitter as _, Manager};
|
|
|
|
|
use tauri_plugin_shell::{
|
|
|
|
|
ShellExt,
|
|
|
|
|
process::{Command, CommandChild, CommandEvent},
|
|
|
|
|
};
|
|
|
|
|
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::{
|
|
|
|
@@ -49,6 +69,8 @@ use tracing_subscriber::{
|
|
|
|
|
registry::LookupSpan,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mod sharing_policy;
|
|
|
|
|
|
|
|
|
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
|
|
|
|
|
|
|
|
|
type OutboundTransfers =
|
|
|
|
@@ -62,6 +84,135 @@ struct OutboundTransferEmitState {
|
|
|
|
|
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, waits for every token to be
|
|
|
|
|
/// dropped, and only then takes ownership of the peer runtime to stop 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);
|
|
|
|
@@ -87,43 +238,444 @@ impl OutboundTransferEmitState {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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>>,
|
|
|
|
|
catalog: Arc<RwLock<GameCatalog>>,
|
|
|
|
|
/// 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>>,
|
|
|
|
|
/// Live unrar sidecar processes, so they can be killed if the launcher exits
|
|
|
|
|
/// mid-unpack. The shell plugin does not kill spawned children on app exit,
|
|
|
|
|
/// and the OS does not cascade-kill child processes, so without this an
|
|
|
|
|
/// in-progress unrar keeps running after the launcher closes.
|
|
|
|
|
/// 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,
|
|
|
|
|
/// 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 sidecar children plus a shutdown latch.
|
|
|
|
|
/// 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
|
|
|
|
|
/// `shutting_down` latch closes a time-of-check/time-of-use hole: the exit kill
|
|
|
|
|
/// sweep drains the map only once, so a child registered *after* the sweep (an
|
|
|
|
|
/// install task caught between `spawn()` and registration, or a later archive in
|
|
|
|
|
/// a multi-archive install — `unpack_archives` does not observe the shutdown
|
|
|
|
|
/// token) would be orphaned. Once the latch is set under this mutex, registration
|
|
|
|
|
/// kills the child immediately instead of inserting it where nothing will reap it.
|
|
|
|
|
/// 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, CommandChild>,
|
|
|
|
|
children: HashMap<u64, Weak<UnrarWorkerControl>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct UnrarWorkerControl {
|
|
|
|
|
cancel_token: CancellationToken,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Monotonic id source for [`UnrarChildRegistry`] entries.
|
|
|
|
@@ -156,6 +708,7 @@ struct UiActiveOperation {
|
|
|
|
|
struct GamesListPayload {
|
|
|
|
|
games: Vec<LauncherGame>,
|
|
|
|
|
active_operations: Vec<UiActiveOperation>,
|
|
|
|
|
transfer_status: GameTransferStatusSnapshot,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, serde::Serialize)]
|
|
|
|
@@ -198,17 +751,26 @@ struct SidecarUnpacker {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) -> UnpackFuture<'a> {
|
|
|
|
|
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 sidecar = app_handle.shell().sidecar("unrar")?;
|
|
|
|
|
do_unrar(&app_handle, sidecar, archive, dest).await
|
|
|
|
|
let program = resolve_unrar_sidecar_program(&app_handle)?;
|
|
|
|
|
do_unrar(&app_handle, &program, archive, dest, cancel_token).await
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -217,6 +779,7 @@ impl Unpacker for SidecarUnpacker {
|
|
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -225,6 +788,7 @@ 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()?);
|
|
|
|
|
}
|
|
|
|
@@ -260,6 +824,7 @@ 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();
|
|
|
|
@@ -277,9 +842,166 @@ async fn request_games(state: tauri::State<'_, LanSpreadState>) -> tauri::Result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
async fn request_call_to_play_events(
|
|
|
|
|
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");
|
|
|
|
@@ -287,7 +1009,7 @@ async fn request_call_to_play_events(
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if peer_ctrl
|
|
|
|
|
.send(PeerCommand::GetCallToPlayEvents { reply: None })
|
|
|
|
|
.send(PeerCommand::GetCallToPlayView { reply: None })
|
|
|
|
|
.is_err()
|
|
|
|
|
{
|
|
|
|
|
return Ok(None);
|
|
|
|
@@ -297,20 +1019,54 @@ async fn request_call_to_play_events(
|
|
|
|
|
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
async fn publish_call_to_play(
|
|
|
|
|
event: CallToPlayEvent,
|
|
|
|
|
intent: CallToPlayLocalIntent,
|
|
|
|
|
display_name: String,
|
|
|
|
|
state: tauri::State<'_, LanSpreadState>,
|
|
|
|
|
) -> Result<bool, String> {
|
|
|
|
|
) -> 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 Ok(false);
|
|
|
|
|
return Err("peer system is not initialized".to_owned());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let (reply, result) = oneshot::channel();
|
|
|
|
|
peer_ctrl
|
|
|
|
|
.send(PeerCommand::PublishCallToPlay { event, reply })
|
|
|
|
|
.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())?.map(|()| true)
|
|
|
|
|
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]
|
|
|
|
@@ -320,6 +1076,7 @@ async fn install_game(
|
|
|
|
|
username: String,
|
|
|
|
|
state: tauri::State<'_, LanSpreadState>,
|
|
|
|
|
) -> tauri::Result<bool> {
|
|
|
|
|
let _app_invoke = enter_app_invoke(state.inner())?;
|
|
|
|
|
if state
|
|
|
|
|
.inner()
|
|
|
|
|
.active_operations
|
|
|
|
@@ -348,7 +1105,7 @@ async fn install_game(
|
|
|
|
|
let _ = (language, username);
|
|
|
|
|
let handled = if let Some(peer_ctrl) = peer_ctrl {
|
|
|
|
|
let command = if !downloaded {
|
|
|
|
|
PeerCommand::GetGame(id.clone())
|
|
|
|
|
PeerCommand::DownloadGameFiles { id: id.clone() }
|
|
|
|
|
} else if !installed {
|
|
|
|
|
PeerCommand::InstallGame { id: id.clone() }
|
|
|
|
|
} else {
|
|
|
|
@@ -369,11 +1126,41 @@ async fn install_game(
|
|
|
|
|
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>,
|
|
|
|
|
) -> tauri::Result<bool> {
|
|
|
|
|
) -> Result<bool, String> {
|
|
|
|
|
let _app_invoke = enter_app_invoke(state.inner()).map_err(|error| error.to_string())?;
|
|
|
|
|
if state
|
|
|
|
|
.inner()
|
|
|
|
|
.active_operations
|
|
|
|
@@ -403,6 +1190,10 @@ async fn stream_install_game(
|
|
|
|
|
);
|
|
|
|
|
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();
|
|
|
|
@@ -410,8 +1201,10 @@ async fn stream_install_game(
|
|
|
|
|
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 }) {
|
|
|
|
|
if let Err(e) = peer_ctrl.send(PeerCommand::StreamInstallGame { id, settings }) {
|
|
|
|
|
log::error!("Failed to send PeerCommand::StreamInstallGame: {e:?}");
|
|
|
|
|
return Ok(false);
|
|
|
|
|
}
|
|
|
|
@@ -426,6 +1219,7 @@ async fn update_game(
|
|
|
|
|
username: String,
|
|
|
|
|
state: tauri::State<'_, LanSpreadState>,
|
|
|
|
|
) -> tauri::Result<bool> {
|
|
|
|
|
let _app_invoke = enter_app_invoke(state.inner())?;
|
|
|
|
|
if state
|
|
|
|
|
.inner()
|
|
|
|
|
.active_operations
|
|
|
|
@@ -443,7 +1237,7 @@ async fn update_game(
|
|
|
|
|
|
|
|
|
|
let _ = (language, username);
|
|
|
|
|
if let Some(peer_ctrl) = peer_ctrl {
|
|
|
|
|
if let Err(e) = peer_ctrl.send(PeerCommand::FetchLatestFromPeers { id: id.clone() }) {
|
|
|
|
|
if let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles { id: id.clone() }) {
|
|
|
|
|
log::error!("Failed to send message to peer: {e:?}");
|
|
|
|
|
return Ok(false);
|
|
|
|
|
}
|
|
|
|
@@ -459,6 +1253,7 @@ 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
|
|
|
|
@@ -489,6 +1284,7 @@ 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
|
|
|
|
@@ -537,6 +1333,7 @@ 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!(
|
|
|
|
@@ -569,6 +1366,7 @@ async fn open_game_files(
|
|
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
@@ -716,6 +1514,7 @@ fn script_params_with_mode(
|
|
|
|
|
|
|
|
|
|
#[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();
|
|
|
|
|
|
|
|
|
@@ -735,9 +1534,11 @@ async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Resul
|
|
|
|
|
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())?;
|
|
|
|
|
let resource_path = app_handle.path().resolve(
|
|
|
|
|
format!("assets/{game_id}.jpg"),
|
|
|
|
|
tauri::path::BaseDirectory::Resource,
|
|
|
|
@@ -745,13 +1546,13 @@ async fn get_game_thumbnail(
|
|
|
|
|
|
|
|
|
|
dbg!(&resource_path);
|
|
|
|
|
|
|
|
|
|
let image_data = std::fs::read(&resource_path)?;
|
|
|
|
|
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(target_os = "windows")]
|
|
|
|
|
fn run_as_admin(
|
|
|
|
|
fn run_as_admin_detached(
|
|
|
|
|
file: &str,
|
|
|
|
|
params: &str,
|
|
|
|
|
dir: &str,
|
|
|
|
@@ -780,6 +1581,288 @@ fn run_as_admin(
|
|
|
|
|
(result.0 as usize) > 32 // Success if greater than 32
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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: &str,
|
|
|
|
|
params: &str,
|
|
|
|
|
dir: &str,
|
|
|
|
|
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 = OsStr::new(file)
|
|
|
|
|
.encode_wide()
|
|
|
|
|
.chain(Some(0))
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
let params_wide = OsStr::new(params)
|
|
|
|
|
.encode_wide()
|
|
|
|
|
.chain(Some(0))
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
let dir_wide = OsStr::new(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")]
|
|
|
|
|
async fn run_game_windows(
|
|
|
|
|
id: String,
|
|
|
|
@@ -824,15 +1907,17 @@ async fn run_game_windows(
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let result = run_as_admin(
|
|
|
|
|
"cmd.exe",
|
|
|
|
|
&script_params(&game_setup_bin, &id, &settings),
|
|
|
|
|
&game_path.display().to_string(),
|
|
|
|
|
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if !result {
|
|
|
|
|
log::error!("failed to run {GAME_SETUP_SCRIPT}");
|
|
|
|
|
let setup_params = script_params(&game_setup_bin, &id, &settings);
|
|
|
|
|
let game_dir = game_path.display().to_string();
|
|
|
|
|
if let Err(err) = scoped_blocking(|| {
|
|
|
|
|
run_as_admin_and_wait(
|
|
|
|
|
"cmd.exe",
|
|
|
|
|
&setup_params,
|
|
|
|
|
&game_dir,
|
|
|
|
|
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
|
|
|
|
)
|
|
|
|
|
}) {
|
|
|
|
|
log::error!("failed to complete {GAME_SETUP_SCRIPT}: {err}");
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -853,10 +1938,12 @@ async fn run_game_windows(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await;
|
|
|
|
|
apply_launch_settings(&state_dir, &game_path, &id, &language, &username);
|
|
|
|
|
|
|
|
|
|
if game_start_bin.exists() {
|
|
|
|
|
let result = run_as_admin(
|
|
|
|
|
// Game processes are intentionally user-owned: unlike setup, their
|
|
|
|
|
// lifetime is not an install transaction or a launcher state boundary.
|
|
|
|
|
let result = run_as_admin_detached(
|
|
|
|
|
"cmd.exe",
|
|
|
|
|
&script_params(&game_start_bin, &id, &settings),
|
|
|
|
|
&game_path.display().to_string(),
|
|
|
|
@@ -875,7 +1962,7 @@ async fn run_game_windows(
|
|
|
|
|
/// files the first time it is played. Uses the same processed values the install
|
|
|
|
|
/// transaction used to write before this step moved to play time.
|
|
|
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
|
|
|
async fn apply_launch_settings(
|
|
|
|
|
fn apply_launch_settings(
|
|
|
|
|
state_dir: &Path,
|
|
|
|
|
game_path: &Path,
|
|
|
|
|
id: &str,
|
|
|
|
@@ -889,9 +1976,7 @@ async fn apply_launch_settings(
|
|
|
|
|
id,
|
|
|
|
|
Some(&settings.account_name),
|
|
|
|
|
Some(&settings.language),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
) {
|
|
|
|
|
Ok(outcome) => log::info!("launch settings for {id}: {outcome:?}"),
|
|
|
|
|
Err(e) => log::error!("failed to apply launch settings for {id}: {e}"),
|
|
|
|
|
}
|
|
|
|
@@ -904,6 +1989,7 @@ async fn run_game(
|
|
|
|
|
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?;
|
|
|
|
@@ -960,9 +2046,10 @@ async fn start_server_windows(
|
|
|
|
|
log::error!("app state directory is not initialized; cannot start server");
|
|
|
|
|
return Ok(false);
|
|
|
|
|
};
|
|
|
|
|
apply_launch_settings(&state_dir, &game_path, &id, &language, &username).await;
|
|
|
|
|
apply_launch_settings(&state_dir, &game_path, &id, &language, &username);
|
|
|
|
|
|
|
|
|
|
let result = run_as_admin(
|
|
|
|
|
// Hosted servers are intentionally user-owned and may outlive the launcher.
|
|
|
|
|
let result = run_as_admin_detached(
|
|
|
|
|
"cmd.exe",
|
|
|
|
|
&server_script_params(&server_start_bin, &id, &settings),
|
|
|
|
|
&game_path.display().to_string(),
|
|
|
|
@@ -983,6 +2070,7 @@ async fn start_server(
|
|
|
|
|
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
|
|
|
|
@@ -1045,19 +2133,39 @@ fn apply_peer_local_games(game_db: &mut GameDB, local_games: &[Game]) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn apply_peer_remote_games(game_db: &mut GameDB, peer_games: Vec<Game>) {
|
|
|
|
|
// Peer events update availability, but catalog metadata stays anchored to game.db.
|
|
|
|
|
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 peer_game in peer_games {
|
|
|
|
|
if let Some(existing) = game_db.get_mut_game_by_id(&peer_game.id) {
|
|
|
|
|
existing.peer_count = peer_game.peer_count;
|
|
|
|
|
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 = peer_game.id
|
|
|
|
|
id = availability.game_id
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -1069,21 +2177,83 @@ fn clear_all_local_game_states(game_db: &mut GameDB) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn emit_games_list(app_handle: &AppHandle) {
|
|
|
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
|
|
|
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}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let installed_peer_counts = state
|
|
|
|
|
.peer_game_db
|
|
|
|
|
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
|
|
|
|
|
.peer_snapshots()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.flat_map(|peer| peer.games)
|
|
|
|
|
.filter(|game| game.installed)
|
|
|
|
|
.fold(HashMap::<String, u32>::new(), |mut counts, game| {
|
|
|
|
|
*counts.entry(game.id).or_default() += 1;
|
|
|
|
|
counts
|
|
|
|
|
});
|
|
|
|
|
.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;
|
|
|
|
@@ -1105,7 +2275,7 @@ async fn emit_games_list(app_handle: &AppHandle) {
|
|
|
|
|
LauncherGame {
|
|
|
|
|
can_host_server: game_can_host_server(&games_folder, &game),
|
|
|
|
|
active_outbound_transfers,
|
|
|
|
|
installed_peer_count: installed_peer_counts.get(&game.id).copied().unwrap_or(0),
|
|
|
|
|
installed_peer_count: game.peer_count,
|
|
|
|
|
game,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
@@ -1118,10 +2288,12 @@ async fn emit_games_list(app_handle: &AppHandle) {
|
|
|
|
|
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)) {
|
|
|
|
@@ -1180,21 +2352,592 @@ fn ui_operation_from_peer(operation: ActiveOperationKind) -> UiOperationKind {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
fn game_directory_exists(path: String) -> bool {
|
|
|
|
|
PathBuf::from(path).is_dir()
|
|
|
|
|
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 update_game_directory(app_handle: tauri::AppHandle, path: String) -> tauri::Result<()> {
|
|
|
|
|
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]
|
|
|
|
|
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 games_folder = PathBuf::from(&path);
|
|
|
|
|
if !games_folder.is_dir() {
|
|
|
|
|
log::error!("game dir {} does not exist", games_folder.display());
|
|
|
|
|
return Ok(());
|
|
|
|
|
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 state = app_handle.state::<LanSpreadState>();
|
|
|
|
|
let current_path = state.games_folder.read().await.clone();
|
|
|
|
|
let active_ids = state
|
|
|
|
|
.active_operations
|
|
|
|
@@ -1203,19 +2946,21 @@ async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> ta
|
|
|
|
|
.keys()
|
|
|
|
|
.cloned()
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
if current_path != path && !active_ids.is_empty() {
|
|
|
|
|
log::warn!(
|
|
|
|
|
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(", ")
|
|
|
|
|
);
|
|
|
|
|
return Ok(());
|
|
|
|
|
log::warn!("{error}");
|
|
|
|
|
return Err(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let path_changed = current_path != path;
|
|
|
|
|
let path_changed = current_path != requested_canonical_path;
|
|
|
|
|
let Some(state_dir) = state.state_dir.get().cloned() else {
|
|
|
|
|
log::error!("app state directory is not initialized; cannot update game directory");
|
|
|
|
|
return Ok(());
|
|
|
|
|
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() {
|
|
|
|
@@ -1228,30 +2973,48 @@ async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> ta
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
*state.games_folder.write().await = path;
|
|
|
|
|
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?;
|
|
|
|
|
let accepted_path = accepted_games_folder.to_string_lossy().into_owned();
|
|
|
|
|
let accepted_path_changed = current_path != accepted_path;
|
|
|
|
|
|
|
|
|
|
ensure_bundled_game_db_loaded(&app_handle).await;
|
|
|
|
|
if path_changed {
|
|
|
|
|
// 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();
|
|
|
|
|
if accepted_path_changed {
|
|
|
|
|
let mut game_db = state.games.write().await;
|
|
|
|
|
clear_all_local_game_states(&mut game_db);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
emit_games_list(&app_handle).await;
|
|
|
|
|
ensure_peer_started(&app_handle, &games_folder).await;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn update_game_db(games: Vec<Game>, app: AppHandle) {
|
|
|
|
|
for game in &games {
|
|
|
|
|
log::trace!("peer event ListGames iter: {game:?}");
|
|
|
|
|
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_games(&mut game_db, games);
|
|
|
|
|
apply_peer_remote_view(&mut game_db, &view, catalog_bundle);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
emit_games_list(&app).await;
|
|
|
|
@@ -1260,6 +3023,14 @@ async fn update_game_db(games: Vec<Game>, app: AppHandle) {
|
|
|
|
|
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);
|
|
|
|
@@ -1284,9 +3055,10 @@ fn add_final_slash(path: &str) -> String {
|
|
|
|
|
|
|
|
|
|
async fn do_unrar(
|
|
|
|
|
app_handle: &AppHandle,
|
|
|
|
|
sidecar: Command,
|
|
|
|
|
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?;
|
|
|
|
@@ -1297,7 +3069,7 @@ async fn do_unrar(
|
|
|
|
|
paths.destination.display()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
run_unrar_sidecar(app_handle, sidecar, &paths, started_at_ms).await
|
|
|
|
|
run_unrar_sidecar(app_handle, program, &paths, started_at_ms, cancel_token).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct UnrarPaths {
|
|
|
|
@@ -1386,25 +3158,50 @@ async fn prepare_unrar_paths(
|
|
|
|
|
|
|
|
|
|
async fn run_unrar_sidecar(
|
|
|
|
|
app_handle: &AppHandle,
|
|
|
|
|
sidecar: Command,
|
|
|
|
|
program: &Path,
|
|
|
|
|
paths: &UnrarPaths,
|
|
|
|
|
started_at_ms: u64,
|
|
|
|
|
cancel_token: CancellationToken,
|
|
|
|
|
) -> eyre::Result<()> {
|
|
|
|
|
// Spawn (instead of `.output()`) so we keep a killable handle. The shell
|
|
|
|
|
// plugin's `output()` drops the `CommandChild` immediately and only drains
|
|
|
|
|
// the event channel, leaving the unrar process orphaned if the launcher
|
|
|
|
|
// exits before extraction finishes.
|
|
|
|
|
let (mut events, child) = match sidecar
|
|
|
|
|
.arg("x") // extract files
|
|
|
|
|
.arg(&paths.archive)
|
|
|
|
|
.arg("-y") // Assume Yes on all queries
|
|
|
|
|
.arg("-o") // Set overwrite mode
|
|
|
|
|
.arg(&paths.destination_arg)
|
|
|
|
|
.spawn()
|
|
|
|
|
{
|
|
|
|
|
Ok(spawned) => spawned,
|
|
|
|
|
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-"),
|
|
|
|
|
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 run unrar sidecar: {err}");
|
|
|
|
|
let stderr = format!("failed to start unrar sidecar supervisor: {err}");
|
|
|
|
|
registration.complete();
|
|
|
|
|
record_unpack_failure(
|
|
|
|
|
app_handle,
|
|
|
|
|
paths.archive.display().to_string(),
|
|
|
|
@@ -1416,43 +3213,28 @@ async fn run_unrar_sidecar(
|
|
|
|
|
bail!("{stderr}");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Register the live child so a launcher exit can kill it, and deregister it
|
|
|
|
|
// automatically on every exit path via the RAII guard.
|
|
|
|
|
let registry = app_handle
|
|
|
|
|
.state::<LanSpreadState>()
|
|
|
|
|
.active_unrar_children
|
|
|
|
|
.clone();
|
|
|
|
|
let child_id = UNRAR_CHILD_SEQ.fetch_add(1, Ordering::Relaxed);
|
|
|
|
|
register_unrar_child(®istry, child_id, child);
|
|
|
|
|
let _child_guard = UnrarChildGuard { registry, child_id };
|
|
|
|
|
|
|
|
|
|
let mut stdout_bytes = Vec::new();
|
|
|
|
|
let mut stderr_bytes = Vec::new();
|
|
|
|
|
let mut status_code = None;
|
|
|
|
|
while let Some(event) = events.recv().await {
|
|
|
|
|
match event {
|
|
|
|
|
CommandEvent::Stdout(line) => {
|
|
|
|
|
stdout_bytes.extend(line);
|
|
|
|
|
stdout_bytes.push(b'\n');
|
|
|
|
|
}
|
|
|
|
|
CommandEvent::Stderr(line) => {
|
|
|
|
|
stderr_bytes.extend(line);
|
|
|
|
|
stderr_bytes.push(b'\n');
|
|
|
|
|
}
|
|
|
|
|
CommandEvent::Terminated(payload) => {
|
|
|
|
|
status_code = payload.code;
|
|
|
|
|
}
|
|
|
|
|
CommandEvent::Error(err) => {
|
|
|
|
|
log::warn!("unrar sidecar event error: {err}");
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
let 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 = clean_terminal_log(&String::from_utf8_lossy(&stdout_bytes));
|
|
|
|
|
let stderr = clean_terminal_log(&String::from_utf8_lossy(&stderr_bytes));
|
|
|
|
|
let success = status_code == Some(0);
|
|
|
|
|
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,
|
|
|
|
@@ -1482,76 +3264,117 @@ async fn run_unrar_sidecar(
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Tracks a spawned unrar sidecar so the launcher can kill it on shutdown.
|
|
|
|
|
///
|
|
|
|
|
/// If shutdown has already begun (or the registry is poisoned), the child is
|
|
|
|
|
/// killed immediately instead of inserted, since the exit kill sweep has already
|
|
|
|
|
/// run and would never reap a late registration.
|
|
|
|
|
fn register_unrar_child(
|
|
|
|
|
registry: &Arc<Mutex<UnrarChildRegistry>>,
|
|
|
|
|
child_id: u64,
|
|
|
|
|
child: CommandChild,
|
|
|
|
|
) {
|
|
|
|
|
let Ok(mut guard) = registry.lock() else {
|
|
|
|
|
// A poisoned registry means we can no longer guarantee the child is
|
|
|
|
|
// killed on exit, so kill it now rather than risk orphaning it.
|
|
|
|
|
let pid = child.pid();
|
|
|
|
|
log::warn!("unrar child registry is poisoned; killing pid {pid} immediately");
|
|
|
|
|
if let Err(err) = child.kill() {
|
|
|
|
|
log::warn!("Failed to kill untracked unrar child (pid {pid}): {err}");
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if guard.shutting_down {
|
|
|
|
|
drop(guard);
|
|
|
|
|
let pid = child.pid();
|
|
|
|
|
log::info!("Killing unrar child (pid {pid}) that spawned during shutdown");
|
|
|
|
|
if let Err(err) = child.kill() {
|
|
|
|
|
log::warn!("Failed to kill unrar child (pid {pid}) spawned during shutdown: {err}");
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
let _ = write!(
|
|
|
|
|
output,
|
|
|
|
|
"[{stream} truncated after {UNRAR_LOG_CAPTURE_LIMIT} bytes]"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
guard.children.insert(child_id, child);
|
|
|
|
|
output
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Removes an unrar sidecar from the registry when its `run_unrar_sidecar` call
|
|
|
|
|
/// returns, regardless of success, error, or early bail.
|
|
|
|
|
struct UnrarChildGuard {
|
|
|
|
|
/// 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 Drop for UnrarChildGuard {
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
if let Ok(mut guard) = self.registry.lock() {
|
|
|
|
|
guard.children.remove(&self.child_id);
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Kills every in-progress unrar sidecar and latches the registry into shutdown
|
|
|
|
|
/// so any install task that spawns unrar after this point kills it on
|
|
|
|
|
/// registration. Called on app exit so a game install that is mid-extraction does
|
|
|
|
|
/// not leave `unrar` running after the launcher closes.
|
|
|
|
|
fn kill_active_unrar_children(app_handle: &AppHandle) {
|
|
|
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
|
|
|
let children = {
|
|
|
|
|
let Ok(mut guard) = state.active_unrar_children.lock() else {
|
|
|
|
|
log::warn!("unrar child registry is poisoned; cannot kill children on shutdown");
|
|
|
|
|
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.drain().collect::<Vec<_>>()
|
|
|
|
|
guard
|
|
|
|
|
.children
|
|
|
|
|
.values()
|
|
|
|
|
.filter_map(Weak::upgrade)
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (_child_id, child) in children {
|
|
|
|
|
let pid = child.pid();
|
|
|
|
|
match child.kill() {
|
|
|
|
|
Ok(()) => log::info!("Killed in-progress unrar child (pid {pid}) on shutdown"),
|
|
|
|
|
Err(err) => log::warn!("Failed to kill unrar child (pid {pid}) on shutdown: {err}"),
|
|
|
|
|
}
|
|
|
|
|
for control in children {
|
|
|
|
|
control.cancel_token.cancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1582,14 +3405,19 @@ async fn record_unpack_log(app_handle: &AppHandle, entry: UnpackLogEntry) {
|
|
|
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
|
|
|
let mut entry = entry;
|
|
|
|
|
clean_unpack_log_entry(&mut entry);
|
|
|
|
|
let logs = {
|
|
|
|
|
let state_dir = state.state_dir.get().cloned();
|
|
|
|
|
{
|
|
|
|
|
let mut logs = state.inner().unpack_logs.write().await;
|
|
|
|
|
logs.push(entry);
|
|
|
|
|
trim_unpack_logs(&mut logs);
|
|
|
|
|
logs.clone()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
persist_unpack_logs(app_handle, &logs).await;
|
|
|
|
|
if let 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}");
|
|
|
|
@@ -2027,27 +3855,11 @@ fn load_unpack_logs(state_dir: &Path) -> Vec<UnpackLogEntry> {
|
|
|
|
|
logs
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn persist_unpack_logs(app_handle: &AppHandle, logs: &[UnpackLogEntry]) {
|
|
|
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
|
|
|
let Some(state_dir) = state.state_dir.get().cloned() else {
|
|
|
|
|
log::warn!("Cannot persist unpack logs before app state directory is initialized");
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let path = unpack_logs_path(&state_dir);
|
|
|
|
|
let contents = match serde_json::to_vec_pretty(logs) {
|
|
|
|
|
Ok(contents) => contents,
|
|
|
|
|
Err(err) => {
|
|
|
|
|
log::warn!(
|
|
|
|
|
"Failed to serialize unpack logs for {}: {err}",
|
|
|
|
|
path.display()
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if let Err(err) = tokio::fs::write(&path, contents).await {
|
|
|
|
|
log::warn!("Failed to persist unpack logs to {}: {err}", path.display());
|
|
|
|
|
}
|
|
|
|
|
fn 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 {
|
|
|
|
@@ -2058,88 +3870,157 @@ fn now_millis() -> u64 {
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resolve the bundled catalog database packaged with the Tauri application.
|
|
|
|
|
fn resolve_bundled_game_db_path(app_handle: &AppHandle) -> PathBuf {
|
|
|
|
|
app_handle
|
|
|
|
|
/// 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)
|
|
|
|
|
.unwrap_or_else(|e| {
|
|
|
|
|
log::error!("Failed to resolve game.db resource: {e}");
|
|
|
|
|
panic!("game.db resource is required - cannot continue");
|
|
|
|
|
.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()
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Load the bundled catalog into the in-memory game database used by the UI.
|
|
|
|
|
async fn load_bundled_game_db(app_handle: &AppHandle) -> GameDB {
|
|
|
|
|
let game_db_path = resolve_bundled_game_db_path(app_handle);
|
|
|
|
|
let eti_games = get_games(&game_db_path).await.unwrap_or_else(|e| {
|
|
|
|
|
log::error!("Failed to load ETI games: {e}");
|
|
|
|
|
panic!("game.db resource is required - cannot continue");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
log::info!("Loaded {} ETI games from game.db", eti_games.len());
|
|
|
|
|
|
|
|
|
|
let games: Vec<Game> = eti_games.into_iter().map(Into::into).collect();
|
|
|
|
|
GameDB::from(games)
|
|
|
|
|
async fn 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 ensure_bundled_game_db_loaded(app_handle: &AppHandle) {
|
|
|
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
|
|
|
let needs_load = { state.games.read().await.games.is_empty() };
|
|
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if needs_load {
|
|
|
|
|
let game_db = load_bundled_game_db(app_handle).await;
|
|
|
|
|
let catalog = GameCatalog::from_game_db(&game_db);
|
|
|
|
|
*state.games.write().await = game_db;
|
|
|
|
|
*state.catalog.write().await = catalog;
|
|
|
|
|
/// 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());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
*slot = Some(start()?);
|
|
|
|
|
Ok(slot)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn ensure_peer_started(app_handle: &AppHandle, games_folder: &Path) {
|
|
|
|
|
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_ctrl) = peer_ctrl.as_ref() {
|
|
|
|
|
if let Err(e) = peer_ctrl.send(PeerCommand::SetGameDir(games_folder.to_path_buf())) {
|
|
|
|
|
log::error!("Failed to send PeerCommand::SetGameDir: {e}");
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
let (reply, result) = oneshot::channel();
|
|
|
|
|
peer_ctrl
|
|
|
|
|
.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 {
|
|
|
|
|
log::error!("app state directory is not initialized; cannot start peer");
|
|
|
|
|
return;
|
|
|
|
|
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);
|
|
|
|
|
match start_peer_with_options(
|
|
|
|
|
games_folder.to_path_buf(),
|
|
|
|
|
tx_peer_event,
|
|
|
|
|
state.peer_game_db.clone(),
|
|
|
|
|
unpacker,
|
|
|
|
|
state.catalog.clone(),
|
|
|
|
|
PeerStartOptions {
|
|
|
|
|
state_dir: Some(state_dir),
|
|
|
|
|
active_outbound_transfers: Some(state.active_outbound_transfers.clone()),
|
|
|
|
|
stream_install_provider: Some(stream_install_provider),
|
|
|
|
|
},
|
|
|
|
|
) {
|
|
|
|
|
Ok(handle) => {
|
|
|
|
|
let sender = handle.sender();
|
|
|
|
|
*peer_ctrl = Some(sender.clone());
|
|
|
|
|
*state.peer_runtime.write().await = Some(handle);
|
|
|
|
|
if let Err(e) = sender.send(PeerCommand::ListGames) {
|
|
|
|
|
log::error!("Failed to send initial PeerCommand::ListGames: {e}");
|
|
|
|
|
}
|
|
|
|
|
log::info!("Peer system initialized successfully with games directory");
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
log::error!("Failed to initialize peer system: {e}");
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
*local_peer_id_slot = Some(local_peer_id);
|
|
|
|
|
drop(installation_identity_slot);
|
|
|
|
|
drop(local_peer_id_slot);
|
|
|
|
|
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> {
|
|
|
|
@@ -2164,14 +4045,230 @@ fn emit_game_id_event(app_handle: &AppHandle, event: &str, id: &str, label: &str
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn spawn_peer_event_loop(app_handle: AppHandle, mut rx_peer_event: UnboundedReceiver<PeerEvent>) {
|
|
|
|
|
tauri::async_runtime::spawn(async move {
|
|
|
|
|
while let Some(event) = rx_peer_event.recv().await {
|
|
|
|
|
handle_peer_event(&app_handle, event).await;
|
|
|
|
|
fn spawn_peer_event_loop(
|
|
|
|
|
app_handle: AppHandle,
|
|
|
|
|
mut rx_peer_event: UnboundedReceiver<PeerEvent>,
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 UnboundedReceiver<PeerEvent>,
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
// The core replies only after all events for the requested transition have
|
|
|
|
|
// been enqueued. Once this UI command wins the fair select, snapshot the
|
|
|
|
|
// peer queue and process that exact FIFO prefix. Later autonomous traffic
|
|
|
|
|
// remains for the normal event-loop turn and cannot contaminate this
|
|
|
|
|
// transition's acknowledgement boundary.
|
|
|
|
|
let count = receiver.len();
|
|
|
|
|
let queued = take_exactly_queued(receiver, count)?;
|
|
|
|
|
for event in queued {
|
|
|
|
|
handle_peer_event(app_handle, event).await;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn fence_queued_peer_events(
|
|
|
|
|
app_handle: &AppHandle,
|
|
|
|
|
receiver: &mut UnboundedReceiver<PeerEvent>,
|
|
|
|
|
) -> 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 UnboundedReceiver<PeerEvent>,
|
|
|
|
|
) -> 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 UnboundedReceiver<PeerEvent>,
|
|
|
|
|
) {
|
|
|
|
|
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 = {
|
|
|
|
@@ -2183,10 +4280,16 @@ async fn schedule_outbound_transfer_emit(app_handle: &AppHandle) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let tasks = state.background_tasks.clone();
|
|
|
|
|
let cancel_token = tasks.cancel_token();
|
|
|
|
|
let app_handle = app_handle.clone();
|
|
|
|
|
tauri::async_runtime::spawn(async move {
|
|
|
|
|
tasks.spawn(async move {
|
|
|
|
|
loop {
|
|
|
|
|
tokio::time::sleep(OUTBOUND_TRANSFER_EMIT_DEBOUNCE).await;
|
|
|
|
|
tokio::select! {
|
|
|
|
|
biased;
|
|
|
|
|
() = cancel_token.cancelled() => break,
|
|
|
|
|
() = tokio::time::sleep(OUTBOUND_TRANSFER_EMIT_DEBOUNCE) => {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let observed_generation = {
|
|
|
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
|
|
@@ -2215,15 +4318,49 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|
|
|
|
match event {
|
|
|
|
|
PeerEvent::LocalPeerReady { peer_id, addr } => {
|
|
|
|
|
log::info!("Local peer ready: {peer_id} at {addr}");
|
|
|
|
|
*app_handle
|
|
|
|
|
let authoritative_peer_id = app_handle
|
|
|
|
|
.state::<LanSpreadState>()
|
|
|
|
|
.local_peer_id
|
|
|
|
|
.write()
|
|
|
|
|
.await = Some(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::ListGames(games) => {
|
|
|
|
|
log::info!("PeerEvent::ListGames received");
|
|
|
|
|
update_game_db(games, app_handle.clone()).await;
|
|
|
|
|
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");
|
|
|
|
@@ -2238,70 +4375,110 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|
|
|
|
}
|
|
|
|
|
emit_games_list(app_handle).await;
|
|
|
|
|
}
|
|
|
|
|
PeerEvent::CallToPlayEvents(events) => {
|
|
|
|
|
if let Err(err) = app_handle.emit("call-to-play-events", Some(events)) {
|
|
|
|
|
log::error!("Failed to emit call-to-play-events event: {err}");
|
|
|
|
|
PeerEvent::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 => {
|
|
|
|
|
PeerEvent::OutboundTransferCountChanged(change) => {
|
|
|
|
|
log::info!("PeerEvent::OutboundTransferCountChanged received");
|
|
|
|
|
schedule_outbound_transfer_emit(app_handle).await;
|
|
|
|
|
drop(change);
|
|
|
|
|
}
|
|
|
|
|
PeerEvent::GotGameFiles {
|
|
|
|
|
id,
|
|
|
|
|
file_descriptions: _,
|
|
|
|
|
} => {
|
|
|
|
|
handle_got_game_files(app_handle, id).await;
|
|
|
|
|
}
|
|
|
|
|
PeerEvent::NoPeersHaveGame { id } => {
|
|
|
|
|
log::warn!("PeerEvent::NoPeersHaveGame received for {id}");
|
|
|
|
|
emit_game_id_event(
|
|
|
|
|
app_handle,
|
|
|
|
|
"game-no-peers",
|
|
|
|
|
&id,
|
|
|
|
|
"PeerEvent::NoPeersHaveGame",
|
|
|
|
|
PeerEvent::DownloadGameFilesBegin { attempt } => {
|
|
|
|
|
log::info!(
|
|
|
|
|
"PeerEvent::DownloadGameFilesBegin received for {} attempt {}",
|
|
|
|
|
attempt.id,
|
|
|
|
|
attempt.attempt_id
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
PeerEvent::DownloadGameFilesBegin { id } => {
|
|
|
|
|
log::info!("PeerEvent::DownloadGameFilesBegin received for {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}: \
|
|
|
|
|
{relative_path} offset {offset} length {length} from {peer_addr}"
|
|
|
|
|
{} offset {offset} length {length} for content {content_id} \
|
|
|
|
|
from authenticated peer {peer_id} at {peer_addr}",
|
|
|
|
|
relative_path.as_str()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
PeerEvent::DownloadGameFilesProgress(progress) => {
|
|
|
|
|
if let Err(e) = app_handle.emit("game-download-progress", Some(progress)) {
|
|
|
|
|
log::error!("Failed to emit game-download-progress event: {e}");
|
|
|
|
|
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::DownloadGameFilesFinished { id } => {
|
|
|
|
|
log::info!("PeerEvent::DownloadGameFilesFinished received for {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::DownloadGameFilesFailed { id } => {
|
|
|
|
|
log::warn!("PeerEvent::DownloadGameFilesFailed received");
|
|
|
|
|
emit_game_id_event(
|
|
|
|
|
app_handle,
|
|
|
|
|
"game-download-failed",
|
|
|
|
|
&id,
|
|
|
|
|
"PeerEvent::DownloadGameFilesFailed",
|
|
|
|
|
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::DownloadGameFilesAllPeersGone { id } => {
|
|
|
|
|
log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}");
|
|
|
|
|
emit_game_id_event(
|
|
|
|
|
app_handle,
|
|
|
|
|
"game-download-peers-gone",
|
|
|
|
|
&id,
|
|
|
|
|
"PeerEvent::DownloadGameFilesAllPeersGone",
|
|
|
|
|
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}");
|
|
|
|
@@ -2351,17 +4528,19 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|
|
|
|
"PeerEvent::RemoveDownloadedGameFailed",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
PeerEvent::PeerConnected(addr) => {
|
|
|
|
|
log::info!("Peer connected: {addr}");
|
|
|
|
|
PeerEvent::PeerDiscovered(endpoint) => {
|
|
|
|
|
log::info!(
|
|
|
|
|
"Peer discovered: authenticated peer {} at {}",
|
|
|
|
|
endpoint.peer_id,
|
|
|
|
|
endpoint.addr
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
PeerEvent::PeerDisconnected(addr) => {
|
|
|
|
|
log::info!("Peer disconnected: {addr}");
|
|
|
|
|
}
|
|
|
|
|
PeerEvent::PeerDiscovered(addr) => {
|
|
|
|
|
log::info!("Peer discovered: {addr}");
|
|
|
|
|
}
|
|
|
|
|
PeerEvent::PeerLost(addr) => {
|
|
|
|
|
log::info!("Peer lost: {addr}");
|
|
|
|
|
PeerEvent::PeerLost(endpoint) => {
|
|
|
|
|
log::info!(
|
|
|
|
|
"Peer lost: authenticated peer {} at {}",
|
|
|
|
|
endpoint.peer_id,
|
|
|
|
|
endpoint.addr
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
PeerEvent::PeerCountUpdated(count) => {
|
|
|
|
|
log::info!("Peer count updated: {count}");
|
|
|
|
@@ -2369,6 +4548,19 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|
|
|
|
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}");
|
|
|
|
@@ -2382,16 +4574,16 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn handle_got_game_files(app_handle: &AppHandle, id: String) {
|
|
|
|
|
log::info!("PeerEvent::GotGameFiles received");
|
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
|
|
|
|
struct ProtocolMismatch {
|
|
|
|
|
observed: Option<u32>,
|
|
|
|
|
expected: u32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let state = app_handle.state::<LanSpreadState>();
|
|
|
|
|
let peer_ctrl = state.peer_ctrl.read().await.clone();
|
|
|
|
|
if let Some(peer_ctrl) = peer_ctrl
|
|
|
|
|
&& let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles { id })
|
|
|
|
|
{
|
|
|
|
|
log::error!("Failed to continue queued game transfer: {e}");
|
|
|
|
|
}
|
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
|
|
|
|
struct ProtocolMismatchSnapshot {
|
|
|
|
|
revision: u64,
|
|
|
|
|
mismatch: Option<ProtocolMismatch>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[allow(clippy::missing_panics_doc)]
|
|
|
|
@@ -2399,6 +4591,7 @@ async fn handle_got_game_files(app_handle: &AppHandle, id: String) {
|
|
|
|
|
pub fn run() {
|
|
|
|
|
// channel to receive events from the peer
|
|
|
|
|
let (tx_peer_event, rx_peer_event) = tokio::sync::mpsc::unbounded_channel::<PeerEvent>();
|
|
|
|
|
let (tx_ui_state, rx_ui_state) = tokio::sync::mpsc::unbounded_channel::<UiStateCommand>();
|
|
|
|
|
|
|
|
|
|
tauri::Builder::default()
|
|
|
|
|
.plugin(tauri_plugin_store::Builder::new().build())
|
|
|
|
@@ -2406,9 +4599,15 @@ pub fn run() {
|
|
|
|
|
.plugin(tauri_plugin_shell::init())
|
|
|
|
|
.invoke_handler(tauri::generate_handler![
|
|
|
|
|
request_games,
|
|
|
|
|
request_call_to_play_events,
|
|
|
|
|
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,
|
|
|
|
@@ -2426,6 +4625,7 @@ pub fn run() {
|
|
|
|
|
])
|
|
|
|
|
.manage(LanSpreadState::default())
|
|
|
|
|
.manage(PeerEventTx(tx_peer_event))
|
|
|
|
|
.manage(UiStateTx(tx_ui_state))
|
|
|
|
|
.setup(move |app| {
|
|
|
|
|
let state_dir = app.path().app_data_dir()?;
|
|
|
|
|
std::fs::create_dir_all(&state_dir)?;
|
|
|
|
@@ -2435,51 +4635,1003 @@ pub fn run() {
|
|
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
if state.state_dir.set(state_dir).is_err() {
|
|
|
|
|
log::warn!("app state directory was already initialized");
|
|
|
|
|
}
|
|
|
|
|
spawn_peer_event_loop(app.handle().clone(), rx_peer_event);
|
|
|
|
|
spawn_peer_event_loop(app.handle().clone(), rx_peer_event, rx_ui_state);
|
|
|
|
|
Ok(())
|
|
|
|
|
})
|
|
|
|
|
.build(tauri::generate_context!())
|
|
|
|
|
.expect("error while building tauri application")
|
|
|
|
|
.run(|app_handle, event| {
|
|
|
|
|
if matches!(event, tauri::RunEvent::Exit) {
|
|
|
|
|
// Kill unrar first: an in-progress extraction would otherwise keep
|
|
|
|
|
// running after the launcher closes, and killing it lets the
|
|
|
|
|
// install task unwind so the peer runtime can stop promptly.
|
|
|
|
|
kill_active_unrar_children(app_handle);
|
|
|
|
|
shutdown_peer_runtime(app_handle);
|
|
|
|
|
shutdown_application(app_handle);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn shutdown_peer_runtime(app_handle: &AppHandle) {
|
|
|
|
|
fn shutdown_application(app_handle: &AppHandle) {
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
// Close every invoke entry point before any shutdown mutation. Unrar is
|
|
|
|
|
// cancelled next; its lexical process worker settles before the peer
|
|
|
|
|
// runtime can report that the install task has stopped.
|
|
|
|
|
app_invokes.close_admission();
|
|
|
|
|
cancel_active_unrar_workers(app_handle);
|
|
|
|
|
|
|
|
|
|
tauri::async_runtime::block_on(async move {
|
|
|
|
|
let Some(mut handle) = peer_runtime.write().await.take() else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
handle.shutdown();
|
|
|
|
|
if tokio::time::timeout(std::time::Duration::from_secs(2), handle.wait_stopped())
|
|
|
|
|
.await
|
|
|
|
|
.is_err()
|
|
|
|
|
{
|
|
|
|
|
log::warn!("Peer runtime did not stop within 2s of shutdown request");
|
|
|
|
|
// 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;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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::*;
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
@@ -2514,6 +5666,72 @@ mod tests {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(),
|
|
|
|
@@ -2532,6 +5750,147 @@ mod tests {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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"));
|
|
|
|
@@ -2910,7 +6269,39 @@ mod tests {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn peer_remote_snapshot_updates_counts_without_overwriting_catalog_version() {
|
|
|
|
|
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());
|
|
|
|
@@ -2921,16 +6312,29 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
let mut game_db = GameDB::from(vec![alpha, beta]);
|
|
|
|
|
|
|
|
|
|
let mut peer_alpha = game_fixture("alpha", "Peer Alpha");
|
|
|
|
|
peer_alpha.size = 42;
|
|
|
|
|
peer_alpha.peer_count = 3;
|
|
|
|
|
peer_alpha.eti_game_version = Some("20990101".to_string());
|
|
|
|
|
|
|
|
|
|
let mut unknown = game_fixture("unknown", "Unknown");
|
|
|
|
|
unknown.peer_count = 1;
|
|
|
|
|
unknown.eti_game_version = Some("20990101".to_string());
|
|
|
|
|
|
|
|
|
|
apply_peer_remote_games(&mut game_db, vec![peer_alpha, unknown]);
|
|
|
|
|
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");
|
|
|
|
@@ -2944,4 +6348,70 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|