use crate::messages::{Broadcast, NetworkQueue, PeerStateQueue};
use crate::systems;
use bevy::prelude::*;
use bevy_matchbox::prelude::*;
use matchbox_socket::RtcIceServerConfig;
use serde::{Serialize, de::DeserializeOwned};
use std::marker::PhantomData;
use std::time::Duration;
#[derive(Resource, Debug, Clone)]
pub struct SymbiosMultiuserConfig<T> {
pub room_url: String,
pub ice_servers: Option<RtcIceServerConfig>,
#[doc(hidden)]
pub _marker: PhantomData<T>,
}
#[cfg(feature = "client")]
#[derive(Resource)]
struct SocketOpened<T> {
room_url: String,
ice: Option<RtcIceServerConfig>,
_marker: PhantomData<T>,
}
#[cfg(feature = "client")]
#[derive(Resource)]
struct ReconnectCooldown<T> {
next_allowed_at: Duration,
next_delay: Duration,
_marker: PhantomData<T>,
}
#[cfg(feature = "client")]
const INITIAL_RECONNECT_DELAY: Duration = Duration::from_secs(1);
#[cfg(feature = "client")]
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(60);
#[cfg(feature = "client")]
fn ice_unchanged(
current: &Option<RtcIceServerConfig>,
stored: &Option<RtcIceServerConfig>,
) -> bool {
match (current, stored) {
(None, None) => true,
(Some(a), Some(b)) => {
a.urls == b.urls && a.username == b.username && a.credential == b.credential
}
_ => false,
}
}
pub struct SymbiosMultiuserPlugin<T> {
config: Option<SymbiosMultiuserConfig<T>>,
_marker: PhantomData<T>,
}
impl<T> SymbiosMultiuserPlugin<T> {
pub fn new(room_url: impl Into<String>) -> Self {
Self {
config: Some(SymbiosMultiuserConfig {
room_url: room_url.into(),
ice_servers: None,
_marker: PhantomData,
}),
_marker: PhantomData,
}
}
pub fn with_config(config: SymbiosMultiuserConfig<T>) -> Self {
Self {
config: Some(config),
_marker: PhantomData,
}
}
pub fn deferred() -> Self {
Self {
config: None,
_marker: PhantomData,
}
}
}
#[cfg(feature = "client")]
#[derive(Resource)]
struct SymbiosPluginInstalled;
#[cfg(feature = "client")]
impl<T> Plugin for SymbiosMultiuserPlugin<T>
where
T: Serialize + DeserializeOwned + Send + Sync + 'static + std::fmt::Debug + Clone,
{
fn build(&self, app: &mut App) {
if app.world().contains_resource::<SymbiosPluginInstalled>() {
panic!(
"SymbiosMultiuserPlugin can only be added once per App. \
MatchboxSocket is a single global resource and cannot be \
shared across multiple plugin instances (even with different \
message type parameters)."
);
}
app.insert_resource(SymbiosPluginInstalled);
app.init_resource::<NetworkQueue<T>>()
.init_resource::<PeerStateQueue<T>>()
.init_resource::<crate::signaller::PeerSessionMapRes>()
.add_message::<Broadcast<T>>()
.add_systems(
Update,
(
open_socket::<T>,
(
systems::poll_peers::<T>,
systems::receive_messages::<T>,
systems::transmit_messages::<T>,
)
.chain()
.run_if(resource_exists::<MatchboxSocket>),
)
.chain(),
);
if let Some(ref config) = self.config {
app.insert_resource(config.clone());
}
}
}
#[cfg(feature = "client")]
#[allow(clippy::too_many_arguments)]
fn open_socket<T: Send + Sync + 'static>(
mut commands: Commands,
time: Res<Time>,
config: Option<Res<SymbiosMultiuserConfig<T>>>,
opened: Option<Res<SocketOpened<T>>>,
socket: Option<Res<MatchboxSocket>>,
cooldown: Option<Res<ReconnectCooldown<T>>>,
#[cfg(feature = "client")] token_source: Option<Res<crate::signaller::TokenSourceRes>>,
#[cfg(feature = "client")] session_map: Res<crate::signaller::PeerSessionMapRes>,
) {
if let Some(ref marker) = opened {
let needs_teardown = match config.as_ref() {
None => true,
Some(cfg) => {
cfg.room_url != marker.room_url || !ice_unchanged(&cfg.ice_servers, &marker.ice)
}
};
if needs_teardown {
tracing::info!("tearing down socket (config removed or room changed)");
commands.remove_resource::<SocketOpened<T>>();
if socket.is_some() {
commands.remove_resource::<MatchboxSocket>();
}
if cooldown.is_some() {
commands.remove_resource::<ReconnectCooldown<T>>();
}
return;
}
let socket_dead = socket.as_ref().is_some_and(|s| s.any_channel_closed());
tracing::trace!(
socket_present = socket.is_some(),
socket_dead,
"open_socket health check",
);
if socket.is_none() || socket_dead {
if socket_dead {
tracing::info!(
"matchbox socket message loop terminated, tearing down for reconnect"
);
commands.remove_resource::<MatchboxSocket>();
} else {
tracing::info!(
"socket was lost while config unchanged, clearing marker for reconnect"
);
}
commands.remove_resource::<SocketOpened<T>>();
let current_delay = cooldown
.as_ref()
.map(|cd| cd.next_delay)
.unwrap_or(INITIAL_RECONNECT_DELAY);
let next_allowed_at = time.elapsed() + current_delay;
let next_delay = (current_delay * 2).min(MAX_RECONNECT_DELAY);
commands.insert_resource(ReconnectCooldown::<T> {
next_allowed_at,
next_delay,
_marker: PhantomData,
});
}
return;
}
let Some(config) = config else {
return;
};
if let Some(cd) = cooldown.as_ref()
&& time.elapsed() < cd.next_allowed_at
{
return;
}
tracing::debug!(
room_url = %config.room_url,
has_ice_servers = config.ice_servers.is_some(),
"opening WebRTC socket",
);
let mut builder = WebRtcSocketBuilder::new(&config.room_url)
.add_channel(ChannelConfig::reliable())
.add_channel(ChannelConfig::unreliable());
if let Some(ref ice) = config.ice_servers {
tracing::debug!(?ice, "configuring ICE servers");
builder = builder.ice_server(ice.clone());
}
#[cfg(feature = "client")]
{
let map = session_map.0.clone();
let signaller = if let Some(ts) = token_source {
crate::signaller::signaller_with_token_source_and_map(ts.0.clone(), map)
} else {
crate::signaller::signaller_anonymous_with_map(map)
};
builder = builder.signaller_builder(signaller);
}
commands.open_socket(builder);
commands.insert_resource(SocketOpened::<T> {
room_url: config.room_url.clone(),
ice: config.ice_servers.clone(),
_marker: PhantomData,
});
}