fix(peer): refresh settled install state after operations

The follow-up review found a few stale lifecycle edges around local game
transactions. Recovery could sweep active roots, post-operation refreshes
still re-ran full startup recovery, and the UI kept inferring local-only state
from downloaded and installed flags instead of the backend availability.

This updates the peer lifecycle so startup recovery skips active operations,
install/update/uninstall refresh only the affected game after the operation
guard is dropped, and path-changing game-directory updates are rejected while
operations are active. It also removes the dead UpdateGame command, drops the
unused manifest_hash write field while preserving old JSON reads, renames the
internal install-finished event, and carries availability through the DB,
peer summaries, Tauri refreshes, and the React model.

The included follow-up documents record the review source, implementation
decisions, and the remaining FOLLOW_UP_2.md work so later commits can stay
small instead of reopening the completed plan items.

Test Plan:
- git diff --check
- just fmt
- just clippy
- just test

Follow-up-Plan: FOLLOW_UP_PLAN.md
This commit is contained in:
2026-05-16 08:50:51 +02:00
parent fce34c7bd2
commit b5d20c1e72
22 changed files with 1389 additions and 131 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
use std::path::Path;
use lanspread_db::db::Game;
use lanspread_db::db::{AVAILABILITY_LOCAL_ONLY, Game};
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
@@ -63,6 +63,7 @@ impl From<EtiGame> for Game {
size: (eti_game.game_size * 1024.0 * 1024.0 * 1024.0) as u64,
downloaded: false,
installed: false,
availability: AVAILABILITY_LOCAL_ONLY.to_string(),
eti_game_version: None,
local_version: None,
peer_count: 0, // ETI games start with 0 peers until peer system discovers them
+25 -1
View File
@@ -5,6 +5,10 @@ use std::{collections::HashMap, fmt, path::Path};
use serde::{Deserialize, Serialize};
pub const AVAILABILITY_READY: &str = "Ready";
pub const AVAILABILITY_DOWNLOADING: &str = "Downloading";
pub const AVAILABILITY_LOCAL_ONLY: &str = "LocalOnly";
/// Read version from version.ini file
/// # Errors
/// Returns error if file cannot be read or parsed
@@ -30,6 +34,10 @@ pub fn read_version_from_ini(game_dir: &Path) -> eyre::Result<Option<String>> {
}
}
fn default_availability() -> String {
AVAILABILITY_LOCAL_ONLY.to_string()
}
/// A game
#[derive(Clone, Serialize, Deserialize)]
pub struct Game {
@@ -57,6 +65,9 @@ pub struct Game {
/// only relevant for client (yeah... I know)
#[serde(default)]
pub installed: bool,
/// Backend-reported availability state for this game's local or peer summary.
#[serde(default = "default_availability")]
pub availability: String,
/// ETI game version from version.ini (YYYYMMDD format) (server)
pub eti_game_version: Option<String>,
/// Local game version from version.ini (YYYYMMDD format)
@@ -157,6 +168,7 @@ impl GameDB {
for game in self.games.values_mut() {
game.downloaded = false;
game.installed = false;
game.availability = AVAILABILITY_LOCAL_ONLY.to_string();
game.local_version = None;
}
}
@@ -207,7 +219,7 @@ impl fmt::Debug for GameFileDescription {
mod tests {
use serde_json::json;
use super::{Game, GameFileDescription};
use super::{AVAILABILITY_LOCAL_ONLY, Game, GameFileDescription};
#[test]
fn installed_defaults_to_false_when_missing() {
@@ -234,6 +246,7 @@ mod tests {
!game.installed,
"missing installed flag should default to false"
);
assert_eq!(game.availability, AVAILABILITY_LOCAL_ONLY);
}
#[test]
@@ -262,4 +275,15 @@ mod tests {
};
assert!(!other_game.is_version_ini());
}
#[test]
fn version_ini_predicate_accepts_windows_separators() {
let root = GameFileDescription {
game_id: "aoe2".to_string(),
relative_path: r"aoe2\version.ini".to_string(),
is_dir: false,
size: 8,
};
assert!(root.is_version_ini());
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ It is designed to run headless other crates (most notably
of the peer crate's platform layer, and the catalog set gates which local game
roots are announced or served.
- `PeerCommand` represents the small control surface exposed to the UI layer:
`ListGames`, `GetGame`, `DownloadGameFiles`, `InstallGame`, `UpdateGame`,
`ListGames`, `GetGame`, `DownloadGameFiles`, `InstallGame`,
`UninstallGame`, and `SetGameDir`.
- `PeerEvent` enumerates everything the peer runtime reports back to the UI:
library snapshots, download/install/uninstall lifecycle updates, runtime
+65 -8
View File
@@ -107,7 +107,9 @@ fn ensure_download_not_cancelled(
Ok(())
}
fn partition_download_descriptions(
/// Extracts the root `version.ini` descriptor while keeping every descriptor in
/// the transfer list. The chunk writer diverts the sentinel bytes into memory.
fn extract_version_descriptor(
game_id: &str,
game_file_descs: Vec<GameFileDescription>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
@@ -740,7 +742,7 @@ pub async fn download_game_files(
}
let (version_desc, transfer_descs) =
partition_download_descriptions(game_id, game_file_descs, &tx_notify_ui)?;
extract_version_descriptor(game_id, game_file_descs, &tx_notify_ui)?;
let version_buffer = match VersionIniBuffer::new(&version_desc) {
Ok(buffer) => Arc::new(buffer),
Err(err) => {
@@ -1111,10 +1113,57 @@ mod tests {
assert!(!game_root.join(".version.ini.discarded").exists());
}
#[tokio::test]
async fn begin_version_ini_transaction_parks_existing_sentinel() {
let temp = TempDir::new();
let game_root = temp.path().join("game");
tokio::fs::create_dir_all(&game_root)
.await
.expect("game root should be created");
tokio::fs::write(game_root.join("version.ini"), b"20240101")
.await
.expect("version sentinel should be written");
tokio::fs::write(game_root.join(".version.ini.tmp"), b"partial")
.await
.expect("tmp sentinel should be written");
begin_version_ini_transaction(&game_root)
.await
.expect("transaction should begin");
assert!(!game_root.join("version.ini").exists());
assert!(!game_root.join(".version.ini.tmp").exists());
assert_eq!(
std::fs::read(game_root.join(".version.ini.discarded"))
.expect("discarded sentinel should exist"),
b"20240101"
);
}
#[tokio::test]
async fn rollback_version_ini_transaction_sweeps_transients() {
let temp = TempDir::new();
let game_root = temp.path().join("game");
tokio::fs::create_dir_all(&game_root)
.await
.expect("game root should be created");
tokio::fs::write(game_root.join(".version.ini.tmp"), b"partial")
.await
.expect("tmp sentinel should be written");
tokio::fs::write(game_root.join(".version.ini.discarded"), b"old")
.await
.expect("discarded sentinel should be written");
rollback_version_ini_transaction(&game_root).await;
assert!(!game_root.join(".version.ini.tmp").exists());
assert!(!game_root.join(".version.ini.discarded").exists());
}
#[test]
fn partition_requires_exactly_one_root_version_ini() {
fn version_descriptor_extraction_keeps_nested_decoy_in_transfer_list() {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let duplicate = vec![
let nested_decoy = vec![
GameFileDescription {
game_id: "game".to_string(),
relative_path: "game/version.ini".to_string(),
@@ -1129,19 +1178,27 @@ mod tests {
},
];
let (version, transfer) = partition_download_descriptions("game", duplicate, &tx)
.expect("only one root sentinel");
let (version, transfer) =
extract_version_descriptor("game", nested_decoy, &tx).expect("only one root sentinel");
assert_eq!(version.relative_path, "game/version.ini");
assert_eq!(transfer.len(), 2);
}
#[test]
fn version_descriptor_extraction_requires_a_root_version_ini() {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let missing = vec![GameFileDescription {
game_id: "game".to_string(),
relative_path: "game/archive.eti".to_string(),
is_dir: false,
size: 1,
}];
assert!(partition_download_descriptions("game", missing, &tx).is_err());
assert!(extract_version_descriptor("game", missing, &tx).is_err());
}
#[test]
fn version_descriptor_extraction_rejects_duplicate_root_version_ini() {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let multiple = vec![
GameFileDescription {
game_id: "game".to_string(),
@@ -1156,6 +1213,6 @@ mod tests {
size: 8,
},
];
assert!(partition_download_descriptions("game", multiple, &tx).is_err());
assert!(extract_version_descriptor("game", multiple, &tx).is_err());
}
}
+333 -76
View File
@@ -1,6 +1,11 @@
//! Command handlers for peer commands.
use std::{collections::hash_map::Entry, net::SocketAddr, path::PathBuf, sync::Arc};
use std::{
collections::{HashSet, hash_map::Entry},
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
};
use lanspread_db::db::{GameDB, GameFileDescription};
use tokio::sync::{RwLock, mpsc::UnboundedSender};
@@ -19,6 +24,7 @@ use crate::{
get_game_file_descriptions,
local_dir_is_directory,
local_download_available,
rescan_local_game,
scan_local_library,
version_ini_is_regular_file,
},
@@ -211,12 +217,7 @@ pub async fn handle_download_game_files_command(
{
log::error!("Failed to send DownloadGameFilesFinished event: {e}");
}
spawn_install_operation(
ctx,
tx_notify_ui,
id.clone(),
RequestedInstallOperation::Auto,
);
spawn_install_operation(ctx, tx_notify_ui, id.clone());
} else {
log::error!("No trusted peers available after majority validation for game {id}");
}
@@ -267,13 +268,7 @@ pub async fn handle_download_game_files_command(
match result {
Ok(()) => {
run_install_operation(
&ctx_clone,
&tx_notify_ui_clone,
download_id,
RequestedInstallOperation::Auto,
)
.await;
run_install_operation(&ctx_clone, &tx_notify_ui_clone, download_id).await;
}
Err(e) => {
log::error!("Download failed for {download_id}: {e}");
@@ -288,16 +283,7 @@ pub async fn handle_install_game_command(
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
) {
spawn_install_operation(ctx, tx_notify_ui, id, RequestedInstallOperation::Install);
}
/// Handles the `UpdateGame` command.
pub async fn handle_update_game_command(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
) {
spawn_install_operation(ctx, tx_notify_ui, id, RequestedInstallOperation::Update);
spawn_install_operation(ctx, tx_notify_ui, id);
}
/// Handles the `UninstallGame` command.
@@ -313,32 +299,15 @@ pub async fn handle_uninstall_game_command(
});
}
#[derive(Clone, Copy)]
enum RequestedInstallOperation {
Auto,
Install,
Update,
}
fn spawn_install_operation(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
requested: RequestedInstallOperation,
) {
fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
let ctx = ctx.clone();
let tx_notify_ui = tx_notify_ui.clone();
ctx.task_tracker.clone().spawn(async move {
run_install_operation(&ctx, &tx_notify_ui, id, requested).await;
run_install_operation(&ctx, &tx_notify_ui, id).await;
});
}
async fn run_install_operation(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
requested: RequestedInstallOperation,
) {
async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
if !catalog_contains(ctx, &id).await {
log::warn!("Ignoring install command for non-catalog game {id}");
return;
@@ -352,14 +321,10 @@ async fn run_install_operation(
}
let local_present = local_dir_is_directory(&game_root).await;
let operation = match requested {
RequestedInstallOperation::Auto | RequestedInstallOperation::Install if local_present => {
InstallOperation::Updating
}
RequestedInstallOperation::Auto | RequestedInstallOperation::Install => {
InstallOperation::Installing
}
RequestedInstallOperation::Update => InstallOperation::Updating,
let operation = if local_present {
InstallOperation::Updating
} else {
InstallOperation::Installing
};
let operation_kind = match operation {
InstallOperation::Installing => OperationKind::Installing,
@@ -371,20 +336,24 @@ async fn run_install_operation(
return;
}
let _operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
events::send(
tx_notify_ui,
PeerEvent::InstallGameBegin {
id: id.clone(),
operation,
},
);
let result = {
let _operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
events::send(
tx_notify_ui,
PeerEvent::InstallGameBegin {
id: id.clone(),
operation,
},
);
let result = match operation {
InstallOperation::Installing => {
install::install(&game_root, &id, ctx.unpacker.clone()).await
match operation {
InstallOperation::Installing => {
install::install(&game_root, &id, ctx.unpacker.clone()).await
}
InstallOperation::Updating => {
install::update(&game_root, &id, ctx.unpacker.clone()).await
}
}
InstallOperation::Updating => install::update(&game_root, &id, ctx.unpacker.clone()).await,
};
match result {
@@ -393,14 +362,17 @@ async fn run_install_operation(
tx_notify_ui,
PeerEvent::InstallGameFinished { id: id.clone() },
);
if let Err(err) = load_local_library(ctx, tx_notify_ui).await {
if let Err(err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
log::error!("Failed to refresh local library after install: {err}");
}
}
Err(err) => {
log::error!("Install operation failed for {id}: {err}");
events::send(tx_notify_ui, PeerEvent::InstallGameFailed { id });
if let Err(refresh_err) = load_local_library(ctx, tx_notify_ui).await {
events::send(
tx_notify_ui,
PeerEvent::InstallGameFailed { id: id.clone() },
);
if let Err(refresh_err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
log::error!("Failed to refresh local library after install failure: {refresh_err}");
}
}
@@ -418,14 +390,18 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
return;
}
let _operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
let game_root = { ctx.game_dir.read().await.join(&id) };
events::send(
tx_notify_ui,
PeerEvent::UninstallGameBegin { id: id.clone() },
);
let result = {
let _operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
events::send(
tx_notify_ui,
PeerEvent::UninstallGameBegin { id: id.clone() },
);
match install::uninstall(&game_root, &id).await {
install::uninstall(&game_root, &id).await
};
match result {
Ok(()) => {
events::send(
tx_notify_ui,
@@ -441,7 +417,7 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
}
}
if let Err(err) = load_local_library(ctx, tx_notify_ui).await {
if let Err(err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
log::error!("Failed to refresh local library after uninstall: {err}");
}
}
@@ -467,6 +443,32 @@ pub async fn handle_set_game_dir_command(
tx_notify_ui: &UnboundedSender<PeerEvent>,
game_dir: PathBuf,
) {
let current_game_dir = ctx.game_dir.read().await.clone();
if current_game_dir == game_dir {
log::info!(
"Game directory {} unchanged; refreshing without recovery",
game_dir.display()
);
let tx_notify_ui = tx_notify_ui.clone();
let ctx_clone = ctx.clone();
ctx.task_tracker.spawn(async move {
if let Err(err) = refresh_local_library(&ctx_clone, &tx_notify_ui).await {
log::error!("Failed to refresh local game database: {err}");
}
});
return;
}
let active_ids = active_operation_ids(ctx).await;
if !active_ids.is_empty() {
log::warn!(
"Rejecting game directory change to {} while operations are active for: {}",
game_dir.display(),
active_ids.into_iter().collect::<Vec<_>>().join(", ")
);
return;
}
*ctx.game_dir.write().await = game_dir.clone();
log::info!("Game directory set to: {}", game_dir.display());
@@ -489,13 +491,46 @@ pub async fn load_local_library(
tx_notify_ui: &UnboundedSender<PeerEvent>,
) -> eyre::Result<()> {
let game_dir = { ctx.game_dir.read().await.clone() };
install::recover_on_startup(&game_dir).await?;
let active_ids = active_operation_ids(ctx).await;
install::recover_on_startup(&game_dir, &active_ids).await?;
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir).await
}
async fn refresh_local_library(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
) -> eyre::Result<()> {
let game_dir = { ctx.game_dir.read().await.clone() };
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir).await
}
async fn scan_and_announce_local_library(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
game_dir: &Path,
) -> eyre::Result<()> {
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(&game_dir, &catalog).await?;
let scan = scan_local_library(game_dir, &catalog).await?;
update_and_announce_games(ctx, tx_notify_ui, scan).await;
Ok(())
}
async fn refresh_local_game(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
) -> eyre::Result<()> {
let game_dir = { ctx.game_dir.read().await.clone() };
let catalog = ctx.catalog.read().await.clone();
let scan = rescan_local_game(&game_dir, &catalog, id).await?;
update_and_announce_games(ctx, tx_notify_ui, scan).await;
Ok(())
}
async fn active_operation_ids(ctx: &Ctx) -> HashSet<String> {
ctx.active_operations.read().await.keys().cloned().collect()
}
/// Handles the `GetPeerCount` command.
pub async fn handle_get_peer_count_command(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>) {
log::info!("GetPeerCount command received");
@@ -589,3 +624,225 @@ pub async fn update_and_announce_games(
}
}
}
#[cfg(test)]
mod tests {
use std::{
collections::HashSet,
path::{Path, PathBuf},
sync::Arc,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tokio::sync::mpsc;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use super::*;
use crate::{UnpackFuture, Unpacker};
struct TempDir(PathBuf);
impl TempDir {
fn new(prefix: &str) -> Self {
let mut path = std::env::temp_dir();
path.push(format!(
"{prefix}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&path).expect("temp dir should be created");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
fn game_root(&self) -> PathBuf {
self.0.join("game")
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
struct FakeUnpacker;
impl Unpacker for FakeUnpacker {
fn unpack<'a>(&'a self, _archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
Box::pin(async move {
tokio::fs::write(dest.join("payload.txt"), b"installed").await?;
Ok(())
})
}
}
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent dir should be created");
}
std::fs::write(path, bytes).expect("file should be written");
}
fn test_ctx(game_dir: PathBuf) -> Ctx {
Ctx::new(
Arc::new(RwLock::new(PeerGameDB::new())),
"peer".to_string(),
game_dir,
Arc::new(FakeUnpacker),
CancellationToken::new(),
TaskTracker::new(),
Arc::new(RwLock::new(HashSet::from(["game".to_string()]))),
)
}
async fn recv_event(rx: &mut mpsc::UnboundedReceiver<PeerEvent>) -> PeerEvent {
tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
.expect("event should arrive")
.expect("event channel should remain open")
}
fn assert_local_update(event: PeerEvent, installed: bool, downloaded: bool) {
let PeerEvent::LocalGamesUpdated(games) = event else {
panic!("expected LocalGamesUpdated");
};
let game = games
.iter()
.find(|game| game.id == "game")
.expect("game should be announced");
assert_eq!(game.installed, installed);
assert_eq!(game.downloaded, downloaded);
}
#[tokio::test]
async fn install_refreshes_settled_state_after_guard_release() {
let temp = TempDir::new("lanspread-handler-install");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("game.eti"), b"archive");
let ctx = test_ctx(temp.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
run_install_operation(&ctx, &tx, "game".to_string()).await;
match recv_event(&mut rx).await {
PeerEvent::InstallGameBegin { id, operation } => {
assert_eq!(id, "game");
assert_eq!(operation, InstallOperation::Installing);
}
_ => panic!("expected InstallGameBegin"),
}
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
));
assert!(ctx.active_operations.read().await.is_empty());
assert_local_update(recv_event(&mut rx).await, true, true);
}
#[tokio::test]
async fn update_refreshes_settled_state_after_guard_release() {
let temp = TempDir::new("lanspread-handler-update");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("local").join("old.txt"), b"old");
let ctx = test_ctx(temp.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
run_install_operation(&ctx, &tx, "game".to_string()).await;
match recv_event(&mut rx).await {
PeerEvent::InstallGameBegin { id, operation } => {
assert_eq!(id, "game");
assert_eq!(operation, InstallOperation::Updating);
}
_ => panic!("expected InstallGameBegin"),
}
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
));
assert!(ctx.active_operations.read().await.is_empty());
assert_local_update(recv_event(&mut rx).await, true, true);
}
#[tokio::test]
async fn uninstall_refreshes_settled_state_after_guard_release() {
let temp = TempDir::new("lanspread-handler-uninstall");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("local").join("old.txt"), b"old");
let ctx = test_ctx(temp.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
run_uninstall_operation(&ctx, &tx, "game".to_string()).await;
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::UninstallGameBegin { id } if id == "game"
));
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::UninstallGameFinished { id } if id == "game"
));
assert!(ctx.active_operations.read().await.is_empty());
assert_local_update(recv_event(&mut rx).await, false, true);
}
#[tokio::test]
async fn path_changing_set_game_dir_is_rejected_while_operations_are_active() {
let current = TempDir::new("lanspread-handler-current-dir");
let next = TempDir::new("lanspread-handler-next-dir");
let ctx = test_ctx(current.path().to_path_buf());
ctx.active_operations
.write()
.await
.insert("game".to_string(), OperationKind::Downloading);
let (tx, _rx) = mpsc::unbounded_channel();
handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await;
assert_eq!(*ctx.game_dir.read().await, current.path());
}
#[tokio::test]
async fn same_path_set_game_dir_refreshes_without_recovery() {
let temp = TempDir::new("lanspread-handler-same-dir");
write_file(&temp.game_root().join(".version.ini.tmp"), b"tmp");
let ctx = test_ctx(temp.path().to_path_buf());
let (tx, _rx) = mpsc::unbounded_channel();
handle_set_game_dir_command(&ctx, &tx, temp.path().to_path_buf()).await;
ctx.task_tracker.close();
ctx.task_tracker.wait().await;
assert!(temp.game_root().join(".version.ini.tmp").is_file());
}
#[tokio::test]
async fn path_changing_set_game_dir_runs_recovery() {
let current = TempDir::new("lanspread-handler-old-dir");
let next = TempDir::new("lanspread-handler-new-dir");
write_file(&next.game_root().join(".version.ini.tmp"), b"tmp");
let ctx = test_ctx(current.path().to_path_buf());
let (tx, _rx) = mpsc::unbounded_channel();
handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await;
ctx.task_tracker.close();
ctx.task_tracker.wait().await;
assert!(!next.game_root().join(".version.ini.tmp").exists());
}
}
+53 -2
View File
@@ -25,7 +25,6 @@ pub struct InstallIntent {
pub recorded_at: u64,
pub state: InstallIntentState,
pub eti_version: Option<String>,
pub manifest_hash: Option<u64>,
}
impl InstallIntent {
@@ -36,7 +35,6 @@ impl InstallIntent {
recorded_at: now_unix_secs(),
state,
eti_version,
manifest_hash: None,
}
}
@@ -193,4 +191,57 @@ mod tests {
let recovered = read_intent(temp.path(), "game").await;
assert_eq!(recovered.state, InstallIntentState::None);
}
#[tokio::test]
async fn mismatched_id_is_treated_as_missing() {
let temp = TempDir::new();
tokio::fs::write(
intent_path(temp.path()),
r#"{"schema_version":1,"id":"other","recorded_at":0,"state":"Updating"}"#,
)
.await
.expect("intent should be written");
let recovered = read_intent(temp.path(), "game").await;
assert_eq!(recovered.state, InstallIntentState::None);
}
#[tokio::test]
async fn corrupt_intent_is_treated_as_missing() {
let temp = TempDir::new();
tokio::fs::write(intent_path(temp.path()), b"not json")
.await
.expect("intent should be written");
let recovered = read_intent(temp.path(), "game").await;
assert_eq!(recovered.state, InstallIntentState::None);
}
#[tokio::test]
async fn old_manifest_hash_field_is_ignored_and_new_writes_omit_it() {
let temp = TempDir::new();
tokio::fs::write(
intent_path(temp.path()),
r#"{"schema_version":1,"id":"game","recorded_at":0,"state":"Updating","eti_version":"20240101","manifest_hash":42}"#,
)
.await
.expect("intent should be written");
let recovered = read_intent(temp.path(), "game").await;
assert_eq!(recovered.state, InstallIntentState::Updating);
assert_eq!(recovered.eti_version.as_deref(), Some("20240101"));
write_intent(temp.path(), &InstallIntent::none("game", None))
.await
.expect("intent should be written");
let written = tokio::fs::read_to_string(intent_path(temp.path()))
.await
.expect("intent should be readable");
assert!(
serde_json::from_str::<serde_json::Value>(&written)
.expect("intent should parse")
.get("manifest_hash")
.is_none()
);
}
}
@@ -1,4 +1,5 @@
use std::{
collections::HashSet,
io::ErrorKind,
path::{Path, PathBuf},
sync::Arc,
@@ -115,7 +116,7 @@ pub async fn uninstall(game_root: &Path, id: &str) -> eyre::Result<()> {
}
}
pub async fn recover_on_startup(game_dir: &Path) -> eyre::Result<()> {
pub async fn recover_on_startup(game_dir: &Path, active_ids: &HashSet<String>) -> eyre::Result<()> {
recover_download_transients(game_dir).await?;
let mut entries = match tokio::fs::read_dir(game_dir).await {
@@ -135,6 +136,10 @@ pub async fn recover_on_startup(game_dir: &Path) -> eyre::Result<()> {
if id == ".lanspread" {
continue;
}
if active_ids.contains(&id) {
log::debug!("Skipping recovery for active game root {id}");
continue;
}
recover_game_root(&entry.path(), &id).await?;
}
@@ -457,6 +462,7 @@ impl From<bool> for FsEntryState {
#[cfg(test)]
mod tests {
use std::{
collections::HashSet,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
@@ -551,6 +557,29 @@ mod tests {
assert_eq!(intent.state, InstallIntentState::None);
}
#[tokio::test]
async fn install_unpacks_multiple_root_eti_archives_in_sorted_order() {
let temp = TempDir::new();
let root = temp.game_root();
write_file(&root.join("b.eti"), b"archive");
write_file(&root.join("a.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
let unpacker = Arc::new(FakeUnpacker::default());
install(&root, "game", unpacker.clone())
.await
.expect("install should succeed");
let archives = unpacker
.archives
.lock()
.expect("archive list should not be poisoned")
.iter()
.filter_map(|path| path.file_name()?.to_str().map(ToOwned::to_owned))
.collect::<Vec<_>>();
assert_eq!(archives, vec!["a.eti", "b.eti"]);
}
#[tokio::test]
async fn update_failure_restores_previous_local() {
let temp = TempDir::new();
@@ -571,6 +600,26 @@ mod tests {
assert_eq!(intent.state, InstallIntentState::None);
}
#[tokio::test]
async fn update_success_promotes_new_local_and_removes_backup() {
let temp = TempDir::new();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("local").join("old.txt"), b"old");
update(&root, "game", successful_unpacker())
.await
.expect("update should succeed");
assert!(root.join("local").join("payload.txt").is_file());
assert!(!root.join("local").join("old.txt").exists());
assert!(!root.join(".local.installing").exists());
assert!(!root.join(".local.backup").exists());
let intent = read_intent(&root, "game").await;
assert_eq!(intent.state, InstallIntentState::None);
}
#[tokio::test]
async fn uninstall_removes_only_local_install() {
let temp = TempDir::new();
@@ -648,4 +697,20 @@ mod tests {
assert!(!root.join(VERSION_TMP_FILE).exists());
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
}
#[tokio::test]
async fn startup_recovery_skips_active_game_roots() {
let temp = TempDir::new();
let active_root = temp.0.join("active");
let inactive_root = temp.0.join("inactive");
write_file(&active_root.join(VERSION_TMP_FILE), b"tmp");
write_file(&inactive_root.join(VERSION_TMP_FILE), b"tmp");
recover_on_startup(&temp.0, &HashSet::from(["active".to_string()]))
.await
.expect("recovery should succeed");
assert!(active_root.join(VERSION_TMP_FILE).is_file());
assert!(!inactive_root.join(VERSION_TMP_FILE).exists());
}
}
-6
View File
@@ -58,7 +58,6 @@ use crate::{
handle_list_games_command,
handle_set_game_dir_command,
handle_uninstall_game_command,
handle_update_game_command,
load_local_library,
},
};
@@ -159,8 +158,6 @@ pub enum PeerCommand {
},
/// Install already-downloaded archives into `local/`.
InstallGame { id: String },
/// Update an installed game from already-downloaded archives.
UpdateGame { id: String },
/// Remove only the `local/` install for a game.
UninstallGame { id: String },
/// Set the local game directory.
@@ -291,9 +288,6 @@ async fn handle_peer_commands(
PeerCommand::InstallGame { id } => {
handle_install_game_command(ctx, tx_notify_ui, id).await;
}
PeerCommand::UpdateGame { id } => {
handle_update_game_command(ctx, tx_notify_ui, id).await;
}
PeerCommand::UninstallGame { id } => {
handle_uninstall_game_command(ctx, tx_notify_ui, id).await;
}
+17 -1
View File
@@ -8,7 +8,14 @@ use std::{
time::{SystemTime, UNIX_EPOCH},
};
use lanspread_db::db::{Game, GameDB, GameFileDescription};
use lanspread_db::db::{
AVAILABILITY_DOWNLOADING,
AVAILABILITY_LOCAL_ONLY,
AVAILABILITY_READY,
Game,
GameDB,
GameFileDescription,
};
use lanspread_proto::{Availability, GameSummary};
use serde::{Deserialize, Serialize};
@@ -380,12 +387,21 @@ pub(crate) fn game_from_summary(summary: &GameSummary) -> Game {
size: summary.size,
downloaded: summary.downloaded,
installed: summary.installed,
availability: availability_label(&summary.availability).to_string(),
eti_game_version: summary.eti_version.clone(),
local_version: summary.eti_version.clone(),
peer_count: 0,
}
}
pub(crate) fn availability_label(availability: &Availability) -> &'static str {
match availability {
Availability::Ready => AVAILABILITY_READY,
Availability::Downloading => AVAILABILITY_DOWNLOADING,
Availability::LocalOnly => AVAILABILITY_LOCAL_ONLY,
}
}
struct IndexUpdate {
summary: Option<GameSummary>,
changed: bool,
+10 -2
View File
@@ -7,10 +7,10 @@ use std::{
time::{Duration, Instant},
};
use lanspread_db::db::{Game, GameFileDescription};
use lanspread_db::db::{AVAILABILITY_READY, Game, GameFileDescription};
use lanspread_proto::{Availability, GameSummary, LibraryDelta, LibrarySnapshot};
use crate::library::compute_library_digest;
use crate::{library::compute_library_digest, local_games::availability_label};
pub type PeerId = String;
/// Information about a discovered peer.
@@ -293,6 +293,10 @@ impl PeerGameDB {
}
if game_is_ready(game) {
existing.downloaded = true;
existing.availability = AVAILABILITY_READY.to_string();
} else if !existing.downloaded {
existing.availability =
availability_label(&game.availability).to_string();
}
if game.installed {
existing.installed = true;
@@ -744,6 +748,7 @@ fn summary_to_game(summary: &GameSummary) -> Game {
size: summary.size,
downloaded: summary.downloaded,
installed: summary.installed,
availability: availability_label(&summary.availability).to_string(),
eti_game_version,
local_version: None,
peer_count: 0,
@@ -754,6 +759,8 @@ fn summary_to_game(summary: &GameSummary) -> Game {
mod tests {
use std::net::SocketAddr;
use lanspread_db::db::AVAILABILITY_LOCAL_ONLY;
use super::*;
fn addr(port: u16) -> SocketAddr {
@@ -817,6 +824,7 @@ mod tests {
assert_eq!(games.len(), 1);
assert_eq!(games[0].peer_count, 0);
assert!(!games[0].downloaded);
assert_eq!(games[0].availability, AVAILABILITY_LOCAL_ONLY);
assert_eq!(games[0].eti_game_version, None);
assert!(db.peers_with_game("game").is_empty());
+12 -5
View File
@@ -2,7 +2,12 @@
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
use lanspread_db::db::Game;
use lanspread_db::db::{
AVAILABILITY_DOWNLOADING,
AVAILABILITY_LOCAL_ONLY,
AVAILABILITY_READY,
Game,
};
use lanspread_proto::{Availability, GameSummary};
use tokio::sync::RwLock;
@@ -26,10 +31,12 @@ pub async fn ensure_peer_id_for_addr(
}
pub fn summary_from_game(game: &Game) -> GameSummary {
let availability = if game.downloaded {
Availability::Ready
} else {
Availability::LocalOnly
let availability = match game.availability.as_str() {
AVAILABILITY_READY => Availability::Ready,
AVAILABILITY_DOWNLOADING => Availability::Downloading,
AVAILABILITY_LOCAL_ONLY => Availability::LocalOnly,
_ if game.downloaded => Availability::Ready,
_ => Availability::LocalOnly,
};
GameSummary {
+2
View File
@@ -7,6 +7,8 @@ pub const PROTOCOL_VERSION: u32 = 2;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum Availability {
Ready,
/// Wire-compatible transitional state. Local library summaries currently
/// suppress active operations instead of advertising this value.
Downloading,
LocalOnly,
}
@@ -9,7 +9,13 @@ use std::{
use eyre::bail;
use lanspread_compat::eti::get_games;
use lanspread_db::db::{Game, GameDB, GameFileDescription};
use lanspread_db::db::{
AVAILABILITY_LOCAL_ONLY,
AVAILABILITY_READY,
Game,
GameDB,
GameFileDescription,
};
use lanspread_peer::{
InstallOperation,
PeerCommand,
@@ -356,6 +362,11 @@ fn update_game_installation_state(game: &mut Game, games_root: &Path) {
let installed = local_install_is_present(&game_path);
game.installed = installed;
game.availability = if downloaded {
AVAILABILITY_READY.to_string()
} else {
AVAILABILITY_LOCAL_ONLY.to_string()
};
// Size stays anchored to bundled game.db; skip expensive recalculation.
@@ -534,6 +545,23 @@ async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> ta
}
let state = app_handle.state::<LanSpreadState>();
let current_path = state.games_folder.read().await.clone();
let active_ids = state
.active_operations
.read()
.await
.keys()
.cloned()
.collect::<Vec<_>>();
if current_path != path && !active_ids.is_empty() {
log::warn!(
"Rejecting game directory change to {} while UI operations are active for: {}",
games_folder.display(),
active_ids.join(", ")
);
return Ok(());
}
*state.games_folder.write().await = path;
ensure_bundled_game_db_loaded(&app_handle).await;
@@ -601,6 +629,9 @@ async fn update_local_games_in_db(local_games: Vec<Game>, app: AppHandle) {
if let Some(existing_game) = game_db.get_mut_game_by_id(&local_game.id) {
existing_game.downloaded = local_game.downloaded;
existing_game.installed = local_game.installed;
existing_game
.availability
.clone_from(&local_game.availability);
existing_game
.local_version
.clone_from(&local_game.local_version);
@@ -618,6 +649,7 @@ async fn update_local_games_in_db(local_games: Vec<Game>, app: AppHandle) {
);
game.downloaded = false;
game.installed = false;
game.availability = AVAILABILITY_LOCAL_ONLY.to_string();
game.local_version = None;
}
}
@@ -887,7 +919,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
.remove(&id);
emit_game_id_event(
app_handle,
"game-unpack-finished",
"game-install-finished",
&id,
"PeerEvent::InstallGameFinished",
);
+14 -6
View File
@@ -40,6 +40,7 @@ interface Game {
thumbnail: Uint8Array | number[];
downloaded: boolean;
installed: boolean;
availability: GameAvailability;
install_status: InstallStatus;
eti_game_version?: string;
local_version?: string;
@@ -48,6 +49,12 @@ interface Game {
peer_count: number;
}
enum GameAvailability {
Ready = 'Ready',
Downloading = 'Downloading',
LocalOnly = 'LocalOnly',
}
interface GameThumbnailProps {
gameId: string;
alt: string;
@@ -105,6 +112,7 @@ const mergeGameUpdate = (game: Game, previous?: Game): Game => {
return {
...game,
availability: game.availability ?? (game.downloaded ? GameAvailability.Ready : GameAvailability.LocalOnly),
install_status: installStatus,
status_message: localStateChanged ? undefined : previous?.status_message,
status_level: localStateChanged ? undefined : previous?.status_level,
@@ -289,11 +297,11 @@ const App = () => {
}, [gameDir]);
useEffect(() => {
// Listen for game-unpack-finished events specifically
const setupUnpackListener = async () => {
const unlisten = await listen('game-unpack-finished', (event) => {
// Listen for game-install-finished events specifically
const setupInstallFinishedListener = async () => {
const unlisten = await listen('game-install-finished', (event) => {
const game_id = event.payload as string;
console.log(`🗲 game-unpack-finished ${game_id} event received`);
console.log(`🗲 game-install-finished ${game_id} event received`);
clearCheckingPeersTimeout(game_id);
setGameItems(prev => prev.map(item => item.id === game_id
? {
@@ -316,7 +324,7 @@ const App = () => {
return unlisten;
};
setupUnpackListener();
setupInstallFinishedListener();
}, [gameDir]);
useEffect(() => {
@@ -739,7 +747,7 @@ const App = () => {
<span className="size-text">{(item.size / 1024 / 1024 / 1024).toFixed(1)} GB</span>
</div>
<div className="badges">
{item.installed && !item.downloaded && (
{item.installed && item.availability === GameAvailability.LocalOnly && (
<span className="badge local-only">LocalOnly</span>
)}
{!item.installed && item.downloaded && item.local_version && (