feat(peer)!: cut over to authenticated catalog sharing

Replace address-only trust and pushed peer state with installation identities,
SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned
protocol-8 pulls. The runtime now owns each network generation and all admitted
work through shutdown.

Add exact bundled content identities, reproducible manifest publishing,
capability-confined downloads, streaming BLAKE3 verification, quarantine and
retry, and crash-recoverable download and install transactions. Ship generated
fixture catalogs and fail closed when production manifests are absent.

The Tauri backend exposes durable sharing policy, redacted identity state, and
attempt-keyed transfer snapshots. Frontend consumption follows in the next
commit. Repository-wide test certificates and protocol-7 paths are removed.

BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts;
protocol-7 frames and shared-certificate identities are no longer accepted.

Test Plan:
- `just test` -- passed on the completed stack (708 workspace tests)
- `just clippy` -- passed on the completed stack
- `just build` -- passed with fixture catalogs on the completed stack
- `just catalog-check-production` -- failed closed because the external
  production manifest corpus is absent
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-10 13:59:18 +02:00
parent 36c4785775
commit 60fd7ba0c2
128 changed files with 51759 additions and 10784 deletions
@@ -24,6 +24,8 @@ lanspread-peer = { path = "../../lanspread-peer" }
# external
base64 = { workspace = true }
cap-fs-ext = { workspace = true }
cap-primitives = { workspace = true }
eyre = { workspace = true }
log = { workspace = true }
mimalloc = { workspace = true }
@@ -41,11 +43,17 @@ tracing-log = { workspace = true }
tracing-subscriber = { workspace = true }
walkdir = { workspace = true }
[dev-dependencies]
sqlx = { workspace = true }
[build-dependencies]
lanspread-compat = { path = "../../lanspread-compat" }
serde_json = { workspace = true }
tauri-build = { version = "2", features = [] }
tokio = { workspace = true }
[target."cfg(windows)".dependencies]
windows = { workspace = true }
windows = { workspace = true, features = ["Win32_Storage_FileSystem"] }
[lints.clippy]
needless_pass_by_value = "allow"
@@ -1,3 +1,62 @@
use std::{env, fs};
use build_support::catalog_gate::{
CatalogBuildMode,
CatalogGateInput,
select_catalog_build_mode,
validate_production_catalog,
};
mod build_support {
#[path = "catalog_gate.rs"]
pub(crate) mod catalog_gate;
}
const FIXTURE_DEVELOPMENT_ENV: &str = "LANSPREAD_USE_FIXTURE_CATALOG";
fn main() {
println!("cargo:rerun-if-env-changed=TAURI_CONFIG");
println!("cargo:rerun-if-env-changed={FIXTURE_DEVELOPMENT_ENV}");
println!("cargo:rerun-if-changed=tauri.conf.json");
println!("cargo:rerun-if-changed=game.db");
println!("cargo:rerun-if-changed=manifests");
let mode = catalog_build_mode().unwrap_or_else(|error| {
panic!("catalog packaging policy failed: {error}");
});
if mode == CatalogBuildMode::Production
&& let Err(error) = validate_production_catalog("game.db", "manifests")
{
panic!("production catalog authority gate failed: {error}");
}
tauri_build::build();
}
fn catalog_build_mode() -> Result<CatalogBuildMode, Box<dyn std::error::Error>> {
let base_config = fs::read_to_string("tauri.conf.json")?;
let config_override = env::var_os("TAURI_CONFIG")
.map(|value| {
value
.into_string()
.map_err(|_| "TAURI_CONFIG is not valid UTF-8".to_owned())
})
.transpose()?;
let fixture_development_opt_in = match env::var_os(FIXTURE_DEVELOPMENT_ENV) {
None => false,
Some(value) if value == "1" => true,
Some(_) => {
return Err(format!("{FIXTURE_DEVELOPMENT_ENV} must be exactly 1 when set").into());
}
};
let cargo_profile = env::var("PROFILE").ok();
let out_dir = env::var_os("OUT_DIR").map(std::path::PathBuf::from);
select_catalog_build_mode(CatalogGateInput {
base_config: &base_config,
config_override: config_override.as_deref(),
fixture_development_opt_in,
cargo_profile: cargo_profile.as_deref(),
out_dir: out_dir.as_deref(),
})
.map_err(Into::into)
}
@@ -0,0 +1,431 @@
use std::{
collections::{BTreeMap, BTreeSet},
path::Path,
};
use lanspread_compat::catalog_bundle::load_catalog_bundle;
const PRODUCTION_RESOURCES: [&str; 3] = ["assets/*", "game.db", "manifests/*"];
const DEVELOPMENT_RESOURCES: [(&str, &str); 3] = [
(
"../../lanspread-peer-cli/catalogs/default/game.db",
"game.db",
),
(
"../../lanspread-peer-cli/catalogs/default/manifests/",
"manifests/",
),
("assets/*", "assets/"),
];
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum CatalogBuildMode {
FixtureDevelopment,
Production,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct CatalogGateInput<'a> {
pub(crate) base_config: &'a str,
pub(crate) config_override: Option<&'a str>,
pub(crate) fixture_development_opt_in: bool,
pub(crate) cargo_profile: Option<&'a str>,
pub(crate) out_dir: Option<&'a Path>,
}
/// Chooses whether the build may use fixture authority or must validate the
/// production catalog corpus.
///
/// Production is the default for every invocation, including Cargo's ordinary
/// `release` profile. Fixture authority requires both the exact checked-in
/// resource map and an explicit repository-development opt-in. A custom
/// `production` profile can never be downgraded by that opt-in.
pub(crate) fn select_catalog_build_mode(
input: CatalogGateInput<'_>,
) -> Result<CatalogBuildMode, String> {
let resources = effective_resources(input.base_config, input.config_override)?;
let forced_production = input.cargo_profile == Some("production")
|| input.out_dir.is_some_and(|out_dir| {
out_dir
.components()
.any(|component| component.as_os_str() == "production")
});
if input.fixture_development_opt_in && !forced_production {
if resources != ResourceAuthority::FixtureDevelopment {
return Err(
"fixture catalog opt-in requires the exact development resource map".to_owned(),
);
}
return Ok(CatalogBuildMode::FixtureDevelopment);
}
if resources != ResourceAuthority::Production {
let reason = if forced_production {
"the production Cargo profile"
} else {
"a build without the fixture catalog opt-in"
};
return Err(format!(
"{reason} requires exactly the production catalog resources"
));
}
Ok(CatalogBuildMode::Production)
}
/// Validates the exact catalog authority shipped by a production bundle.
///
/// This deliberately uses the same coherent database loader as the
/// application runtime before eagerly validating every manifest body. The
/// runtime loader catches database identity and join ambiguity; the final
/// pass is the release-time integrity gate for the complete manifest corpus.
pub(crate) fn validate_production_catalog(
game_db: impl AsRef<Path>,
manifests_root: impl AsRef<Path>,
) -> Result<(), String> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| format!("failed to create catalog validation runtime: {error}"))?;
let catalog = runtime
.block_on(load_catalog_bundle(
game_db.as_ref(),
manifests_root.as_ref(),
))
.map_err(|error| error.to_string())?;
catalog
.bundle()
.validate_all()
.map_err(|error| error.to_string())
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ResourceAuthority {
FixtureDevelopment,
Production,
}
fn effective_resources(
base_config: &str,
config_override: Option<&str>,
) -> Result<ResourceAuthority, String> {
let base = parse_config(base_config, "base Tauri config")?;
let base_resources = base
.pointer("/bundle/resources")
.ok_or_else(|| "base Tauri config does not declare bundle.resources".to_owned())?;
if let Some(raw_override) = config_override {
let config_override = parse_config(raw_override, "TAURI_CONFIG override")?;
if let Some(resources) = config_override.pointer("/bundle/resources") {
return classify_resources(resources);
}
}
classify_resources(base_resources)
}
fn parse_config(raw: &str, label: &str) -> Result<serde_json::Value, String> {
serde_json::from_str(raw).map_err(|error| format!("failed to parse {label}: {error}"))
}
fn classify_resources(resources: &serde_json::Value) -> Result<ResourceAuthority, String> {
if let Some(resources) = resources.as_array() {
let actual = resources
.iter()
.map(|resource| {
resource
.as_str()
.ok_or_else(|| "production resource entries must be strings".to_owned())
})
.collect::<Result<Vec<_>, _>>()?;
let unique = actual.iter().copied().collect::<BTreeSet<_>>();
let expected = PRODUCTION_RESOURCES.into_iter().collect::<BTreeSet<_>>();
if actual.len() == PRODUCTION_RESOURCES.len() && unique == expected {
return Ok(ResourceAuthority::Production);
}
return Err(format!(
"production resource list must be exactly {expected:?}, got {actual:?}"
));
}
if let Some(resources) = resources.as_object() {
let actual = resources
.iter()
.map(|(source, destination)| {
destination
.as_str()
.map(|destination| (source.as_str(), destination))
.ok_or_else(|| "development resource destinations must be strings".to_owned())
})
.collect::<Result<BTreeMap<_, _>, _>>()?;
let expected = DEVELOPMENT_RESOURCES
.into_iter()
.collect::<BTreeMap<_, _>>();
if actual == expected {
return Ok(ResourceAuthority::FixtureDevelopment);
}
return Err(format!(
"development resource map must be exactly {expected:?}, got {actual:?}"
));
}
Err("bundle.resources must be an exact production list or development map".to_owned())
}
#[cfg(test)]
mod tests {
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use super::*;
static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
const PRODUCTION: &str = r#"{
"bundle": {"resources": ["game.db", "manifests/*", "assets/*"]}
}"#;
const DEVELOPMENT: &str = r#"{
"bundle": {"resources": {
"../../lanspread-peer-cli/catalogs/default/game.db": "game.db",
"../../lanspread-peer-cli/catalogs/default/manifests/": "manifests/",
"assets/*": "assets/"
}}
}"#;
fn input(
config_override: Option<&str>,
fixture_development_opt_in: bool,
) -> CatalogGateInput<'_> {
CatalogGateInput {
base_config: PRODUCTION,
config_override,
fixture_development_opt_in,
cargo_profile: Some("release"),
out_dir: Some(Path::new("target/release/build/app/out")),
}
}
#[test]
fn ordinary_release_and_production_overrides_are_production_gated() {
assert_eq!(
select_catalog_build_mode(input(None, false)),
Ok(CatalogBuildMode::Production)
);
assert_eq!(
select_catalog_build_mode(input(Some(PRODUCTION), false)),
Ok(CatalogBuildMode::Production)
);
assert_eq!(
select_catalog_build_mode(input(Some(r#"{"build": {}}"#), false)),
Ok(CatalogBuildMode::Production)
);
}
#[test]
fn exact_development_map_requires_the_explicit_opt_in() {
assert_eq!(
select_catalog_build_mode(input(Some(DEVELOPMENT), true)),
Ok(CatalogBuildMode::FixtureDevelopment)
);
assert!(select_catalog_build_mode(input(Some(DEVELOPMENT), false)).is_err());
assert!(select_catalog_build_mode(input(None, true)).is_err());
}
#[test]
fn production_profile_cannot_be_downgraded_to_fixture_authority() {
for mut input in [
CatalogGateInput {
cargo_profile: Some("production"),
..input(Some(DEVELOPMENT), true)
},
CatalogGateInput {
out_dir: Some(Path::new("target/production/build/app/out")),
..input(Some(DEVELOPMENT), true)
},
] {
assert!(select_catalog_build_mode(input).is_err());
input.config_override = Some(PRODUCTION);
assert_eq!(
select_catalog_build_mode(input),
Ok(CatalogBuildMode::Production)
);
}
}
#[test]
fn incomplete_duplicated_or_unknown_resource_shapes_fail_closed() {
for config in [
r#"{"bundle":{"resources":["game.db","manifests/*"]}}"#,
r#"{"bundle":{"resources":["game.db","manifests/*","assets/*","assets/*"]}}"#,
r#"{"bundle":{"resources":{"fixture.db":"game.db"}}}"#,
r#"{"bundle":{"resources":true}}"#,
"not JSON",
] {
assert!(
select_catalog_build_mode(input(Some(config), false)).is_err(),
"accepted resource config: {config}"
);
}
}
#[test]
fn invalid_base_resources_fail_even_with_an_unrelated_override() {
let mut input = input(Some(r#"{"build": {}}"#), false);
input.base_config = r#"{"bundle":{"resources":["game.db"]}}"#;
assert!(select_catalog_build_mode(input).is_err());
}
#[derive(Clone, Copy)]
enum DatabaseCorruption {
DuplicateDbId,
MissingGenre,
DuplicateGenre,
}
impl DatabaseCorruption {
const fn expected_error(self) -> &'static str {
match self {
Self::DuplicateDbId => "duplicate raw game db_id",
Self::MissingGenre => "missing genre join expansion",
Self::DuplicateGenre => "duplicate genre join expansion",
}
}
}
#[test]
fn production_gate_rejects_malformed_runtime_database_authority() {
for corruption in [
DatabaseCorruption::DuplicateDbId,
DatabaseCorruption::MissingGenre,
DatabaseCorruption::DuplicateGenre,
] {
let fixture = MalformedCatalogFixture::new(corruption);
let error = validate_production_catalog(&fixture.game_db, &fixture.manifests)
.expect_err("the production gate must use the strict application loader");
assert!(
error.contains(corruption.expected_error()),
"unexpected error for {}: {error}",
corruption.expected_error()
);
}
}
struct MalformedCatalogFixture {
root: PathBuf,
game_db: PathBuf,
manifests: PathBuf,
}
impl MalformedCatalogFixture {
fn new(corruption: DatabaseCorruption) -> Self {
let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should follow the epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!(
"lanspread-tauri-catalog-gate-{}-{nanos}-{sequence}",
std::process::id()
));
let game_db = root.join("game.db");
let manifests = root.join("manifests");
fs::create_dir(&root).expect("test root should be created");
fs::create_dir(&manifests).expect("manifest root should be created");
create_malformed_database(&game_db, corruption);
for game_id in ["g", "h"] {
fs::write(manifests.join(format!("{game_id}.json")), b"not parsed\n")
.expect("placeholder manifest should be created");
}
Self {
root,
game_db,
manifests,
}
}
}
impl Drop for MalformedCatalogFixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn create_malformed_database(path: &Path, corruption: DatabaseCorruption) {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should be created");
runtime.block_on(async {
let options = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.expect("fixture database should open");
let result = async {
sqlx::query(
"CREATE TABLE games (
game_id TEXT NOT NULL, db_id INTEGER NOT NULL,
game_title TEXT NOT NULL, game_key TEXT NOT NULL,
game_release TEXT NOT NULL, game_publisher TEXT NOT NULL,
game_size REAL NOT NULL, game_readme_de TEXT NOT NULL,
game_readme_en TEXT NOT NULL, game_readme_fr TEXT NOT NULL,
game_maxplayers INTEGER NOT NULL, game_master_req INTEGER NOT NULL,
genre_id INTEGER NOT NULL, game_version TEXT NOT NULL
)",
)
.execute(&pool)
.await?;
sqlx::query(
"CREATE TABLE genre (genre_id INTEGER NOT NULL, genre_de TEXT NOT NULL)",
)
.execute(&pool)
.await?;
if !matches!(corruption, DatabaseCorruption::MissingGenre) {
sqlx::query("INSERT INTO genre VALUES (10, 'Strategy')")
.execute(&pool)
.await?;
}
if matches!(corruption, DatabaseCorruption::DuplicateGenre) {
sqlx::query("INSERT INTO genre VALUES (10, 'Duplicate')")
.execute(&pool)
.await?;
}
insert_game(&pool, 1, "g").await?;
if matches!(corruption, DatabaseCorruption::DuplicateDbId) {
insert_game(&pool, 1, "h").await?;
}
Ok::<(), sqlx::Error>(())
}
.await;
pool.close().await;
result.expect("malformed fixture database should be written");
});
}
async fn insert_game(
pool: &sqlx::SqlitePool,
db_id: i64,
game_id: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO games VALUES (
?, ?, 'Game', 'key', '2024', 'publisher', 1.0,
'de', 'en', 'fr', 4, 0, 10, '20240101'
)",
)
.bind(game_id)
.bind(db_id)
.execute(pool)
.await?;
Ok(())
}
}
+3860 -390
View File
@@ -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),
],
&registration.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(&registry, child_id, child);
let _child_guard = UnrarChildGuard { registry, child_id };
let mut stdout_bytes = Vec::new();
let mut stderr_bytes = Vec::new();
let mut status_code = None;
while let Some(event) = events.recv().await {
match event {
CommandEvent::Stdout(line) => {
stdout_bytes.extend(line);
stdout_bytes.push(b'\n');
}
CommandEvent::Stderr(line) => {
stderr_bytes.extend(line);
stderr_bytes.push(b'\n');
}
CommandEvent::Terminated(payload) => {
status_code = payload.code;
}
CommandEvent::Error(err) => {
log::warn!("unrar sidecar event error: {err}");
}
_ => {}
let 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(&registry, "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(&current)
.expect("begin should apply")
.expect("new attempt should publish")
.revision,
2
);
assert_eq!(
store.snapshot.open_attempts.get("alpha"),
Some(&current.attempt_id),
"the full snapshot must expose the current open attempt"
);
assert!(
store
.activity(
&current,
Some(DownloadVerificationActivity::RetryingInvalidSource),
)
.expect("activity should apply")
.is_some()
);
assert!(
store
.begin(&current)
.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(&registry), 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);
}
}
@@ -0,0 +1,483 @@
use std::{
fs::{self, File},
io::{ErrorKind, Read, Write as _},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
use cap_fs_ext::{
FollowSymlinks,
OpenOptionsFollowExt as _,
OpenOptionsMaybeDirExt as _,
OpenOptionsSyncExt as _,
};
use cap_primitives::{
ambient_authority,
fs::{self as cap_fs, OpenOptions as CapOpenOptions},
};
use eyre::{WrapErr as _, ensure};
use serde::{Deserialize, Serialize};
pub(crate) const POLICY_FILE_NAME: &str = "local-network-sharing.json";
const POLICY_VERSION: u32 = 1;
const MAX_POLICY_BYTES: u64 = 1024;
const UNIQUE_PATH_ATTEMPTS: usize = 64;
static NEXT_POLICY_SIDECAR: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct PolicyRecord {
version: u32,
enabled: bool,
}
struct PolicyLocation {
parent: File,
path: PathBuf,
file_name: PathBuf,
}
struct UniquePolicySidecar {
file_name: PathBuf,
file: File,
}
/// Loads the backend-owned Local network sharing policy.
///
/// A missing record is the intentional first-run default (`true`). Every other
/// read, safety, size, version, or schema failure is returned to the caller so
/// setup can fail closed to a disabled session and expose only a redacted
/// diagnostic to the frontend.
pub(crate) fn load(path: &Path) -> eyre::Result<bool> {
let location = match open_policy_location(path) {
Ok(location) => location,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(true),
Err(error) => {
return Err(error).wrap_err("failed to retain Local network sharing policy directory");
}
};
let mut file = match open_regular_file_at(&location, &location.file_name) {
Ok(file) => file,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(true),
Err(error) => return Err(error).wrap_err("failed to open Local network sharing policy"),
};
let length = file
.metadata()
.wrap_err("failed to inspect Local network sharing policy")?
.len();
ensure!(
length <= MAX_POLICY_BYTES,
"Local network sharing policy exceeds its size limit"
);
let mut bytes = Vec::with_capacity(usize::try_from(length).unwrap_or(0));
Read::by_ref(&mut file)
.take(MAX_POLICY_BYTES + 1)
.read_to_end(&mut bytes)
.wrap_err("failed to read Local network sharing policy")?;
ensure!(
u64::try_from(bytes.len()).unwrap_or(u64::MAX) <= MAX_POLICY_BYTES,
"Local network sharing policy exceeds its size limit"
);
let record: PolicyRecord =
serde_json::from_slice(&bytes).wrap_err("failed to parse Local network sharing policy")?;
ensure!(
record.version == POLICY_VERSION,
"unsupported Local network sharing policy version"
);
Ok(record.enabled)
}
/// Durably replaces the backend-owned sharing policy with one canonical
/// versioned record. Publication never truncates the previously accepted file:
/// a same-directory create-new sidecar is synced, atomically installed, and
/// followed by a directory sync on platforms that support it.
pub(crate) fn save(path: &Path, enabled: bool) -> eyre::Result<()> {
save_with_publish(path, enabled, publish_sidecar)
}
fn save_with_publish(
path: &Path,
enabled: bool,
publish: impl FnOnce(&PolicyLocation, &Path) -> std::io::Result<()>,
) -> eyre::Result<()> {
let parent = policy_parent_path(path)?;
fs::create_dir_all(parent)
.wrap_err("failed to create Local network sharing policy directory")?;
let location = open_policy_location(path)
.wrap_err("failed to retain Local network sharing policy directory")?;
let mut bytes = serde_json::to_vec(&PolicyRecord {
version: POLICY_VERSION,
enabled,
})
.wrap_err("failed to encode Local network sharing policy")?;
bytes.push(b'\n');
ensure!(
u64::try_from(bytes.len()).unwrap_or(u64::MAX) <= MAX_POLICY_BYTES,
"encoded Local network sharing policy exceeds its size limit"
);
let UniquePolicySidecar {
file_name,
mut file,
} = create_unique_file(&location)?;
let preparation = file
.write_all(&bytes)
.wrap_err("failed to write temporary Local network sharing policy")
.and_then(|()| {
file.sync_all()
.wrap_err("failed to sync temporary Local network sharing policy")
});
// Close the sidecar before either publication or cleanup. Windows does not
// permit replacing/removing an open file unless the original handle opted
// into delete sharing, which this capability open deliberately does not
// assume.
drop(file);
if let Err(error) = preparation {
let _ = cap_fs::remove_file(&location.parent, &file_name);
return Err(error);
}
let publication = publish(&location, &file_name)
.wrap_err("failed to publish Local network sharing policy")
.and_then(|()| sync_parent_directory(&location.parent));
if publication.is_err() {
let _ = cap_fs::remove_file(&location.parent, &file_name);
}
publication
}
fn create_unique_file(location: &PolicyLocation) -> eyre::Result<UniquePolicySidecar> {
for _ in 0..UNIQUE_PATH_ATTEMPTS {
let file_name = sidecar_file_name(&location.file_name);
let mut options = CapOpenOptions::new();
options.write(true).create_new(true);
options.follow(FollowSymlinks::No).nonblock(true);
match cap_fs::open(&location.parent, &file_name, &options) {
Ok(file) => return Ok(UniquePolicySidecar { file_name, file }),
Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
Err(error) => {
return Err(error)
.wrap_err("failed to create temporary Local network sharing policy");
}
}
}
eyre::bail!("could not allocate a unique Local network sharing policy sidecar")
}
fn sidecar_file_name(file_name: &Path) -> PathBuf {
let sequence = NEXT_POLICY_SIDECAR.fetch_add(1, Ordering::Relaxed);
let file_name = file_name.to_str().unwrap_or("local-network-sharing.json");
PathBuf::from(format!(
".{file_name}.tmp-{}-{sequence}",
std::process::id()
))
}
fn publish_sidecar(location: &PolicyLocation, temporary_file_name: &Path) -> std::io::Result<()> {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt as _;
use windows::{
Win32::Storage::FileSystem::{
MOVEFILE_REPLACE_EXISTING,
MOVEFILE_WRITE_THROUGH,
MoveFileExW,
},
core::PCWSTR,
};
let temporary_path = location.path.with_file_name(temporary_file_name);
let temporary_wide = temporary_path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
let destination_wide = location
.path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
// SAFETY: both pointers reference live, NUL-terminated UTF-16 buffers
// for the duration of the call. The sidecar is in the same retained
// directory as the destination.
unsafe {
MoveFileExW(
PCWSTR::from_raw(temporary_wide.as_ptr()),
PCWSTR::from_raw(destination_wide.as_ptr()),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
}
.map_err(std::io::Error::other)
}
#[cfg(not(windows))]
{
cap_fs::rename(
&location.parent,
temporary_file_name,
&location.parent,
&location.file_name,
)
}
}
fn policy_parent_path(path: &Path) -> eyre::Result<&Path> {
let parent = path
.parent()
.ok_or_else(|| eyre::eyre!("Local network sharing policy path has no parent"))?;
if parent.as_os_str().is_empty() {
Ok(Path::new("."))
} else {
Ok(parent)
}
}
fn open_policy_location(path: &Path) -> std::io::Result<PolicyLocation> {
let parent_path = policy_parent_path(path)
.map_err(|error| std::io::Error::new(ErrorKind::InvalidInput, error.to_string()))?;
let file_name = path.file_name().ok_or_else(|| {
std::io::Error::new(
ErrorKind::InvalidInput,
"Local network sharing policy path has no file name",
)
})?;
let parent = cap_fs::open_ambient(parent_path, &directory_options(), ambient_authority())?;
validate_directory_handle(&parent, parent_path)?;
Ok(PolicyLocation {
parent,
path: path.to_path_buf(),
file_name: file_name.into(),
})
}
fn open_regular_file_at(location: &PolicyLocation, file_name: &Path) -> std::io::Result<File> {
let file = cap_fs::open(&location.parent, file_name, &regular_file_options())?;
validate_regular_file_handle(&file, &location.path)?;
Ok(file)
}
fn directory_options() -> CapOpenOptions {
let mut options = CapOpenOptions::new();
options.read(true);
options
.maybe_dir(true)
.follow(FollowSymlinks::No)
.nonblock(true);
options
}
fn regular_file_options() -> CapOpenOptions {
let mut options = CapOpenOptions::new();
options.read(true);
options.follow(FollowSymlinks::No).nonblock(true);
options
}
fn validate_directory_handle(file: &File, display: &Path) -> std::io::Result<()> {
let metadata = file.metadata()?;
if !metadata.is_dir() || is_windows_reparse(&metadata) {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
format!(
"Local network sharing policy parent is not a non-reparse directory: {}",
display.display()
),
));
}
Ok(())
}
fn validate_regular_file_handle(file: &File, display: &Path) -> std::io::Result<()> {
let metadata = file.metadata()?;
if !metadata.is_file() || is_windows_reparse(&metadata) {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
format!(
"Local network sharing policy is not a non-reparse regular file: {}",
display.display()
),
));
}
Ok(())
}
#[cfg(windows)]
fn is_windows_reparse(metadata: &fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt as _;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
const fn is_windows_reparse(_metadata: &fs::Metadata) -> bool {
false
}
#[cfg(unix)]
fn sync_parent_directory(parent: &File) -> eyre::Result<()> {
parent
.sync_all()
.wrap_err("failed to sync Local network sharing policy directory")
}
#[cfg(not(unix))]
const fn sync_parent_directory(_parent: &File) -> eyre::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicU64, Ordering};
use super::*;
static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0);
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new() -> Self {
let sequence = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"lanspread-sharing-policy-test-{}-{sequence}",
std::process::id()
));
fs::create_dir(&path).expect("test directory should be unique");
Self(path)
}
fn policy(&self) -> PathBuf {
self.0.join(POLICY_FILE_NAME)
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn missing_policy_defaults_to_enabled() {
let directory = TestDirectory::new();
assert!(load(&directory.policy()).expect("missing policy should use first-run default"));
}
#[test]
fn missing_policy_parent_defaults_to_enabled() {
let directory = TestDirectory::new();
let policy = directory.0.join("not-created").join(POLICY_FILE_NAME);
assert!(load(&policy).expect("empty app data should use first-run default"));
}
#[test]
fn exact_boolean_policy_round_trips_canonically() {
let directory = TestDirectory::new();
let policy = directory.policy();
save(&policy, false).expect("false policy should persist");
assert!(!load(&policy).expect("false policy should load"));
assert_eq!(
fs::read(&policy).expect("policy should be readable"),
b"{\"version\":1,\"enabled\":false}\n"
);
save(&policy, true).expect("true policy should replace false");
assert!(load(&policy).expect("true policy should load"));
assert_eq!(
fs::read(&policy).expect("policy should be readable"),
b"{\"version\":1,\"enabled\":true}\n"
);
}
#[test]
fn malformed_unknown_or_oversized_policy_is_rejected() {
let directory = TestDirectory::new();
let policy = directory.policy();
let invalid_records = [
b"not-json".as_slice(),
b"{\"version\":2,\"enabled\":true}",
b"{\"version\":1,\"enabled\":true,\"extra\":false}",
b"{\"version\":1,\"enabled\":\"yes\"}",
];
for record in invalid_records {
fs::write(&policy, record).expect("invalid fixture should be written");
assert!(load(&policy).is_err(), "invalid record should fail closed");
}
fs::write(
&policy,
vec![
b' ';
usize::try_from(MAX_POLICY_BYTES + 1).expect("test policy limit should fit usize")
],
)
.expect("oversized fixture should be written");
assert!(
load(&policy).is_err(),
"oversized record should fail closed"
);
}
#[test]
fn non_regular_policy_is_rejected() {
let directory = TestDirectory::new();
fs::create_dir(directory.policy()).expect("directory fixture should be created");
assert!(load(&directory.policy()).is_err());
}
#[cfg(unix)]
#[test]
fn symlink_policy_is_rejected_without_following_it() {
use std::os::unix::fs::symlink;
let directory = TestDirectory::new();
let outside = directory.0.join("outside.json");
fs::write(&outside, b"{\"version\":1,\"enabled\":true}\n")
.expect("target fixture should be written");
symlink(&outside, directory.policy()).expect("symlink fixture should be created");
assert!(load(&directory.policy()).is_err());
}
#[test]
fn failed_publication_preserves_the_previous_policy() {
let directory = TestDirectory::new();
let policy = directory.policy();
save(&policy, false).expect("baseline policy should persist");
let error = save_with_publish(&policy, true, |_location, _sidecar| {
Err(std::io::Error::other("injected publication failure"))
})
.expect_err("injected publication failure must surface");
assert!(error.to_string().contains("publish"));
assert!(!load(&policy).expect("baseline policy should remain readable"));
}
#[test]
fn post_publication_error_requires_an_explicit_false_repair() {
let directory = TestDirectory::new();
let policy = directory.policy();
save(&policy, false).expect("baseline policy should persist");
let _error = save_with_publish(&policy, true, |location, sidecar| {
publish_sidecar(location, sidecar)?;
Err(std::io::Error::other("injected post-publication failure"))
})
.expect_err("post-publication failure must surface");
assert!(
load(&policy).expect("published bytes should demonstrate ambiguous failure"),
"an error after replacement need not preserve the old policy"
);
save(&policy, false).expect("privacy repair should durably restore false");
assert!(!load(&policy).expect("repaired policy should load disabled"));
}
}
@@ -36,6 +36,7 @@
],
"resources": [
"game.db",
"manifests/*",
"assets/*"
]
}
@@ -0,0 +1,10 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"resources": {
"../../lanspread-peer-cli/catalogs/default/game.db": "game.db",
"../../lanspread-peer-cli/catalogs/default/manifests/": "manifests/",
"assets/*": "assets/"
}
}
}
@@ -0,0 +1,10 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"resources": [
"game.db",
"manifests/*",
"assets/*"
]
}
}
@@ -0,0 +1,2 @@
#[path = "../build_support/catalog_gate.rs"]
mod catalog_gate;