refactor(peer): split local library and operation UI events

Replace the `a9f9845` local-update dedup cache with explicit peer event
semantics. Local scans now emit `LocalLibraryChanged` when the library changes,
while operation mutations emit `ActiveOperationsChanged` from the mutation
path. Tauri keeps joining those facts into the existing `games-list-updated`
payload, so the frontend contract stays stable.

This removes the cache/invalidation coupling between scan emission and
operation state. The remaining forced local snapshot is explicit: accepted game
directory changes can refresh the UI for an equivalent new path without sending
a peer library delta.

Operation guard cleanup and liveness cancellation now publish the same active
operation snapshot as normal command-handler transitions. The peer CLI JSONL
events follow the same split with `local-library-changed` and
`active-operations-changed`.

Test Plan:
- `just fmt`
- `CARGO_BUILD_RUSTC_WRAPPER= just test`
- `CARGO_BUILD_RUSTC_WRAPPER= just clippy`
- `git diff --check`

Refs: CLEAN_CODE_PLAN_1.md
This commit is contained in:
2026-05-18 21:25:20 +02:00
parent be00a7a298
commit 41e9a0efc1
14 changed files with 657 additions and 255 deletions
+7 -9
View File
@@ -359,19 +359,17 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
shared.state.write().await.remote_games = games.clone();
("list-games", json!({ "games": games }))
}
PeerEvent::LocalGamesUpdated {
games,
active_operations,
} => {
PeerEvent::LocalLibraryChanged { games } => {
let mut state = shared.state.write().await;
state.local_games.clone_from(&games);
("local-library-changed", json!({ "games": games }))
}
PeerEvent::ActiveOperationsChanged { active_operations } => {
let mut state = shared.state.write().await;
state.active_operations.clone_from(&active_operations);
(
"local-games-updated",
json!({
"games": games,
"active_operations": active_operations_json(&active_operations),
}),
"active-operations-changed",
json!({ "active_operations": active_operations_json(&active_operations) }),
)
}
PeerEvent::GotGameFiles {
+5
View File
@@ -77,6 +77,11 @@ When a peer is discovered:
- an active operation lock drops events for that game;
- a rescan already running for the ID sets a rescan-pending flag;
- the running rescan loops once more when that flag was set.
- Local library scans emit `LocalLibraryChanged` only for real library changes,
except that accepted game-directory changes can force a UI snapshot for the
new path without sending a peer delta.
- Active operation mutations emit `ActiveOperationsChanged` from the mutation
path instead of riding on local library scans.
- Send `LibraryDelta` to known peers; send `LibrarySummary` on new connections.
## Local game scanning: fast and low cost
+2 -1
View File
@@ -37,7 +37,8 @@ lifetime of the process:
to keep peer liveness up to date and prunes stale entries from `PeerGameDB`.
4. **Local game monitor** (`run_local_game_monitor`) watches the configured
game directory and each game root non-recursively, gates per-ID rescans while
operations are active, and runs a 300-second fallback scan for missed events.
operations are active, emits local-library changes separately from active
operation snapshots, and runs a 300-second fallback scan for missed events.
`scan_local_library` maintains a lightweight on-disk index and produces both a
`GameDB` and protocol summaries. A game is downloaded only when its root-level
+75 -7
View File
@@ -8,10 +8,10 @@ use std::{
};
use lanspread_db::db::GameDB;
use tokio::sync::RwLock;
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use crate::{PeerEvent, Unpacker, library::LocalLibraryState, peer_db::PeerGameDB};
use crate::{PeerEvent, Unpacker, events, library::LocalLibraryState, peer_db::PeerGameDB};
/// Mutating filesystem operation currently in flight for a game root.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -124,6 +124,7 @@ pub(crate) struct OperationGuard {
id: String,
active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
active_downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
tx_notify_ui: UnboundedSender<PeerEvent>,
clears_download: bool,
armed: bool,
}
@@ -132,11 +133,13 @@ impl OperationGuard {
pub(crate) fn new(
id: String,
active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
tx_notify_ui: UnboundedSender<PeerEvent>,
) -> Self {
Self {
id,
active_operations,
active_downloads: Arc::new(RwLock::new(HashMap::new())),
tx_notify_ui,
clears_download: false,
armed: true,
}
@@ -146,11 +149,13 @@ impl OperationGuard {
id: String,
active_operations: Arc<RwLock<HashMap<String, OperationKind>>>,
active_downloads: Arc<RwLock<HashMap<String, CancellationToken>>>,
tx_notify_ui: UnboundedSender<PeerEvent>,
) -> Self {
Self {
id,
active_operations,
active_downloads,
tx_notify_ui,
clears_download: true,
armed: true,
}
@@ -173,13 +178,19 @@ impl Drop for OperationGuard {
);
if let Ok(mut guard) = self.active_operations.try_write() {
guard.remove(&id);
if guard.remove(&id).is_some() {
events::send_active_operations_snapshot(&self.tx_notify_ui, &guard);
}
} else if let Ok(handle) = tokio::runtime::Handle::try_current() {
let active_operations = self.active_operations.clone();
let tx_notify_ui = self.tx_notify_ui.clone();
handle.spawn({
let id = id.clone();
async move {
active_operations.write().await.remove(&id);
let mut active_operations = active_operations.write().await;
if active_operations.remove(&id).is_some() {
events::send_active_operations_snapshot(&tx_notify_ui, &active_operations);
}
}
});
} else {
@@ -210,10 +221,11 @@ impl Drop for OperationGuard {
mod tests {
use std::{collections::HashMap, sync::Arc, time::Duration};
use tokio::sync::RwLock;
use tokio::sync::{RwLock, mpsc};
use tokio_util::sync::CancellationToken;
use super::{OperationGuard, OperationKind};
use crate::{ActiveOperation, ActiveOperationKind, PeerEvent};
type OperationTracking = (
Arc<RwLock<HashMap<String, OperationKind>>>,
@@ -253,18 +265,34 @@ mod tests {
(active_operations, active_downloads, cancel)
}
async fn recv_active_operations(
rx: &mut mpsc::UnboundedReceiver<PeerEvent>,
) -> Vec<ActiveOperation> {
let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
.expect("active operation event should arrive")
.expect("event channel should remain open");
let PeerEvent::ActiveOperationsChanged { active_operations } = event else {
panic!("expected ActiveOperationsChanged");
};
active_operations
}
#[tokio::test]
async fn operation_guard_cleans_tracking_when_not_disarmed() {
let id = "game-complete";
let (active_operations, active_downloads, _) = tracked_download_state(id);
let (tx, mut rx) = mpsc::unbounded_channel();
drop(OperationGuard::download(
id.to_string(),
active_operations.clone(),
active_downloads.clone(),
tx,
));
wait_for_tracking_clear(id, &active_operations, &active_downloads).await;
assert!(recv_active_operations(&mut rx).await.is_empty());
}
#[tokio::test]
@@ -272,25 +300,30 @@ mod tests {
let id = "game-cancelled";
let (active_operations, active_downloads, cancel) = tracked_download_state(id);
cancel.cancel();
let (tx, mut rx) = mpsc::unbounded_channel();
drop(OperationGuard::download(
id.to_string(),
active_operations.clone(),
active_downloads.clone(),
tx,
));
wait_for_tracking_clear(id, &active_operations, &active_downloads).await;
assert!(recv_active_operations(&mut rx).await.is_empty());
}
#[tokio::test]
async fn disarmed_operation_guard_does_not_clean_tracking() {
let id = "game-finished";
let (active_operations, active_downloads, _) = tracked_download_state(id);
let (tx, _rx) = mpsc::unbounded_channel();
OperationGuard::download(
id.to_string(),
active_operations.clone(),
active_downloads.clone(),
tx,
)
.disarm();
@@ -303,13 +336,19 @@ mod tests {
let id = "game-aborted";
let (active_operations, active_downloads, _) = tracked_download_state(id);
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
let (tx, mut rx) = mpsc::unbounded_channel();
let handle = tokio::spawn({
let active_operations = active_operations.clone();
let active_downloads = active_downloads.clone();
let tx = tx.clone();
async move {
let _guard =
OperationGuard::download(id.to_string(), active_operations, active_downloads);
let _guard = OperationGuard::download(
id.to_string(),
active_operations,
active_downloads,
tx,
);
let _ = ready_tx.send(());
std::future::pending::<()>().await;
}
@@ -320,5 +359,34 @@ mod tests {
let _ = handle.await;
wait_for_tracking_clear(id, &active_operations, &active_downloads).await;
assert_eq!(
recv_active_operations(&mut rx).await,
Vec::<ActiveOperation>::new()
);
}
#[tokio::test]
async fn operation_guard_cleanup_snapshot_keeps_other_operations() {
let active_operations = Arc::new(RwLock::new(HashMap::from([
("aborted".to_string(), OperationKind::Downloading),
("other".to_string(), OperationKind::Installing),
])));
let active_downloads = Arc::new(RwLock::new(HashMap::new()));
let (tx, mut rx) = mpsc::unbounded_channel();
drop(OperationGuard::download(
"aborted".to_string(),
active_operations,
active_downloads,
tx,
));
assert_eq!(
recv_active_operations(&mut rx).await,
vec![ActiveOperation {
id: "other".to_string(),
operation: ActiveOperationKind::Installing,
}]
);
}
}
+51 -2
View File
@@ -1,10 +1,16 @@
//! UI event helpers used by peer command and service code.
use std::{net::SocketAddr, sync::Arc};
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use crate::{PeerEvent, peer_db::PeerGameDB};
use crate::{
ActiveOperation,
ActiveOperationKind,
PeerEvent,
context::OperationKind,
peer_db::PeerGameDB,
};
pub fn send(tx_notify_ui: &UnboundedSender<PeerEvent>, event: PeerEvent) {
if let Err(err) = tx_notify_ui.send(event) {
@@ -13,6 +19,49 @@ pub fn send(tx_notify_ui: &UnboundedSender<PeerEvent>, event: PeerEvent) {
}
}
pub(crate) fn active_operation_snapshot_from_map(
active_operations: &HashMap<String, OperationKind>,
) -> Vec<ActiveOperation> {
let mut snapshot = active_operations
.iter()
.map(|(id, operation)| ActiveOperation {
id: id.clone(),
operation: active_operation_kind(*operation),
})
.collect::<Vec<_>>();
snapshot.sort_by(|left, right| left.id.cmp(&right.id));
snapshot
}
pub(crate) fn send_active_operations_snapshot(
tx_notify_ui: &UnboundedSender<PeerEvent>,
active_operations: &HashMap<String, OperationKind>,
) {
send(
tx_notify_ui,
PeerEvent::ActiveOperationsChanged {
active_operations: active_operation_snapshot_from_map(active_operations),
},
);
}
pub(crate) async fn emit_active_operations(
active_operations: &Arc<RwLock<HashMap<String, OperationKind>>>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
) {
let active_operations = active_operations.read().await;
send_active_operations_snapshot(tx_notify_ui, &active_operations);
}
fn active_operation_kind(operation: OperationKind) -> ActiveOperationKind {
match operation {
OperationKind::Downloading => ActiveOperationKind::Downloading,
OperationKind::Installing => ActiveOperationKind::Installing,
OperationKind::Updating => ActiveOperationKind::Updating,
OperationKind::Uninstalling => ActiveOperationKind::Uninstalling,
}
}
pub async fn emit_peer_game_list(
peer_game_db: &Arc<RwLock<PeerGameDB>>,
tx_notify_ui: &UnboundedSender<PeerEvent>,
+305 -129
View File
@@ -12,8 +12,6 @@ use lanspread_db::db::{GameDB, GameFileDescription};
use tokio::sync::{RwLock, mpsc::UnboundedSender};
use crate::{
ActiveOperation,
ActiveOperationKind,
InstallOperation,
PeerEvent,
context::{Ctx, OperationGuard, OperationKind},
@@ -272,17 +270,9 @@ pub async fn handle_download_game_files_command(
return;
}
{
let mut in_progress = ctx.active_operations.write().await;
match in_progress.entry(id.clone()) {
Entry::Vacant(entry) => {
entry.insert(OperationKind::Downloading);
}
Entry::Occupied(_) => {
log::warn!("Operation for {id} already in progress; ignoring new download request");
return;
}
}
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
log::warn!("Operation for {id} already in progress; ignoring new download request");
return;
}
let active_operations = ctx.active_operations.clone();
@@ -298,8 +288,12 @@ pub async fn handle_download_game_files_command(
.insert(id, cancel_token.clone());
ctx.task_tracker.spawn(async move {
let download_state_guard =
OperationGuard::download(download_id.clone(), active_operations, active_downloads);
let download_state_guard = OperationGuard::download(
download_id.clone(),
active_operations,
active_downloads,
tx_notify_ui_clone.clone(),
);
let result = download_game_files(
&download_id,
@@ -317,7 +311,7 @@ pub async fn handle_download_game_files_command(
let Some(prepared) =
prepare_install_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await
else {
end_download_operation(&ctx_clone, &download_id).await;
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
download_state_guard.disarm();
return;
};
@@ -325,6 +319,7 @@ pub async fn handle_download_game_files_command(
if install_after_download {
if transition_download_to_install(
&ctx_clone,
&tx_notify_ui_clone,
&download_id,
prepared.operation_kind,
)
@@ -342,7 +337,7 @@ pub async fn handle_download_game_files_command(
clear_active_download(&ctx_clone, &download_id).await;
}
} else {
end_download_operation(&ctx_clone, &download_id).await;
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
if let Err(err) =
refresh_local_game(&ctx_clone, &tx_notify_ui_clone, &download_id).await
{
@@ -352,7 +347,7 @@ pub async fn handle_download_game_files_command(
download_state_guard.disarm();
}
Err(e) => {
end_download_operation(&ctx_clone, &download_id).await;
end_download_operation(&ctx_clone, &tx_notify_ui_clone, &download_id).await;
download_state_guard.disarm();
log::error!("Download failed for {download_id}: {e}");
}
@@ -395,7 +390,7 @@ async fn run_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEve
return;
};
if !begin_operation(ctx, &id, prepared.operation_kind).await {
if !begin_operation(ctx, tx_notify_ui, &id, prepared.operation_kind).await {
log::warn!("Operation for {id} already in progress; ignoring install command");
return;
}
@@ -459,7 +454,11 @@ async fn run_started_install_operation(
..
} = prepared;
let operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
let operation_guard = OperationGuard::new(
id.clone(),
ctx.active_operations.clone(),
tx_notify_ui.clone(),
);
let result = {
events::send(
tx_notify_ui,
@@ -478,7 +477,7 @@ async fn run_started_install_operation(
}
}
};
end_operation(ctx, &id).await;
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
match result {
@@ -510,13 +509,17 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
return;
}
if !begin_operation(ctx, &id, OperationKind::Uninstalling).await {
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::Uninstalling).await {
log::warn!("Operation for {id} already in progress; ignoring uninstall command");
return;
}
let game_root = { ctx.game_dir.read().await.join(&id) };
let operation_guard = OperationGuard::new(id.clone(), ctx.active_operations.clone());
let operation_guard = OperationGuard::new(
id.clone(),
ctx.active_operations.clone(),
tx_notify_ui.clone(),
);
let result = {
events::send(
tx_notify_ui,
@@ -525,7 +528,7 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
install::uninstall(&game_root, &id).await
};
end_operation(ctx, &id).await;
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
match result {
@@ -549,47 +552,77 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
}
}
async fn begin_operation(ctx: &Ctx, id: &str, operation: OperationKind) -> bool {
let mut active_operations = ctx.active_operations.write().await;
match active_operations.entry(id.to_string()) {
Entry::Vacant(entry) => {
entry.insert(operation);
true
async fn begin_operation(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
operation: OperationKind,
) -> bool {
let started = {
let mut active_operations = ctx.active_operations.write().await;
match active_operations.entry(id.to_string()) {
Entry::Vacant(entry) => {
entry.insert(operation);
true
}
Entry::Occupied(_) => false,
}
Entry::Occupied(_) => false,
};
if started {
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
}
started
}
async fn transition_download_to_install(ctx: &Ctx, id: &str, operation: OperationKind) -> bool {
let mut active_operations = ctx.active_operations.write().await;
match active_operations.get_mut(id) {
Some(current) if *current == OperationKind::Downloading => {
*current = operation;
true
}
Some(current) => {
log::warn!(
"Cannot transition {id} from download to install; current operation is {current:?}"
);
false
}
None => {
log::warn!("Cannot transition {id} from download to install; operation is not active");
false
async fn transition_download_to_install(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
operation: OperationKind,
) -> bool {
let transitioned = {
let mut active_operations = ctx.active_operations.write().await;
match active_operations.get_mut(id) {
Some(current) if *current == OperationKind::Downloading => {
*current = operation;
true
}
Some(current) => {
log::warn!(
"Cannot transition {id} from download to install; current operation is {current:?}"
);
false
}
None => {
log::warn!(
"Cannot transition {id} from download to install; operation is not active"
);
false
}
}
};
if transitioned {
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
}
transitioned
}
async fn end_operation(ctx: &Ctx, id: &str) {
ctx.active_operations.write().await.remove(id);
async fn end_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
if ctx.active_operations.write().await.remove(id).is_some() {
events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await;
}
}
async fn clear_active_download(ctx: &Ctx, id: &str) {
ctx.active_downloads.write().await.remove(id);
}
async fn end_download_operation(ctx: &Ctx, id: &str) {
end_operation(ctx, id).await;
async fn end_download_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: &str) {
end_operation(ctx, tx_notify_ui, id).await;
clear_active_download(ctx, id).await;
}
@@ -636,7 +669,13 @@ pub async fn handle_set_game_dir_command(
let ctx_clone = ctx.clone();
ctx.task_tracker.spawn(async move {
match load_local_library(&ctx_clone, &tx_notify_ui).await {
match load_local_library_with_policy(
&ctx_clone,
&tx_notify_ui,
LocalLibraryEventPolicy::ForceSnapshot,
)
.await
{
Ok(()) => log::info!("Local game database loaded successfully"),
Err(e) => {
log::error!("Failed to load local game database: {e}");
@@ -649,11 +688,19 @@ pub async fn handle_set_game_dir_command(
pub async fn load_local_library(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
) -> eyre::Result<()> {
load_local_library_with_policy(ctx, tx_notify_ui, LocalLibraryEventPolicy::OnChange).await
}
async fn load_local_library_with_policy(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
event_policy: LocalLibraryEventPolicy,
) -> eyre::Result<()> {
let game_dir = { ctx.game_dir.read().await.clone() };
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
scan_and_announce_local_library(ctx, tx_notify_ui, &game_dir, event_policy).await
}
async fn refresh_local_library(
@@ -661,17 +708,24 @@ async fn refresh_local_library(
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
scan_and_announce_local_library(
ctx,
tx_notify_ui,
&game_dir,
LocalLibraryEventPolicy::OnChange,
)
.await
}
async fn scan_and_announce_local_library(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
game_dir: &Path,
event_policy: LocalLibraryEventPolicy,
) -> eyre::Result<()> {
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(game_dir, &catalog).await?;
update_and_announce_games(ctx, tx_notify_ui, scan).await;
update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy).await;
Ok(())
}
@@ -683,10 +737,22 @@ async fn refresh_local_game(
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;
update_and_announce_games_with_policy(
ctx,
tx_notify_ui,
scan,
LocalLibraryEventPolicy::OnChange,
)
.await;
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum LocalLibraryEventPolicy {
OnChange,
ForceSnapshot,
}
async fn active_operation_ids(ctx: &Ctx) -> HashSet<String> {
ctx.active_operations.read().await.keys().cloned().collect()
}
@@ -722,6 +788,21 @@ pub async fn update_and_announce_games(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
scan: LocalLibraryScan,
) {
update_and_announce_games_with_policy(
ctx,
tx_notify_ui,
scan,
LocalLibraryEventPolicy::OnChange,
)
.await;
}
async fn update_and_announce_games_with_policy(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
scan: LocalLibraryScan,
event_policy: LocalLibraryEventPolicy,
) {
let LocalLibraryScan {
mut game_db,
@@ -729,11 +810,11 @@ pub async fn update_and_announce_games(
revision,
} = scan;
let active_operations = active_operation_snapshot(ctx).await;
if !active_operations.is_empty() {
let active_operation_ids = active_operation_ids(ctx).await;
if !active_operation_ids.is_empty() {
let previous = ctx.local_library.read().await.games.clone();
for id in active_operations.iter().map(|operation| &operation.id) {
if let Some(summary) = previous.get(id) {
for id in &active_operation_ids {
if let Some(summary) = previous.get(id.as_str()) {
summaries.insert(id.clone(), summary.clone());
} else {
summaries.remove(id);
@@ -754,11 +835,15 @@ pub async fn update_and_announce_games(
let all_games = game_db.all_games().into_iter().cloned().collect::<Vec<_>>();
if let Err(e) = tx_notify_ui.send(PeerEvent::LocalGamesUpdated {
games: all_games.clone(),
active_operations,
}) {
log::error!("Failed to send LocalGamesUpdated event: {e}");
if delta.is_some() || event_policy == LocalLibraryEventPolicy::ForceSnapshot {
events::send(
tx_notify_ui,
PeerEvent::LocalLibraryChanged {
games: all_games.clone(),
},
);
} else {
log::debug!("Skipping unchanged local library event");
}
let Some(delta) = delta else {
@@ -784,28 +869,6 @@ pub async fn update_and_announce_games(
}
}
async fn active_operation_snapshot(ctx: &Ctx) -> Vec<ActiveOperation> {
let active_operations = ctx.active_operations.read().await;
let mut snapshot = active_operations
.iter()
.map(|(id, operation)| ActiveOperation {
id: id.clone(),
operation: active_operation_kind(*operation),
})
.collect::<Vec<_>>();
snapshot.sort_by(|left, right| left.id.cmp(&right.id));
snapshot
}
fn active_operation_kind(operation: OperationKind) -> ActiveOperationKind {
match operation {
OperationKind::Downloading => ActiveOperationKind::Downloading,
OperationKind::Installing => ActiveOperationKind::Installing,
OperationKind::Updating => ActiveOperationKind::Updating,
OperationKind::Uninstalling => ActiveOperationKind::Uninstalling,
}
}
#[cfg(test)]
mod tests {
use std::{
@@ -821,7 +884,13 @@ mod tests {
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use super::*;
use crate::{UnpackFuture, Unpacker, test_support::TempDir};
use crate::{
ActiveOperation,
ActiveOperationKind,
UnpackFuture,
Unpacker,
test_support::TempDir,
};
struct FakeUnpacker;
@@ -860,6 +929,15 @@ mod tests {
.expect("event channel should remain open")
}
async fn assert_no_event(rx: &mut mpsc::UnboundedReceiver<PeerEvent>) {
assert!(
tokio::time::timeout(Duration::from_millis(50), rx.recv())
.await
.is_err(),
"event channel should stay quiet"
);
}
fn addr(port: u16) -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], port))
}
@@ -895,17 +973,9 @@ mod tests {
installed: bool,
downloaded: bool,
) -> lanspread_db::db::Game {
let PeerEvent::LocalGamesUpdated {
games,
active_operations,
} = event
else {
panic!("expected LocalGamesUpdated");
let PeerEvent::LocalLibraryChanged { games } = event else {
panic!("expected LocalLibraryChanged");
};
assert!(
active_operations.is_empty(),
"settled local update should not report active operations"
);
let game = games
.into_iter()
.find(|game| game.id == "game")
@@ -915,6 +985,20 @@ mod tests {
game
}
fn assert_active_update(event: PeerEvent, expected: Vec<ActiveOperation>) {
let PeerEvent::ActiveOperationsChanged { active_operations } = event else {
panic!("expected ActiveOperationsChanged");
};
assert_eq!(active_operations, expected);
}
fn active_update(id: &str, operation: ActiveOperationKind) -> Vec<ActiveOperation> {
vec![ActiveOperation {
id: id.to_string(),
operation,
}]
}
#[test]
fn update_source_selects_latest_ready_peer_manifest() {
let old_addr = addr(12_000);
@@ -1039,8 +1123,8 @@ mod tests {
}
#[tokio::test]
async fn local_games_update_reports_authoritative_active_operations() {
let temp = TempDir::new("lanspread-handler-active-snapshot");
async fn local_library_scan_freezes_active_game_state() {
let temp = TempDir::new("lanspread-handler-active-freeze");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("game.eti"), b"archive");
@@ -1058,29 +1142,38 @@ mod tests {
update_and_announce_games(&ctx, &tx, scan).await;
let PeerEvent::LocalGamesUpdated {
games,
active_operations,
} = recv_event(&mut rx).await
else {
panic!("expected LocalGamesUpdated");
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
panic!("expected LocalLibraryChanged");
};
assert!(
games.is_empty(),
"active game should keep its previous announced state"
);
assert_eq!(
active_operations,
}
#[tokio::test]
async fn begin_operation_reports_authoritative_active_operation_snapshot() {
let temp = TempDir::new("lanspread-handler-active-begin");
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();
assert!(begin_operation(&ctx, &tx, "game", OperationKind::Updating).await);
assert_active_update(
recv_event(&mut rx).await,
vec![ActiveOperation {
id: "game".to_string(),
operation: ActiveOperationKind::Installing,
}]
operation: ActiveOperationKind::Updating,
}],
);
}
#[tokio::test]
async fn unchanged_scan_still_reports_active_operation_snapshot() {
let temp = TempDir::new("lanspread-handler-active-unchanged");
async fn unchanged_settled_scan_is_not_reemitted() {
let temp = TempDir::new("lanspread-handler-settled-unchanged");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("game.eti"), b"archive");
@@ -1095,28 +1188,50 @@ mod tests {
update_and_announce_games(&ctx, &tx, scan).await;
assert_local_update(recv_event(&mut rx).await, false, true);
ctx.active_operations
.write()
.await
.insert("game".to_string(), OperationKind::Updating);
let scan = scan_local_library(temp.path(), &catalog)
.await
.expect("second scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
let PeerEvent::LocalGamesUpdated {
active_operations, ..
} = recv_event(&mut rx).await
else {
panic!("expected LocalGamesUpdated");
};
assert_eq!(
active_operations,
vec![ActiveOperation {
id: "game".to_string(),
operation: ActiveOperationKind::Updating,
}]
assert_no_event(&mut rx).await;
}
#[tokio::test]
async fn unchanged_operation_refresh_still_reports_settled_snapshot() {
let temp = TempDir::new("lanspread-handler-operation-unchanged");
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();
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(temp.path(), &catalog)
.await
.expect("initial scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
assert_local_update(recv_event(&mut rx).await, true, true);
run_install_operation(&ctx, &tx, "game".to_string()).await;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Updating),
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameBegin {
id,
operation: InstallOperation::Updating
} if id == "game"
));
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
));
assert_no_event(&mut rx).await;
}
#[tokio::test]
@@ -1131,6 +1246,10 @@ mod tests {
run_install_operation(&ctx, &tx, "game".to_string()).await;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Installing),
);
match recv_event(&mut rx).await {
PeerEvent::InstallGameBegin { id, operation } => {
assert_eq!(id, "game");
@@ -1138,6 +1257,7 @@ mod tests {
}
_ => panic!("expected InstallGameBegin"),
}
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
@@ -1174,7 +1294,8 @@ mod tests {
let tx = tx.clone();
async move {
assert!(
transition_download_to_install(&ctx, "game", prepared.operation_kind).await
transition_download_to_install(&ctx, &tx, "game", prepared.operation_kind)
.await
);
clear_active_download(&ctx, "game").await;
run_started_install_operation(&ctx, &tx, "game".to_string(), prepared).await;
@@ -1186,6 +1307,10 @@ mod tests {
drop(read_guard);
install_task.await.expect("handoff task should finish");
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Installing),
);
match recv_event(&mut rx).await {
PeerEvent::InstallGameBegin { id, operation } => {
assert_eq!(id, "game");
@@ -1193,6 +1318,7 @@ mod tests {
}
_ => panic!("expected InstallGameBegin"),
}
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
@@ -1215,6 +1341,10 @@ mod tests {
run_install_operation(&ctx, &tx, "game".to_string()).await;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Updating),
);
match recv_event(&mut rx).await {
PeerEvent::InstallGameBegin { id, operation } => {
assert_eq!(id, "game");
@@ -1222,6 +1352,7 @@ mod tests {
}
_ => panic!("expected InstallGameBegin"),
}
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
@@ -1241,6 +1372,10 @@ mod tests {
let (tx, mut rx) = mpsc::unbounded_channel();
run_install_operation(&ctx, &tx, "game".to_string()).await;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Installing),
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameBegin {
@@ -1248,6 +1383,7 @@ mod tests {
operation: InstallOperation::Installing
} if id == "game"
));
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
@@ -1259,6 +1395,10 @@ mod tests {
write_file(&root.join("game.eti"), b"new archive");
run_install_operation(&ctx, &tx, "game".to_string()).await;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Updating),
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameBegin {
@@ -1266,6 +1406,7 @@ mod tests {
operation: InstallOperation::Updating
} if id == "game"
));
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game"
@@ -1274,10 +1415,15 @@ mod tests {
assert_eq!(game.local_version.as_deref(), Some("20250101"));
run_uninstall_operation(&ctx, &tx, "game".to_string()).await;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Uninstalling),
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::UninstallGameBegin { id } if id == "game"
));
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::UninstallGameFinished { id } if id == "game"
@@ -1300,10 +1446,15 @@ mod tests {
run_uninstall_operation(&ctx, &tx, "game".to_string()).await;
assert_active_update(
recv_event(&mut rx).await,
active_update("game", ActiveOperationKind::Uninstalling),
);
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::UninstallGameBegin { id } if id == "game"
));
assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!(
recv_event(&mut rx).await,
PeerEvent::UninstallGameFinished { id } if id == "game"
@@ -1356,4 +1507,29 @@ mod tests {
assert!(!next.game_root().join(".version.ini.tmp").exists());
}
#[tokio::test]
async fn path_changing_set_game_dir_emits_equivalent_snapshot() {
let current = TempDir::new("lanspread-handler-old-equivalent-dir");
let next = TempDir::new("lanspread-handler-new-equivalent-dir");
for root in [current.game_root(), next.game_root()] {
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("game.eti"), b"archive");
}
let ctx = test_ctx(current.path().to_path_buf());
let (tx, mut rx) = mpsc::unbounded_channel();
let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(current.path(), &catalog)
.await
.expect("initial scan should succeed");
update_and_announce_games(&ctx, &tx, scan).await;
assert_local_update(recv_event(&mut rx).await, false, true);
handle_set_game_dir_command(&ctx, &tx, next.path().to_path_buf()).await;
ctx.task_tracker.close();
ctx.task_tracker.wait().await;
assert_local_update(recv_event(&mut rx).await, false, true);
}
}
+5 -4
View File
@@ -132,9 +132,10 @@ pub enum PeerEvent {
PeerLost(SocketAddr),
/// The total peer count has changed.
PeerCountUpdated(usize),
/// Local games have been scanned, with authoritative in-progress work.
LocalGamesUpdated {
games: Vec<Game>,
/// The local library contents changed after a scan.
LocalLibraryChanged { games: Vec<Game> },
/// The set of in-progress local operations changed.
ActiveOperationsChanged {
active_operations: Vec<ActiveOperation>,
},
/// A required peer runtime component failed.
@@ -168,7 +169,7 @@ pub enum InstallOperation {
Updating,
}
/// In-progress operation snapshot attached to local library updates.
/// In-progress operation snapshot sent when operation state changes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ActiveOperation {
pub id: String,
+37 -6
View File
@@ -202,12 +202,13 @@ async fn handle_active_downloads_without_peers(
return;
}
let mut changed = false;
for id in active_ids {
if peers_still_have_game(peer_game_db, &id).await {
continue;
}
active_operations.write().await.remove(&id);
changed |= active_operations.write().await.remove(&id).is_some();
let Some(cancel_token) = active_downloads.write().await.remove(&id) else {
continue;
};
@@ -215,9 +216,13 @@ async fn handle_active_downloads_without_peers(
events::send(
tx_notify_ui,
PeerEvent::DownloadGameFilesAllPeersGone { id },
PeerEvent::DownloadGameFilesAllPeersGone { id: id.clone() },
);
}
if changed {
events::emit_active_operations(active_operations, tx_notify_ui).await;
}
}
async fn peers_still_have_game(peer_game_db: &Arc<RwLock<PeerGameDB>>, game_id: &str) -> bool {
@@ -233,10 +238,16 @@ mod tests {
use tokio_util::sync::CancellationToken;
use super::handle_active_downloads_without_peers;
use crate::{PeerEvent, context::OperationKind, peer_db::PeerGameDB};
use crate::{
ActiveOperation,
ActiveOperationKind,
PeerEvent,
context::OperationKind,
peer_db::PeerGameDB,
};
#[tokio::test]
async fn all_peers_gone_cancels_download_and_emits_only_peers_gone() {
async fn all_peers_gone_cancels_download_and_emits_peers_gone_then_active_snapshot() {
let peer_game_db = Arc::new(RwLock::new(PeerGameDB::new()));
let active_operations = Arc::new(RwLock::new(HashMap::from([(
"game".to_string(),
@@ -266,9 +277,17 @@ mod tests {
event,
PeerEvent::DownloadGameFilesAllPeersGone { id } if id == "game"
));
let event = rx
.recv()
.await
.expect("active operation snapshot should be emitted");
assert!(matches!(
event,
PeerEvent::ActiveOperationsChanged { active_operations } if active_operations.is_empty()
));
assert!(
rx.try_recv().is_err(),
"peers-gone cancellation must not emit a duplicate failure event"
"peers-gone cancellation must not emit extra events"
);
}
@@ -318,9 +337,21 @@ mod tests {
}
cancelled_ids.sort();
assert_eq!(cancelled_ids, vec!["first", "second"]);
let event = rx
.recv()
.await
.expect("active operation snapshot should be emitted");
assert!(matches!(
event,
PeerEvent::ActiveOperationsChanged { active_operations }
if active_operations == vec![ActiveOperation {
id: "installing".to_string(),
operation: ActiveOperationKind::Installing,
}]
));
assert!(
rx.try_recv().is_err(),
"multiple peers-gone cancellations must not emit duplicate failure events"
"multiple peers-gone cancellations must not emit extra events"
);
}
}
@@ -391,19 +391,15 @@ mod tests {
async fn recv_local_update(
rx: &mut mpsc::UnboundedReceiver<PeerEvent>,
) -> (Vec<lanspread_db::db::Game>, Vec<crate::ActiveOperation>) {
) -> Vec<lanspread_db::db::Game> {
let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
.expect("local update event should arrive")
.expect("event channel should stay open");
let PeerEvent::LocalGamesUpdated {
games,
active_operations,
} = event
else {
panic!("expected LocalGamesUpdated");
let PeerEvent::LocalLibraryChanged { games } = event else {
panic!("expected LocalLibraryChanged");
};
(games, active_operations)
games
}
#[test]
@@ -537,7 +533,7 @@ mod tests {
ctx.task_tracker.wait().await;
let mut update_count = 0;
while let Ok(Some(PeerEvent::LocalGamesUpdated { .. })) =
while let Ok(Some(PeerEvent::LocalLibraryChanged { .. })) =
tokio::time::timeout(Duration::from_millis(50), rx.recv()).await
{
update_count += 1;
@@ -560,8 +556,7 @@ mod tests {
run_fallback_scan(&ctx, &tx).await;
let (games, active_operations) = recv_local_update(&mut rx).await;
assert!(active_operations.is_empty());
let games = recv_local_update(&mut rx).await;
let game = games
.iter()
.find(|game| game.id == "game")
@@ -585,9 +580,12 @@ mod tests {
run_fallback_scan(&ctx, &tx).await;
let (games, active_operations) = recv_local_update(&mut rx).await;
assert!(games.is_empty());
assert!(active_operations.is_empty());
assert!(
tokio::time::timeout(Duration::from_millis(50), rx.recv())
.await
.is_err(),
"non-catalog scan should not emit a local library event"
);
let library = ctx.local_library.read().await;
assert!(library.games.is_empty());
assert!(library.recent_deltas.is_empty());
@@ -13,7 +13,6 @@ use lanspread_db::db::{Availability, Game, GameDB, GameFileDescription};
use lanspread_peer::{
ActiveOperation,
ActiveOperationKind,
InstallOperation,
PeerCommand,
PeerEvent,
PeerGameDB,
@@ -747,17 +746,18 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
log::info!("PeerEvent::ListGames received");
update_game_db(games, app_handle.clone()).await;
}
PeerEvent::LocalGamesUpdated {
games: local_games,
active_operations,
} => {
log::info!("PeerEvent::LocalGamesUpdated received");
PeerEvent::LocalLibraryChanged { games: local_games } => {
log::info!("PeerEvent::LocalLibraryChanged received");
update_local_games_in_db(local_games, app_handle.clone()).await;
}
PeerEvent::ActiveOperationsChanged { active_operations } => {
log::info!("PeerEvent::ActiveOperationsChanged received");
let state = app_handle.state::<LanSpreadState>();
{
let state = app_handle.state::<LanSpreadState>();
let mut ui_active_operations = state.active_operations.write().await;
reconcile_active_operations(&mut ui_active_operations, &active_operations);
}
update_local_games_in_db(local_games, app_handle.clone()).await;
emit_games_list(app_handle).await;
}
PeerEvent::GotGameFiles {
id,
@@ -773,21 +773,9 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
&id,
"PeerEvent::NoPeersHaveGame",
);
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.remove(&id);
}
PeerEvent::DownloadGameFilesBegin { id } => {
log::info!("PeerEvent::DownloadGameFilesBegin received");
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.insert(id.clone(), UiOperationKind::Downloading);
emit_game_id_event(
app_handle,
"game-download-begin",
@@ -808,7 +796,7 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
);
}
PeerEvent::DownloadGameFilesFinished { id } => {
handle_download_finished(app_handle, id).await;
handle_download_finished(app_handle, id);
}
PeerEvent::DownloadGameFilesFailed { id } => {
log::warn!("PeerEvent::DownloadGameFilesFailed received");
@@ -818,12 +806,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
&id,
"PeerEvent::DownloadGameFilesFailed",
);
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.remove(&id);
}
PeerEvent::DownloadGameFilesAllPeersGone { id } => {
log::warn!("PeerEvent::DownloadGameFilesAllPeersGone received for {id}");
@@ -833,26 +815,10 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
&id,
"PeerEvent::DownloadGameFilesAllPeersGone",
);
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.remove(&id);
}
PeerEvent::InstallGameBegin { id, operation } => {
let operation_name: &'static str = (&operation).into();
log::info!("PeerEvent::InstallGameBegin received for {id}: {operation_name}");
let ui_operation = match operation {
InstallOperation::Installing => UiOperationKind::Installing,
InstallOperation::Updating => UiOperationKind::Updating,
};
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.insert(id.clone(), ui_operation);
emit_game_id_event(
app_handle,
"game-install-begin",
@@ -862,12 +828,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
PeerEvent::InstallGameFinished { id } => {
log::info!("PeerEvent::InstallGameFinished received for {id}");
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.remove(&id);
emit_game_id_event(
app_handle,
"game-install-finished",
@@ -877,12 +837,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
PeerEvent::InstallGameFailed { id } => {
log::warn!("PeerEvent::InstallGameFailed received for {id}");
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.remove(&id);
emit_game_id_event(
app_handle,
"game-install-failed",
@@ -892,12 +846,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
PeerEvent::UninstallGameBegin { id } => {
log::info!("PeerEvent::UninstallGameBegin received for {id}");
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.insert(id.clone(), UiOperationKind::Uninstalling);
emit_game_id_event(
app_handle,
"game-uninstall-begin",
@@ -907,12 +855,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
PeerEvent::UninstallGameFinished { id } => {
log::info!("PeerEvent::UninstallGameFinished received for {id}");
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.remove(&id);
emit_game_id_event(
app_handle,
"game-uninstall-finished",
@@ -922,12 +864,6 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
PeerEvent::UninstallGameFailed { id } => {
log::warn!("PeerEvent::UninstallGameFailed received for {id}");
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.remove(&id);
emit_game_id_event(
app_handle,
"game-uninstall-failed",
@@ -995,7 +931,7 @@ async fn handle_got_game_files(
}
}
async fn handle_download_finished(app_handle: &AppHandle, id: String) {
fn handle_download_finished(app_handle: &AppHandle, id: String) {
log::info!("PeerEvent::DownloadGameFilesFinished received");
emit_game_id_event(
app_handle,
@@ -1003,13 +939,6 @@ async fn handle_download_finished(app_handle: &AppHandle, id: String) {
&id,
"PeerEvent::DownloadGameFilesFinished",
);
app_handle
.state::<LanSpreadState>()
.active_operations
.write()
.await
.remove(&id);
}
#[cfg(test)]