use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use manabrew_relay_protocol::{SeatTransportReport, TRANSPORT_WEBRTC};
use serde_json::Value;
use tokio::sync::mpsc;
#[derive(Debug, Clone, PartialEq)]
pub enum ShellEvent {
Envelope { target: String, envelope: Value },
Signal { from: String, payload: Value },
}
#[derive(Debug, Clone, PartialEq)]
pub enum ShellCommand {
Serving { seats: Vec<String> },
Signal { to: String, payload: Value },
SeatEnvelope { from: String, envelope: Value },
}
type Reprime = Box<dyn Fn(&str) + Send + Sync>;
pub struct ShellBridge {
serving: Mutex<HashSet<String>>,
frozen: Mutex<HashSet<String>>,
owed_a_board: Mutex<HashSet<String>>,
attested: Mutex<HashSet<String>>,
emit: Arc<dyn Fn(ShellEvent) + Send + Sync>,
on_fallback: Mutex<Option<Reprime>>,
}
impl ShellBridge {
pub fn new(
emit: impl Fn(ShellEvent) + Send + Sync + 'static,
) -> (
Arc<Self>,
mpsc::UnboundedSender<ShellCommand>,
mpsc::UnboundedReceiver<ShellCommand>,
) {
let (tx, rx) = mpsc::unbounded_channel();
let bridge = Arc::new(Self {
serving: Mutex::new(HashSet::new()),
frozen: Mutex::new(HashSet::new()),
owed_a_board: Mutex::new(HashSet::new()),
attested: Mutex::new(HashSet::new()),
emit: Arc::new(emit),
on_fallback: Mutex::new(None),
});
(bridge, tx, rx)
}
pub fn set_roster(&self, seats: impl IntoIterator<Item = String>) {
if let Ok(mut attested) = self.attested.lock() {
*attested = seats.into_iter().collect();
}
}
pub fn set_on_fallback(&self, reprime: impl Fn(&str) + Send + Sync + 'static) {
if let Ok(mut slot) = self.on_fallback.lock() {
*slot = Some(Box::new(reprime));
}
}
pub fn set_serving(&self, seats: Vec<String>) {
let incoming: HashSet<String> = seats.into_iter().collect();
let lost: Vec<String> = {
let Ok(mut frozen) = self.frozen.lock() else {
return;
};
let lost: Vec<String> = frozen.difference(&incoming).cloned().collect();
for seat in &lost {
frozen.remove(seat);
}
lost
};
if let Ok(mut serving) = self.serving.lock() {
*serving = incoming;
}
for seat in lost {
let newly_owed = self
.owed_a_board
.lock()
.map(|mut set| set.insert(seat.clone()))
.unwrap_or(false);
if !newly_owed {
continue;
}
if let Ok(slot) = self.on_fallback.lock() {
if let Some(reprime) = slot.as_ref() {
reprime(&seat);
}
}
}
}
pub fn freeze_for_game(&self, seats: &[String], taken: &[String]) -> Vec<String> {
let Ok(serving) = self.serving.lock() else {
return Vec::new();
};
let Ok(attested) = self.attested.lock() else {
return Vec::new();
};
let mine: HashSet<String> = seats
.iter()
.filter(|seat| {
serving.contains(*seat) && attested.contains(*seat) && !taken.contains(*seat)
})
.cloned()
.collect();
let mut listed: Vec<String> = mine.iter().cloned().collect();
listed.sort();
if let Ok(mut frozen) = self.frozen.lock() {
*frozen = mine;
}
if let Ok(mut owed) = self.owed_a_board.lock() {
owed.clear();
}
listed
}
pub fn clear_game(&self) {
if let Ok(mut frozen) = self.frozen.lock() {
frozen.clear();
}
if let Ok(mut owed) = self.owed_a_board.lock() {
owed.clear();
}
}
pub fn note_relay_message(&self, username: &str) {
if let Ok(mut owed) = self.owed_a_board.lock() {
owed.remove(username);
}
}
pub fn try_send(&self, target: &str, envelope: &Value) -> bool {
let claimed = self
.frozen
.lock()
.map(|frozen| frozen.contains(target))
.unwrap_or(false);
if !claimed {
return false;
}
if envelope.get("kind").and_then(Value::as_str) == Some("prompt") {
tracing::info!(target, "shell bridge: handed a prompt to the webview");
}
(self.emit)(ShellEvent::Envelope {
target: target.to_string(),
envelope: envelope.clone(),
});
true
}
pub fn forward_signal(&self, from: &str, payload: Value) {
(self.emit)(ShellEvent::Signal {
from: from.to_string(),
payload,
});
}
pub fn transport_report(&self, seats: &[String]) -> Vec<SeatTransportReport> {
seats
.iter()
.map(|username| SeatTransportReport {
username: username.clone(),
transport: TRANSPORT_WEBRTC.to_string(),
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::sync::mpsc as std_mpsc;
fn bridge() -> (Arc<ShellBridge>, std_mpsc::Receiver<ShellEvent>) {
let (tx, rx) = std_mpsc::channel();
let (bridge, _cmd_tx, _cmd_rx) = ShellBridge::new(move |event| {
let _ = tx.send(event);
});
(bridge, rx)
}
#[test]
fn nothing_leaves_this_plane_before_the_game_freezes_it() {
let (bridge, events) = bridge();
bridge.set_roster(["bob".to_string()]);
bridge.set_serving(vec!["bob".into()]);
assert!(!bridge.try_send("bob", &json!({"kind": "prompt"})));
assert!(events.try_recv().is_err());
bridge.freeze_for_game(&["bob".into()], &[]);
assert!(bridge.try_send("bob", &json!({"kind": "prompt"})));
}
#[test]
fn a_seat_the_direct_plane_took_is_not_claimed_twice() {
let (bridge, _events) = bridge();
bridge.set_roster(["bob".to_string(), "carol".to_string()]);
bridge.set_serving(vec!["bob".into(), "carol".into()]);
let mine = bridge.freeze_for_game(&["bob".into(), "carol".into()], &["bob".into()]);
assert_eq!(mine, vec!["carol".to_string()]);
assert!(!bridge.try_send("bob", &json!({})));
assert!(bridge.try_send("carol", &json!({})));
}
#[test]
fn a_seat_the_roster_does_not_name_stays_on_the_relay() {
let (bridge, _events) = bridge();
bridge.set_serving(vec!["bob".into()]);
assert!(
bridge.freeze_for_game(&["bob".into()], &[]).is_empty(),
"no roster, no plane"
);
bridge.set_roster(["bob".to_string()]);
assert_eq!(
bridge.freeze_for_game(&["bob".into()], &[]),
vec!["bob".to_string()]
);
}
#[test]
fn a_seat_that_loses_its_channel_falls_back_owing_a_board() {
let (bridge, _events) = bridge();
let repriced: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let seen = repriced.clone();
bridge.set_on_fallback(move |seat| seen.lock().unwrap().push(seat.to_string()));
bridge.set_roster(["bob".to_string()]);
bridge.set_serving(vec!["bob".into()]);
bridge.freeze_for_game(&["bob".into()], &[]);
assert!(bridge.try_send("bob", &json!({})));
bridge.set_serving(vec![]);
assert_eq!(repriced.lock().unwrap().as_slice(), ["bob"]);
assert!(!bridge.try_send("bob", &json!({})));
}
#[test]
fn the_debt_is_owed_once_and_settled_by_the_seat_answering_on_the_relay() {
let (bridge, _events) = bridge();
let count = Arc::new(Mutex::new(0usize));
let seen = count.clone();
bridge.set_on_fallback(move |_| *seen.lock().unwrap() += 1);
bridge.set_roster(["bob".to_string()]);
bridge.set_serving(vec!["bob".into()]);
bridge.freeze_for_game(&["bob".into()], &[]);
bridge.set_serving(vec![]);
bridge.set_serving(vec![]);
assert_eq!(*count.lock().unwrap(), 1);
bridge.note_relay_message("bob");
assert!(!bridge.try_send("bob", &json!({})));
bridge.set_serving(vec!["bob".into()]);
assert!(!bridge.try_send("bob", &json!({})));
}
#[test]
fn a_seat_that_was_never_frozen_here_owes_nothing_when_it_stops_being_served() {
let (bridge, _events) = bridge();
let count = Arc::new(Mutex::new(0usize));
let seen = count.clone();
bridge.set_on_fallback(move |_| *seen.lock().unwrap() += 1);
bridge.set_serving(vec!["bob".into()]);
bridge.set_serving(vec![]);
assert_eq!(*count.lock().unwrap(), 0);
}
#[test]
fn the_capture_is_told_which_seats_it_stopped_seeing() {
let (bridge, _events) = bridge();
let report = bridge.transport_report(&["bob".into()]);
assert_eq!(report.len(), 1);
assert_eq!(report[0].username, "bob");
assert_eq!(report[0].transport, TRANSPORT_WEBRTC);
}
#[test]
fn signalling_for_this_host_reaches_the_webview_that_holds_the_connections() {
let (bridge, events) = bridge();
bridge.forward_signal("bob", json!({"sdp": {"type": "offer"}}));
assert_eq!(
events.try_recv().unwrap(),
ShellEvent::Signal {
from: "bob".into(),
payload: json!({"sdp": {"type": "offer"}}),
}
);
}
}