Skip to main content

self_hosted_node/
shell_bridge.rs

1//! Webview half of a desktop host's data plane, for seats reached over WebRTC.
2//! See docs/TRANSPORT.md.
3
4use std::collections::HashSet;
5use std::sync::{Arc, Mutex};
6
7use manabrew_relay_protocol::{SeatTransportReport, TRANSPORT_WEBRTC};
8use serde_json::Value;
9use tokio::sync::mpsc;
10
11/// Node to webview.
12#[derive(Debug, Clone, PartialEq)]
13pub enum ShellEvent {
14    /// An engine envelope for a seat the webview serves.
15    Envelope { target: String, envelope: Value },
16    /// Signalling for this host, forwarded off the relay socket.
17    Signal { from: String, payload: Value },
18}
19
20/// Webview to node.
21#[derive(Debug, Clone, PartialEq)]
22pub enum ShellCommand {
23    /// Seats the webview has an open channel to. Replaces the whole set.
24    Serving { seats: Vec<String> },
25    /// Signalling to send under this host's relay identity.
26    Signal { to: String, payload: Value },
27    /// A seat's envelope, arrived over the webview's channel.
28    SeatEnvelope { from: String, envelope: Value },
29}
30
31type Reprime = Box<dyn Fn(&str) + Send + Sync>;
32
33/// Carries the seats the webview reaches that the game froze on this plane.
34pub struct ShellBridge {
35    serving: Mutex<HashSet<String>>,
36    /// Frozen at `GameStarted`; a seat leaves and never comes back.
37    frozen: Mutex<HashSet<String>>,
38    /// Seats that left this plane mid-game and need a full board first.
39    owed_a_board: Mutex<HashSet<String>>,
40    /// Relay-attested roster; the freeze carries only seats named here.
41    attested: Mutex<HashSet<String>>,
42    emit: Arc<dyn Fn(ShellEvent) + Send + Sync>,
43    on_fallback: Mutex<Option<Reprime>>,
44}
45
46impl ShellBridge {
47    pub fn new(
48        emit: impl Fn(ShellEvent) + Send + Sync + 'static,
49    ) -> (
50        Arc<Self>,
51        mpsc::UnboundedSender<ShellCommand>,
52        mpsc::UnboundedReceiver<ShellCommand>,
53    ) {
54        let (tx, rx) = mpsc::unbounded_channel();
55        let bridge = Arc::new(Self {
56            serving: Mutex::new(HashSet::new()),
57            frozen: Mutex::new(HashSet::new()),
58            owed_a_board: Mutex::new(HashSet::new()),
59            attested: Mutex::new(HashSet::new()),
60            emit: Arc::new(emit),
61            on_fallback: Mutex::new(None),
62        });
63        (bridge, tx, rx)
64    }
65
66    pub fn set_roster(&self, seats: impl IntoIterator<Item = String>) {
67        if let Ok(mut attested) = self.attested.lock() {
68            *attested = seats.into_iter().collect();
69        }
70    }
71
72    pub fn set_on_fallback(&self, reprime: impl Fn(&str) + Send + Sync + 'static) {
73        if let Ok(mut slot) = self.on_fallback.lock() {
74            *slot = Some(Box::new(reprime));
75        }
76    }
77
78    /// Replaces the served set; a dropped frozen seat falls back owing a board.
79    pub fn set_serving(&self, seats: Vec<String>) {
80        let incoming: HashSet<String> = seats.into_iter().collect();
81        let lost: Vec<String> = {
82            let Ok(mut frozen) = self.frozen.lock() else {
83                return;
84            };
85            let lost: Vec<String> = frozen.difference(&incoming).cloned().collect();
86            for seat in &lost {
87                frozen.remove(seat);
88            }
89            lost
90        };
91        if let Ok(mut serving) = self.serving.lock() {
92            *serving = incoming;
93        }
94        for seat in lost {
95            let newly_owed = self
96                .owed_a_board
97                .lock()
98                .map(|mut set| set.insert(seat.clone()))
99                .unwrap_or(false);
100            if !newly_owed {
101                continue;
102            }
103            if let Ok(slot) = self.on_fallback.lock() {
104                if let Some(reprime) = slot.as_ref() {
105                    reprime(&seat);
106                }
107            }
108        }
109    }
110
111    /// Called on `GameStarted`; returns the seats this bridge carries.
112    pub fn freeze_for_game(&self, seats: &[String], taken: &[String]) -> Vec<String> {
113        let Ok(serving) = self.serving.lock() else {
114            return Vec::new();
115        };
116        let Ok(attested) = self.attested.lock() else {
117            return Vec::new();
118        };
119        let mine: HashSet<String> = seats
120            .iter()
121            .filter(|seat| {
122                serving.contains(*seat) && attested.contains(*seat) && !taken.contains(*seat)
123            })
124            .cloned()
125            .collect();
126        let mut listed: Vec<String> = mine.iter().cloned().collect();
127        listed.sort();
128        if let Ok(mut frozen) = self.frozen.lock() {
129            *frozen = mine;
130        }
131        if let Ok(mut owed) = self.owed_a_board.lock() {
132            owed.clear();
133        }
134        listed
135    }
136
137    pub fn clear_game(&self) {
138        if let Ok(mut frozen) = self.frozen.lock() {
139            frozen.clear();
140        }
141        if let Ok(mut owed) = self.owed_a_board.lock() {
142            owed.clear();
143        }
144    }
145
146    /// A relay envelope from this seat settles its owed board.
147    pub fn note_relay_message(&self, username: &str) {
148        if let Ok(mut owed) = self.owed_a_board.lock() {
149            owed.remove(username);
150        }
151    }
152
153    /// Hands one envelope to the webview; false when the seat is not frozen here.
154    pub fn try_send(&self, target: &str, envelope: &Value) -> bool {
155        let claimed = self
156            .frozen
157            .lock()
158            .map(|frozen| frozen.contains(target))
159            .unwrap_or(false);
160        if !claimed {
161            return false;
162        }
163        if envelope.get("kind").and_then(Value::as_str) == Some("prompt") {
164            tracing::info!(target, "shell bridge: handed a prompt to the webview");
165        }
166        (self.emit)(ShellEvent::Envelope {
167            target: target.to_string(),
168            envelope: envelope.clone(),
169        });
170        true
171    }
172
173    pub fn forward_signal(&self, from: &str, payload: Value) {
174        (self.emit)(ShellEvent::Signal {
175            from: from.to_string(),
176            payload,
177        });
178    }
179
180    pub fn transport_report(&self, seats: &[String]) -> Vec<SeatTransportReport> {
181        seats
182            .iter()
183            .map(|username| SeatTransportReport {
184                username: username.clone(),
185                transport: TRANSPORT_WEBRTC.to_string(),
186            })
187            .collect()
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use serde_json::json;
195    use std::sync::mpsc as std_mpsc;
196
197    fn bridge() -> (Arc<ShellBridge>, std_mpsc::Receiver<ShellEvent>) {
198        let (tx, rx) = std_mpsc::channel();
199        let (bridge, _cmd_tx, _cmd_rx) = ShellBridge::new(move |event| {
200            let _ = tx.send(event);
201        });
202        (bridge, rx)
203    }
204
205    #[test]
206    fn nothing_leaves_this_plane_before_the_game_freezes_it() {
207        let (bridge, events) = bridge();
208        bridge.set_roster(["bob".to_string()]);
209        bridge.set_serving(vec!["bob".into()]);
210        assert!(!bridge.try_send("bob", &json!({"kind": "prompt"})));
211        assert!(events.try_recv().is_err());
212
213        bridge.freeze_for_game(&["bob".into()], &[]);
214        assert!(bridge.try_send("bob", &json!({"kind": "prompt"})));
215    }
216
217    #[test]
218    fn a_seat_the_direct_plane_took_is_not_claimed_twice() {
219        let (bridge, _events) = bridge();
220        bridge.set_roster(["bob".to_string(), "carol".to_string()]);
221        bridge.set_serving(vec!["bob".into(), "carol".into()]);
222        let mine = bridge.freeze_for_game(&["bob".into(), "carol".into()], &["bob".into()]);
223        assert_eq!(mine, vec!["carol".to_string()]);
224        assert!(!bridge.try_send("bob", &json!({})));
225        assert!(bridge.try_send("carol", &json!({})));
226    }
227
228    #[test]
229    fn a_seat_the_roster_does_not_name_stays_on_the_relay() {
230        let (bridge, _events) = bridge();
231        bridge.set_serving(vec!["bob".into()]);
232        assert!(
233            bridge.freeze_for_game(&["bob".into()], &[]).is_empty(),
234            "no roster, no plane"
235        );
236        bridge.set_roster(["bob".to_string()]);
237        assert_eq!(
238            bridge.freeze_for_game(&["bob".into()], &[]),
239            vec!["bob".to_string()]
240        );
241    }
242
243    #[test]
244    fn a_seat_that_loses_its_channel_falls_back_owing_a_board() {
245        let (bridge, _events) = bridge();
246        let repriced: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
247        let seen = repriced.clone();
248        bridge.set_on_fallback(move |seat| seen.lock().unwrap().push(seat.to_string()));
249
250        bridge.set_roster(["bob".to_string()]);
251        bridge.set_serving(vec!["bob".into()]);
252        bridge.freeze_for_game(&["bob".into()], &[]);
253        assert!(bridge.try_send("bob", &json!({})));
254
255        bridge.set_serving(vec![]);
256        assert_eq!(repriced.lock().unwrap().as_slice(), ["bob"]);
257        assert!(!bridge.try_send("bob", &json!({})));
258    }
259
260    #[test]
261    fn the_debt_is_owed_once_and_settled_by_the_seat_answering_on_the_relay() {
262        let (bridge, _events) = bridge();
263        let count = Arc::new(Mutex::new(0usize));
264        let seen = count.clone();
265        bridge.set_on_fallback(move |_| *seen.lock().unwrap() += 1);
266
267        bridge.set_roster(["bob".to_string()]);
268        bridge.set_serving(vec!["bob".into()]);
269        bridge.freeze_for_game(&["bob".into()], &[]);
270        bridge.set_serving(vec![]);
271        bridge.set_serving(vec![]);
272        assert_eq!(*count.lock().unwrap(), 1);
273
274        bridge.note_relay_message("bob");
275        assert!(!bridge.try_send("bob", &json!({})));
276
277        bridge.set_serving(vec!["bob".into()]);
278        assert!(!bridge.try_send("bob", &json!({})));
279    }
280
281    #[test]
282    fn a_seat_that_was_never_frozen_here_owes_nothing_when_it_stops_being_served() {
283        let (bridge, _events) = bridge();
284        let count = Arc::new(Mutex::new(0usize));
285        let seen = count.clone();
286        bridge.set_on_fallback(move |_| *seen.lock().unwrap() += 1);
287
288        bridge.set_serving(vec!["bob".into()]);
289        bridge.set_serving(vec![]);
290        assert_eq!(*count.lock().unwrap(), 0);
291    }
292
293    #[test]
294    fn the_capture_is_told_which_seats_it_stopped_seeing() {
295        let (bridge, _events) = bridge();
296        let report = bridge.transport_report(&["bob".into()]);
297        assert_eq!(report.len(), 1);
298        assert_eq!(report[0].username, "bob");
299        assert_eq!(report[0].transport, TRANSPORT_WEBRTC);
300    }
301
302    #[test]
303    fn signalling_for_this_host_reaches_the_webview_that_holds_the_connections() {
304        let (bridge, events) = bridge();
305        bridge.forward_signal("bob", json!({"sdp": {"type": "offer"}}));
306        assert_eq!(
307            events.try_recv().unwrap(),
308            ShellEvent::Signal {
309                from: "bob".into(),
310                payload: json!({"sdp": {"type": "offer"}}),
311            }
312        );
313    }
314}