bevy_iroh 0.4.3

Share Bevy entities between peers over iroh: replication, rooms, presence and typed messages, with optional voice and video
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
//! Rooms and who is in them.
//!
//! A room is an entity with no spatial meaning: a gossip topic, the peers heard on it, and the
//! entities shared into it. Membership is inferred from signed traffic, not from gossip
//! neighbour events, because a gossip mesh does not make every member your neighbour: every
//! member says `Hello` on join and every few seconds after, and a peer that has been silent for
//! three heartbeats is gone.

use bevy::{platform::collections::HashMap, prelude::*, time::Real};
use iroh::EndpointId;
use iroh_gossip::proto::TopicId;

use crate::{
    IrohSet,
    net::{
        self, Kind, Payload, RoomTicket,
        proto::{Goodbye, Hello},
    },
    node::{FromNet, Inbox, Iroh, NetStats, Receive, Timing, ToNet},
};

/// A room: a topic and its members. Spawn one with [`Room::host`] or [`Room::join`].
#[derive(Component, Debug, Clone)]
pub struct Room {
    pub name: String,
}

impl Room {
    /// Open a fresh room. A [`Ticket`] appears on the entity once there is one to share.
    pub fn host(name: impl Into<String>) -> (Room, RoomRequest, RoomStatus) {
        (
            Room { name: name.into() },
            RoomRequest::Host,
            RoomStatus::Connecting,
        )
    }

    /// Join the room a ticket names.
    pub fn join(ticket: RoomTicket) -> (Room, RoomRequest, RoomStatus) {
        (
            Room {
                name: ticket.name.clone(),
            },
            RoomRequest::Join(ticket),
            RoomStatus::Connecting,
        )
    }
}

/// What the room entity asked for. Consumed on the first frame the node sees it.
#[derive(Component, Debug, Clone)]
pub enum RoomRequest {
    Host,
    Join(RoomTicket),
}

#[derive(Component, Debug, Clone, PartialEq, Eq)]
pub enum RoomStatus {
    Connecting,
    Joined,
    Failed(String),
}

/// The topic, once the node has subscribed. Its presence means "in the room".
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Deref)]
pub struct RoomTopic(pub TopicId);

/// What to hand someone else so they can join. Inserted once mintable: for a host, after a
/// relay is reached or five seconds pass; for a joiner, immediately.
#[derive(Component, Debug, Clone, Deref)]
pub struct Ticket(pub RoomTicket);

/// A remote member of a room. One entity per peer per room, related to it by [`MemberOf`].
#[derive(Component, Debug, Clone)]
pub struct Peer {
    pub id: EndpointId,
    /// `None` until their first `Hello` carries one.
    pub name: Option<String>,
    /// Seconds on `Time<Real>` when they were last heard.
    pub last_heard: f64,
}

impl Peer {
    /// The name, or a short key: unambiguous even when not friendly.
    pub fn label(&self) -> String {
        self.name
            .clone()
            .unwrap_or_else(|| self.id.fmt_short().to_string())
    }
}

/// Which room a [`Peer`] entity belongs to.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
#[relationship(relationship_target = Members)]
pub struct MemberOf(pub Entity);

/// The [`Peer`] entities of a room. Despawning the room despawns them.
#[derive(Component, Debug, Default)]
#[relationship_target(relationship = MemberOf, linked_spawn)]
pub struct Members(Vec<Entity>);

impl Members {
    pub fn iter(&self) -> impl Iterator<Item = Entity> + '_ {
        self.0.iter().copied()
    }

    pub fn len(&self) -> usize {
        self.0.len()
    }

    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }
}

/// A peer was heard for the first time in a room.
#[derive(Message, Debug, Clone)]
pub struct PeerJoined {
    pub room: Entity,
    pub peer: Entity,
    pub id: EndpointId,
}

/// A peer said goodbye or fell silent. The entity is despawned after this is written.
#[derive(Message, Debug, Clone)]
pub struct PeerLeft {
    pub room: Entity,
    pub peer: Entity,
    pub id: EndpointId,
}

/// Topic to room entity, and back.
#[derive(Resource, Default)]
pub struct Rooms {
    by_topic: HashMap<TopicId, Entity>,
    peers: HashMap<(Entity, EndpointId), Entity>,
}

impl Rooms {
    pub fn by_topic(&self, topic: &TopicId) -> Option<Entity> {
        self.by_topic.get(topic).copied()
    }

    /// The [`Peer`] entity for `id` in `room`.
    pub fn peer(&self, room: Entity, id: EndpointId) -> Option<Entity> {
        self.peers.get(&(room, id)).copied()
    }

    /// Every room this node has joined.
    pub fn joined(&self) -> impl Iterator<Item = (TopicId, Entity)> + '_ {
        self.by_topic.iter().map(|(t, e)| (*t, *e))
    }
}

