use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::Arc};
use bevy::prelude::Entity;
use iroh::EndpointId;
use wasm_bindgen::{JsCast, JsValue, closure::Closure};
use wasm_bindgen_futures::JsFuture;
use web_sys::{
MessageEvent, RtcConfiguration, RtcDataChannel, RtcDataChannelEvent, RtcDataChannelInit,
RtcDataChannelState, RtcDataChannelType, RtcIceCandidateInit, RtcIceConnectionState,
RtcIceServer, RtcPeerConnection, RtcPeerConnectionIceEvent, RtcSdpType,
RtcSessionDescriptionInit,
};
use super::{
AUDIO_CHANNEL, FRAMES_CHANNEL, Outbox, RtcSettings, RtcSignal, SignalKind, VIDEO_BACKLOG,
VIDEO_CHANNEL,
};
use crate::{
media::{
transport::{MediaHub, Outbound, RtcLink},
web::describe,
},
node::Iroh,
};
struct WebPeer {
pc: RtcPeerConnection,
audio: Option<RtcDataChannel>,
video: Option<RtcDataChannel>,
frames: Option<RtcDataChannel>,
link: Arc<RtcLink>,
announced: bool,
hub: Arc<MediaHub>,
_closures: Vec<Closure<dyn FnMut(JsValue)>>,
}
thread_local! {
static PEERS: RefCell<HashMap<EndpointId, WebPeer>> = RefCell::new(HashMap::new());
}
fn configuration(settings: &RtcSettings) -> RtcConfiguration {
let config = RtcConfiguration::new();
let servers = js_sys::Array::new();
for stun in &settings.stun {
let server = RtcIceServer::new();
server.set_urls(&JsValue::from_str(&format!("stun:{stun}")));
servers.push(&server);
}
config.set_ice_servers(&servers);
config
}
fn sender(peer: EndpointId) -> impl Fn(Outbound) -> bool + Send + Sync + 'static {
move |message| {
PEERS.with(|peers| {
let peers = peers.borrow();
let Some(p) = peers.get(&peer) else {
return false;
};
let (channel, bytes, video) = match &message {
Outbound::Audio(b) => (&p.audio, b, false),
Outbound::Video(b) => (&p.video, b, true),
Outbound::Control(b) => (&p.video, b, false),
Outbound::Frame(b) => (&p.frames, b, false),
};
let Some(channel) = channel else {
return false;
};
if channel.ready_state() != RtcDataChannelState::Open {
return false;
}
if video && channel.buffered_amount() as usize > VIDEO_BACKLOG {
return false;
}
channel.send_with_u8_array(bytes).is_ok()
})
}
}
fn new_peer(
peer: EndpointId,
room: Entity,
settings: &RtcSettings,
hub: Arc<MediaHub>,
outbox: Outbox,
) -> Result<RtcPeerConnection, String> {
close(peer, &hub);
let pc =
RtcPeerConnection::new_with_configuration(&configuration(settings)).map_err(describe)?;
let link = Arc::new(RtcLink::new(peer, sender(peer)));
let mut closures = Vec::new();
let on_candidate = Closure::wrap(Box::new(move |event: JsValue| {
let event: RtcPeerConnectionIceEvent = event.unchecked_into();
if let Some(candidate) = event.candidate() {
outbox.push(
room,
peer,
RtcSignal {
kind: SignalKind::Candidate,
body: candidate.candidate(),
},
);
}
}) as Box<dyn FnMut(JsValue)>);
pc.set_onicecandidate(Some(on_candidate.as_ref().unchecked_ref()));
closures.push(on_candidate);
let (pc2, hub2) = (pc.clone(), hub.clone());
let on_state = Closure::wrap(Box::new(move |_: JsValue| {
let state = pc2.ice_connection_state();
bevy::log::debug!("bevy_iroh: webrtc {} ice {:?}", peer.fmt_short(), state);
if matches!(
state,
RtcIceConnectionState::Failed
| RtcIceConnectionState::Disconnected
| RtcIceConnectionState::Closed
) {
close(peer, &hub2);
}
}) as Box<dyn FnMut(JsValue)>);
pc.set_oniceconnectionstatechange(Some(on_state.as_ref().unchecked_ref()));
closures.push(on_state);
PEERS.with(|peers| {
peers.borrow_mut().insert(
peer,
WebPeer {
pc: pc.clone(),
audio: None,
video: None,
frames: None,
link,
announced: false,
hub,
_closures: closures,
},
)
});
Ok(pc)
}
fn attach(peer: EndpointId, channel: RtcDataChannel) {
channel.set_binary_type(RtcDataChannelType::Arraybuffer);
let label = channel.label();
let on_open = Closure::wrap(Box::new(move |_: JsValue| {
let ready = PEERS.with(|peers| {
let mut peers = peers.borrow_mut();
let Some(p) = peers.get_mut(&peer) else {
return None;
};
let open = |c: &Option<RtcDataChannel>| {
c.as_ref()
.is_some_and(|c| c.ready_state() == RtcDataChannelState::Open)
};
if open(&p.audio) && open(&p.video) && open(&p.frames) && !p.announced {
p.announced = true;
Some((p.link.clone(), p.hub.clone()))
} else {
None
}
});
if let Some((link, hub)) = ready {
wasm_bindgen_futures::spawn_local(async move { hub.add_rtc_link(link).await });
}
}) as Box<dyn FnMut(JsValue)>);
channel.set_onopen(Some(on_open.as_ref().unchecked_ref()));
let on_message = Closure::wrap(Box::new(move |event: JsValue| {
let event: MessageEvent = event.unchecked_into();
let Ok(buffer) = event.data().dyn_into::<js_sys::ArrayBuffer>() else {
return;
};
let data = js_sys::Uint8Array::new(&buffer).to_vec();
let found = PEERS.with(|peers| {
peers
.borrow()
.get(&peer)
.map(|p| (p.link.clone(), p.hub.clone()))
});
if let Some((link, hub)) = found {
hub.rtc_receive(&link, &data);
}
}) as Box<dyn FnMut(JsValue)>);
channel.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
let hub = PEERS.with(|peers| peers.borrow().get(&peer).map(|p| p.hub.clone()));
let on_close = Closure::wrap(Box::new(move |_: JsValue| {
if let Some(hub) = &hub {
close(peer, hub);
}
}) as Box<dyn FnMut(JsValue)>);
channel.set_onclose(Some(on_close.as_ref().unchecked_ref()));
PEERS.with(|peers| {
let mut peers = peers.borrow_mut();
let Some(p) = peers.get_mut(&peer) else {
return;
};
match label.as_str() {
AUDIO_CHANNEL => p.audio = Some(channel),
VIDEO_CHANNEL => p.video = Some(channel),
FRAMES_CHANNEL => p.frames = Some(channel),
_ => return,
}
p._closures.extend([on_open, on_message, on_close]);
});
}
fn description(kind: RtcSdpType, sdp: &str) -> RtcSessionDescriptionInit {
let init = RtcSessionDescriptionInit::new(kind);
init.set_sdp(sdp);
init
}
fn sdp_of(value: &JsValue) -> Option<String> {
js_sys::Reflect::get(value, &"sdp".into())
.ok()
.and_then(|s| s.as_string())
}
pub(crate) fn offer(
peer: EndpointId,
room: Entity,
settings: &RtcSettings,
hub: Arc<MediaHub>,
outbox: Outbox,
_iroh: &Iroh,
) {
let pc = match new_peer(peer, room, settings, hub, outbox.clone()) {
Ok(pc) => pc,
Err(e) => {
bevy::log::warn!("bevy_iroh: webrtc: {e}");
return;
}
};
let audio_init = RtcDataChannelInit::new();
audio_init.set_ordered(false);
audio_init.set_max_retransmits(0);
attach(
peer,
pc.create_data_channel_with_data_channel_dict(AUDIO_CHANNEL, &audio_init),
);
attach(peer, pc.create_data_channel(VIDEO_CHANNEL));
attach(peer, pc.create_data_channel(FRAMES_CHANNEL));
wasm_bindgen_futures::spawn_local(async move {
let result: Result<(), String> = async {
let offer = JsFuture::from(pc.create_offer()).await.map_err(describe)?;
let sdp = sdp_of(&offer).ok_or("offer without sdp")?;
JsFuture::from(pc.set_local_description(&description(RtcSdpType::Offer, &sdp)))
.await
.map_err(describe)?;
outbox.push(
room,
peer,
RtcSignal {
kind: SignalKind::Offer,
body: sdp,
},
);
Ok(())
}
.await;
if let Err(e) = result {
bevy::log::warn!("bevy_iroh: webrtc offer: {e}");
}
});
}
pub(crate) fn answer(
peer: EndpointId,
room: Entity,
sdp: String,
settings: &RtcSettings,
hub: Arc<MediaHub>,
outbox: Outbox,
_iroh: &Iroh,
) {
let pc = match new_peer(peer, room, settings, hub, outbox.clone()) {
Ok(pc) => pc,
Err(e) => {
bevy::log::warn!("bevy_iroh: webrtc: {e}");
return;
}
};
let on_channel = Closure::wrap(Box::new(move |event: JsValue| {
let event: RtcDataChannelEvent = event.unchecked_into();
attach(peer, event.channel());
}) as Box<dyn FnMut(JsValue)>);
pc.set_ondatachannel(Some(on_channel.as_ref().unchecked_ref()));
PEERS.with(|peers| {
if let Some(p) = peers.borrow_mut().get_mut(&peer) {
p._closures.push(on_channel);
}
});
wasm_bindgen_futures::spawn_local(async move {
let result: Result<(), String> = async {
JsFuture::from(pc.set_remote_description(&description(RtcSdpType::Offer, &sdp)))
.await
.map_err(describe)?;
let answer = JsFuture::from(pc.create_answer()).await.map_err(describe)?;
let sdp = sdp_of(&answer).ok_or("answer without sdp")?;
JsFuture::from(pc.set_local_description(&description(RtcSdpType::Answer, &sdp)))
.await
.map_err(describe)?;
outbox.push(
room,
peer,
RtcSignal {
kind: SignalKind::Answer,
body: sdp,
},
);
Ok(())
}
.await;
if let Err(e) = result {
bevy::log::warn!("bevy_iroh: webrtc answer: {e}");
}
});
}
pub(crate) fn accept_answer(peer: EndpointId, sdp: String) {
let Some(pc) = PEERS.with(|peers| peers.borrow().get(&peer).map(|p| p.pc.clone())) else {
return;
};
wasm_bindgen_futures::spawn_local(async move {
if let Err(e) =
JsFuture::from(pc.set_remote_description(&description(RtcSdpType::Answer, &sdp))).await
{
bevy::log::warn!("bevy_iroh: webrtc answer: {}", describe(e));
}
});
}
pub(crate) fn add_candidate(peer: EndpointId, candidate: String) {
let Some(pc) = PEERS.with(|peers| peers.borrow().get(&peer).map(|p| p.pc.clone())) else {
return;
};
let init = RtcIceCandidateInit::new(&candidate);
init.set_sdp_m_line_index(Some(0));
wasm_bindgen_futures::spawn_local(async move {
if let Err(e) =
JsFuture::from(pc.add_ice_candidate_with_opt_rtc_ice_candidate_init(Some(&init))).await
{
bevy::log::debug!("bevy_iroh: webrtc candidate: {}", describe(e));
}
});
}
pub(crate) fn close(peer: EndpointId, hub: &Arc<MediaHub>) {
let Some(gone) = PEERS.with(|peers| peers.borrow_mut().remove(&peer)) else {
return;
};
gone.pc.set_onicecandidate(None);
gone.pc.set_oniceconnectionstatechange(None);
gone.pc.set_ondatachannel(None);
for channel in [&gone.audio, &gone.video, &gone.frames]
.into_iter()
.flatten()
{
channel.set_onopen(None);
channel.set_onmessage(None);
channel.set_onclose(None);
}
gone.pc.close();
if gone.announced {
let (hub, id) = (hub.clone(), gone.link.id);
wasm_bindgen_futures::spawn_local(async move { hub.remove_rtc_link(id).await });
}
let _ = Rc::new(());
}