use crate::messages::{
Broadcast, LocalSocketReopened, NetworkQueue, PeerStateQueue, SendTo, WelcomeHandshakeComplete,
};
use crate::socket::{MatchboxSocket, OpenSocketExt};
use crate::systems;
use bevy::prelude::*;
use matchbox_socket::{ChannelConfig, PeerId, RtcIceServerConfig, WebRtcSocketBuilder};
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>,
opened_at: Duration,
welcomed_peer_id: Option<PeerId>,
_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")]
const SOCKET_STABILITY_THRESHOLD: Duration = Duration::from_secs(30);
#[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>()
.init_resource::<crate::signaller::SignalDiagnosticsRes>()
.add_message::<Broadcast<T>>()
.add_message::<SendTo<T>>()
.add_message::<LocalSocketReopened>()
.add_message::<WelcomeHandshakeComplete>();
#[cfg(target_arch = "wasm32")]
app.init_resource::<crate::signaller::WasmBlindRetryStateRes>();
app.add_systems(
Update,
(
open_socket::<T>,
(
systems::poll_peers::<T>,
detect_welcome_handshake::<T>,
systems::receive_messages::<T>,
systems::transmit_messages::<T>,
systems::transmit_directed_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>>>,
mut reopened: MessageWriter<LocalSocketReopened>,
#[cfg(feature = "client")] token_source: Option<Res<crate::signaller::TokenSourceRes>>,
#[cfg(feature = "client")] session_map: Res<crate::signaller::PeerSessionMapRes>,
#[cfg(feature = "client")] signal_diagnostics: Res<crate::signaller::SignalDiagnosticsRes>,
#[cfg(all(feature = "client", target_arch = "wasm32"))] wasm_retry: Res<
crate::signaller::WasmBlindRetryStateRes,
>,
) {
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,
});
} else if cooldown.is_some() {
let elapsed_since_opened = time.elapsed().saturating_sub(marker.opened_at);
if elapsed_since_opened >= SOCKET_STABILITY_THRESHOLD {
tracing::debug!(
elapsed_since_opened_secs = elapsed_since_opened.as_secs(),
"socket stable — clearing stale reconnect cooldown"
);
commands.remove_resource::<ReconnectCooldown<T>>();
}
}
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 signaller = crate::signaller::signaller_full(
token_source.map(|ts| ts.0.clone()),
Some(session_map.0.clone()),
Some(signal_diagnostics.0.clone()),
#[cfg(target_arch = "wasm32")]
Some(wasm_retry.0.clone()),
);
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(),
opened_at: time.elapsed(),
welcomed_peer_id: None,
_marker: PhantomData,
});
reopened.write(LocalSocketReopened);
}
#[cfg(feature = "client")]
fn detect_welcome_handshake<T: Send + Sync + 'static>(
socket: Option<ResMut<MatchboxSocket>>,
opened: Option<ResMut<SocketOpened<T>>>,
mut writer: MessageWriter<WelcomeHandshakeComplete>,
) {
let (Some(mut socket), Some(mut opened)) = (socket, opened) else {
return;
};
if opened.welcomed_peer_id.is_some() {
return;
}
if let Some(local_peer_id) = socket.id() {
opened.welcomed_peer_id = Some(local_peer_id);
tracing::debug!(%local_peer_id, "relay welcome handshake complete");
writer.write(WelcomeHandshakeComplete { local_peer_id });
}
}