/// Per-room presence clock.
#[derive(Component)]
struct Presence {
    last_hello: f64,
    /// Say hello on the next send, ahead of the clock: a neighbour just appeared.
    nudge: bool,
}

impl Default for Presence {
    fn default() -> Self {
        // The first hello goes out on the first frame in the room, not after one interval.
        Self {
            last_hello: f64::NEG_INFINITY,
            nudge: false,
        }
    }
}

pub(crate) struct RoomPlugin;

impl Plugin for RoomPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<Rooms>()
            .add_message::<PeerJoined>()
            .add_message::<PeerLeft>()
            .add_systems(
                PreUpdate,
                (
                    drain.in_set(Receive::Drain),
                    (presence_in, reap).chain().in_set(Receive::Rooms),
                ),
            )
            .add_systems(
                PostUpdate,
                (request, presence_out, measure).in_set(IrohSet::Send),
            )
            .add_observer(leave_on_despawn);
    }
}

/// Send the node what new room entities asked for.
fn request(
    mut commands: Commands,
    iroh: Option<Res<Iroh>>,
    rooms: Query<(Entity, &Room, &RoomRequest)>,
) {
    for (entity, room, req) in &rooms {
        commands.entity(entity).remove::<RoomRequest>();
        let Some(iroh) = iroh.as_deref() else {
            commands
                .entity(entity)
                .insert(RoomStatus::Failed("networking is off".into()));
            continue;
        };
        let room_id: u64 = entity.to_bits();
        match req {
            RoomRequest::Host => iroh.send(ToNet::Host {
                room: room_id,
                name: room.name.clone(),
            }),
            RoomRequest::Join(ticket) => {
                commands.entity(entity).insert(Ticket(ticket.clone()));
                iroh.send(ToNet::Join {
                    room: room_id,
                    ticket: ticket.clone(),
                });
            }
        }
    }
}

/// Everything the node said since last frame, sorted into room state and the [`Inbox`].
fn drain(
    mut commands: Commands,
    iroh: Option<ResMut<Iroh>>,
    mut rooms: ResMut<Rooms>,
    mut inbox: ResMut<Inbox>,
    mut stats: ResMut<NetStats>,
    mut presence: Query<&mut Presence>,
) {
    let Some(mut iroh) = iroh else { return };
    for message in iroh.drain() {
        match message {
            FromNet::Joined {
                room,
                topic,
                ticket,
                relayed,
            } => {
                let Some(entity) = Entity::try_from_bits(room) else {
                    continue;
                };
                let Ok(mut e) = commands.get_entity(entity) else {
                    // The room was despawned while joining: leave again.
                    iroh.send(ToNet::Leave { topic });
                    continue;
                };
                if !relayed {
                    warn!(
                        "bevy_iroh: no relay reached; this ticket only works on the local network"
                    );
                }
                e.insert((
                    RoomTopic(topic),
                    RoomStatus::Joined,
                    Ticket(ticket),
                    Presence::default(),
                ));
                rooms.by_topic.insert(topic, entity);
            }
            FromNet::Failed {
                room: Some(room),
                why,
            } => {
                if let Some(entity) = Entity::try_from_bits(room)
                    && let Ok(mut e) = commands.get_entity(entity)
                {
                    warn!("bevy_iroh: room failed: {why}");
                    e.insert(RoomStatus::Failed(why));
                }
            }
            FromNet::Failed { room: None, why } => error!("bevy_iroh: {why}"),
            FromNet::Stats(sample) => stats.0 = Some(sample),
            FromNet::Event(net::Event::Frame(frame)) => inbox.frames.push(frame),
            FromNet::Event(net::Event::Neighbor {
                topic, up: true, ..
            }) => {
                // They joined after our last hello and would otherwise wait for the clock.
                if let Some(room) = rooms.by_topic(&topic)
                    && let Ok(mut p) = presence.get_mut(room)
                {
                    p.nudge = true;
                }
            }
            FromNet::Event(net::Event::Neighbor { .. }) => {}
            FromNet::Event(net::Event::Notice { text, .. }) => debug!("bevy_iroh: {text}"),
        }
    }
}

/// Hello and goodbye frames become [`Peer`] entities and their absence.
fn presence_in(
    mut commands: Commands,
    mut inbox: ResMut<Inbox>,
    mut rooms: ResMut<Rooms>,
    time: Res<Time<Real>>,
    mut peers: Query<&mut Peer>,
    mut joined: MessageWriter<PeerJoined>,
    mut left: MessageWriter<PeerLeft>,
) {
    let now = time.elapsed_secs_f64();
    for frame in inbox.take(Kind::HELLO) {
        let Some(room) = rooms.by_topic(&frame.topic) else {
            continue;
        };
        let name = Hello::from_body(&frame.body)
            .ok()
            .map(|h| h.name)
            .filter(|n| !n.is_empty());
        match rooms.peer(room, frame.from) {
            Some(peer) => {
                if let Ok(mut p) = peers.get_mut(peer) {
                    p.last_heard = now;
                    if name.is_some() {
                        p.name = name;
                    }
                }
            }
            None => {
                let peer = commands
                    .spawn((
                        Peer {
                            id: frame.from,
                            name,
                            last_heard: now,
                        },
                        MemberOf(room),
                    ))
                    .id();
                rooms.peers.insert((room, frame.from), peer);
                joined.write(PeerJoined {
                    room,
                    peer,
                    id: frame.from,
                });
            }
        }
    }
    for frame in inbox.take(Kind::GOODBYE) {
        let Some(room) = rooms.by_topic(&frame.topic) else {
            continue;
        };
        if let Some(peer) = rooms.peers.remove(&(room, frame.from)) {
            left.write(PeerLeft {
                room,
                peer,
                id: frame.from,
            });
            commands.entity(peer).despawn();
        }
    }
    // Any signed frame is proof of life, whatever it says.
    for frame in &inbox.frames {
        if let Some(room) = rooms.by_topic(&frame.topic)
            && let Some(peer) = rooms.peer(room, frame.from)
            && let Ok(mut p) = peers.get_mut(peer)
        {
            p.last_heard = now;
        }
    }
}

/// Three missed heartbeats and a peer is gone. Not two: one missed message is ordinary on a
/// gossip mesh.
fn reap(
    mut commands: Commands,
    mut rooms: ResMut<Rooms>,
    timing: Res<Timing>,
    time: Res<Time<Real>>,
    peers: Query<(Entity, &Peer, &MemberOf)>,
    mut left: MessageWriter<PeerLeft>,
) {
    let silence = timing.heartbeat.as_secs_f64() * 3.0;
    let now = time.elapsed_secs_f64();
    for (entity, peer, member_of) in &peers {
        if now - peer.last_heard > silence {
            rooms.peers.remove(&(member_of.0, peer.id));
            left.write(PeerLeft {
                room: member_of.0,
                peer: entity,
                id: peer.id,
            });
            commands.entity(entity).despawn();
        }
    }
}

/// Say hello ahead of the clock when a neighbour appears, and on the first frame in a room.
/// The clock itself runs on the network thread (see `node::run`), so a page whose tab is
/// hidden, and so runs no frames, keeps saying hello.
fn presence_out(
    iroh: Option<Res<Iroh>>,
    time: Res<Time<Real>>,
    mut rooms: Query<(&RoomTopic, &mut Presence)>,
) {
    let Some(iroh) = iroh else { return };
    let now = time.elapsed_secs_f64();
    for (topic, mut presence) in &mut rooms {
        if presence.nudge || presence.last_hello < 0.0 {
            presence.nudge = false;
            presence.last_hello = now;
            let hello = Hello {
                name: iroh.display_name().to_string(),
            };
            if let Ok(body) = hello.to_body() {
                iroh.send(ToNet::Broadcast {
                    topic: topic.0,
                    kind: Kind::HELLO,
                    body,
                });
            }
        }
    }
}

/// Despawning a room leaves its topic, so nothing in it ghosts on peers.
fn leave_on_despawn(
    remove: On<Remove, RoomTopic>,
    iroh: Option<Res<Iroh>>,
    mut rooms: ResMut<Rooms>,
    topics: Query<&RoomTopic>,
) {
    let Ok(topic) = topics.get(remove.entity) else {
        return;
    };
    rooms.by_topic.remove(&topic.0);
    rooms.peers.retain(|(room, _), _| *room != remove.entity);
    if let Some(iroh) = iroh {
        if let Ok(body) = Goodbye.to_body() {
            iroh.send(ToNet::Broadcast {
                topic: topic.0,
                kind: Kind::GOODBYE,
                body,
            });
        }
        iroh.send(ToNet::Leave { topic: topic.0 });
    }
}

/// Tell the node who to measure round trips to, whenever the member list changes.
fn measure(
    iroh: Option<Res<Iroh>>,
    changed: Query<(), (With<Peer>, Or<(Added<Peer>, Changed<Peer>)>)>,
    mut removed: RemovedComponents<Peer>,
    peers: Query<&Peer>,
) {
    let Some(iroh) = iroh else { return };
    let removed_any = removed.read().next().is_some();
    if changed.is_empty() && !removed_any {
        return;
    }
    let mut list: Vec<(EndpointId, Option<String>)> = Vec::new();
    for peer in &peers {
        match list.iter_mut().find(|(id, _)| *id == peer.id) {
            Some((_, name)) => {
                if name.is_none() {
                    *name = peer.name.clone();
                }
            }
            None => list.push((peer.id, peer.name.clone())),
        }
    }
    iroh.send(ToNet::Peers(list));
}