Upgrade Dioxus and auto-connect signaling

This commit is contained in:
ghost 2025-11-02 14:32:12 +01:00
parent 573abae5e3
commit a917b4142b
10 changed files with 1735 additions and 996 deletions

2333
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,8 @@ edition = "2021"
[dependencies]
# Dioxus Framework
dioxus = { version = "0.6.0", features = ["web"] }
dioxus-logger = "0.6.2"
dioxus = { version = "0.7", features = ["web"] }
dioxus-logger = "0.7"
console_error_panic_hook = "0.1.7"
# WebAssembly and Browser APIs

View File

@ -1,5 +1,6 @@
{
"server": {
"stun_server": "stun:stun.l.google.com:19302"
"stun_server": "stun:stun.l.google.com:19302",
"signaling_url": "ws://localhost:3478/ws"
}
}

View File

@ -1,7 +1,9 @@
use crate::config::Config;
use crate::models::SignalingMessage;
use crate::utils::MediaManager;
use dioxus::prelude::*;
use futures::StreamExt;
use std::rc::Rc;
use wasm_bindgen::prelude::Closure;
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;
@ -19,9 +21,10 @@ pub fn ConnectionPanel(
initiator_connection: Signal<Option<RtcPeerConnection>>, // Initiator PC (wird für eingehende Answers verwendet)
local_media: Signal<Option<MediaStream>>,
) -> Element {
let mut ws_status = use_signal(|| "Nicht verbunden".to_string());
let ws_status = use_signal(|| "Nicht verbunden".to_string());
// Buffer for an incoming Answer SDP if the initiator PC isn't ready yet
let mut pending_answer = use_signal(|| None::<String>);
let pending_answer = use_signal(|| None::<String>);
let cfg_signal: Signal<Config> = use_context();
// **COROUTINE** für Offer-Handling (Responder empfängt Offers)
let offer_handler = use_coroutine(move |mut rx| async move {
@ -174,122 +177,165 @@ pub fn ConnectionPanel(
};
// WebSocket verbinden
let connect_websocket = move |_| {
log::info!("🔌 Verbinde WebSocket...");
ws_status.set("Verbinde...".to_string());
let connect_logic: Rc<dyn Fn()> = {
let ws_status_signal = ws_status.clone();
let connected_signal = connected.clone();
let websocket_signal = websocket.clone();
let offer_handler = offer_handler.clone();
let pending_answer_handle = pending_answer.clone();
let cfg_signal_handle = cfg_signal.clone();
let initiator_connection_signal = initiator_connection.clone();
let peer_connection_signal = peer_connection.clone();
match BrowserWebSocket::new("ws://localhost:3478/ws") {
Ok(socket) => {
socket.set_binary_type(BinaryType::Arraybuffer);
Rc::new(move || {
let mut ws_status = ws_status_signal.clone();
let connected = connected_signal.clone();
let mut websocket = websocket_signal.clone();
let cfg_signal = cfg_signal_handle.clone();
let initiator_connection = initiator_connection_signal.clone();
let peer_connection = peer_connection_signal.clone();
let pending_answer = pending_answer_handle.clone();
// onopen Handler
let mut ws_status_clone = ws_status.clone();
let mut connected_clone = connected.clone();
let onopen = Closure::wrap(Box::new(move |_: web_sys::Event| {
log::info!("✅ WebSocket verbunden!");
ws_status_clone.set("Verbunden".to_string());
connected_clone.set(true);
}) as Box<dyn FnMut(web_sys::Event)>);
if *connected.read() || websocket.read().is_some() {
return;
}
// onclose Handler
let mut ws_status_clone2 = ws_status.clone();
let mut connected_clone2 = connected.clone();
let onclose = Closure::wrap(Box::new(move |_: web_sys::CloseEvent| {
log::warn!("❌ WebSocket getrennt");
ws_status_clone2.set("Getrennt".to_string());
connected_clone2.set(false);
})
as Box<dyn FnMut(web_sys::CloseEvent)>);
ws_status.set("Verbinde...".to_string());
// **MESSAGE ROUTER** - Leitet Messages an die richtigen Handler weiter
let offer_tx = offer_handler.clone();
let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
if let Some(text) = e.data().as_string() {
log::info!("📨 WebSocket Nachricht: {}", text);
let endpoint = cfg_signal.read().server.signaling_url.trim().to_string();
let target = if endpoint.is_empty() {
crate::constants::DEFAULT_SIGNALING_URL.to_string()
} else {
endpoint
};
if let Ok(msg) = serde_json::from_str::<SignalingMessage>(&text) {
match msg.msg_type.as_str() {
"offer" => {
log::info!("🔀 Leite Offer an Responder-Handler weiter");
offer_tx.send(msg);
}
"answer" => {
log::info!("🔀 Answer empfangen - leite an Initiator-PeerConnection weiter");
let data_clone = msg.data.clone();
if let Some(pc) = initiator_connection.read().as_ref() {
// Versuche die Answer als Remote Description zu setzen
let pc_clone = pc.clone();
spawn_local(async move {
match crate::utils::MediaManager::handle_answer(&pc_clone, &data_clone).await {
log::info!("🔌 Verbinde WebSocket zu {}", target);
match BrowserWebSocket::new(&target) {
Ok(socket) => {
socket.set_binary_type(BinaryType::Arraybuffer);
// onopen Handler
let mut ws_status_clone = ws_status.clone();
let mut connected_clone = connected.clone();
let onopen = Closure::wrap(Box::new(move |_: web_sys::Event| {
log::info!("✅ WebSocket verbunden!");
ws_status_clone.set("Verbunden".to_string());
connected_clone.set(true);
})
as Box<dyn FnMut(web_sys::Event)>);
// onclose Handler
let mut ws_status_clone2 = ws_status.clone();
let mut connected_clone2 = connected.clone();
let onclose = Closure::wrap(Box::new(move |_: web_sys::CloseEvent| {
log::warn!("❌ WebSocket getrennt");
ws_status_clone2.set("Getrennt".to_string());
connected_clone2.set(false);
})
as Box<dyn FnMut(web_sys::CloseEvent)>);
// **MESSAGE ROUTER** - Leitet Messages an die richtigen Handler weiter
let offer_tx = offer_handler.clone();
let pending_answer_signal = pending_answer.clone();
let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
if let Some(text) = e.data().as_string() {
log::info!("📨 WebSocket Nachricht: {}", text);
if let Ok(msg) = serde_json::from_str::<SignalingMessage>(&text) {
match msg.msg_type.as_str() {
"offer" => {
log::info!("🔀 Leite Offer an Responder-Handler weiter");
offer_tx.send(msg);
}
"answer" => {
log::info!("🔀 Answer empfangen - leite an Initiator-PeerConnection weiter");
let data_clone = msg.data.clone();
if let Some(pc) = initiator_connection.read().as_ref() {
// Versuche die Answer als Remote Description zu setzen
let pc_clone = pc.clone();
spawn_local(async move {
match crate::utils::MediaManager::handle_answer(&pc_clone, &data_clone).await {
Ok(_) => log::info!("✅ Answer erfolgreich gesetzt auf Initiator-PC"),
Err(e) => log::error!("❌ Fehler beim Setzen der Answer auf Initiator-PC: {}", e),
}
});
} else {
// Buffer the answer until an initiator PC exists
log::warn!("⚠️ Keine Initiator-PeerConnection vorhanden - buffer Answer");
pending_answer.set(Some(data_clone));
});
} else {
// Buffer the answer until an initiator PC exists
log::warn!("⚠️ Keine Initiator-PeerConnection vorhanden - buffer Answer");
let mut pending_answer_slot =
pending_answer_signal.clone();
pending_answer_slot.set(Some(data_clone));
}
}
}
"candidate" => {
log::info!("🔀 ICE-Kandidat empfangen: leite weiter");
// Determine whether this candidate is for initiator or responder
let data_clone = msg.data.clone();
// Try initiator first
if let Some(pc) = initiator_connection.read().as_ref() {
let pc_clone = pc.clone();
spawn_local(async move {
match crate::utils::MediaManager::add_ice_candidate(&pc_clone, &data_clone) {
Ok(_) => log::info!("✅ Kandidat zur Initiator-PC hinzugefügt"),
Err(e) => log::error!("❌ Kandidat konnte nicht hinzugefügt werden: {}", e),
}
});
} else if let Some(pc) = peer_connection.read().as_ref() {
let pc_clone = pc.clone();
spawn_local(async move {
match crate::utils::MediaManager::add_ice_candidate(&pc_clone, &data_clone) {
Ok(_) => log::info!("✅ Kandidat zur Responder-PC hinzugefügt"),
Err(e) => log::error!("❌ Kandidat konnte nicht hinzugefügt werden: {}", e),
}
});
} else {
log::warn!("⚠️ Kein PeerConnection verfügbar, um Kandidaten hinzuzufügen");
"candidate" => {
log::info!("🔀 ICE-Kandidat empfangen: leite weiter");
// Determine whether this candidate is for initiator or responder
let data_clone = msg.data.clone();
// Try initiator first
if let Some(pc) = initiator_connection.read().as_ref() {
let pc_clone = pc.clone();
spawn_local(async move {
match crate::utils::MediaManager::add_ice_candidate(&pc_clone, &data_clone) {
Ok(_) => log::info!("✅ Kandidat zur Initiator-PC hinzugefügt"),
Err(e) => log::error!("❌ Kandidat konnte nicht hinzugefügt werden: {}", e),
}
});
} else if let Some(pc) = peer_connection.read().as_ref() {
let pc_clone = pc.clone();
spawn_local(async move {
match crate::utils::MediaManager::add_ice_candidate(&pc_clone, &data_clone) {
Ok(_) => log::info!("✅ Kandidat zur Responder-PC hinzugefügt"),
Err(e) => log::error!("❌ Kandidat konnte nicht hinzugefügt werden: {}", e),
}
});
} else {
log::warn!("⚠️ Kein PeerConnection verfügbar, um Kandidaten hinzuzufügen");
}
}
}
"text" => {
log::info!("💬 Textnachricht: {}", msg.data);
if let Some(window) = web_sys::window() {
let _ = window.alert_with_message(&format!(
"Nachricht von {}:\n{}",
msg.from, msg.data
));
"text" => {
log::info!("💬 Textnachricht: {}", msg.data);
if let Some(window) = web_sys::window() {
let _ = window.alert_with_message(&format!(
"Nachricht von {}:\n{}",
msg.from, msg.data
));
}
}
_ => {
log::info!("❓ Unbekannte Nachricht: {}", msg.msg_type);
}
}
_ => {
log::info!("❓ Unbekannte Nachricht: {}", msg.msg_type);
}
}
}
}
}) as Box<dyn FnMut(MessageEvent)>);
})
as Box<dyn FnMut(MessageEvent)>);
socket.set_onopen(Some(onopen.as_ref().unchecked_ref()));
socket.set_onclose(Some(onclose.as_ref().unchecked_ref()));
socket.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
socket.set_onopen(Some(onopen.as_ref().unchecked_ref()));
socket.set_onclose(Some(onclose.as_ref().unchecked_ref()));
socket.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
onopen.forget();
onclose.forget();
onmessage.forget();
onopen.forget();
onclose.forget();
onmessage.forget();
websocket.set(Some(socket));
}
Err(e) => {
log::error!("❌ WebSocket Fehler: {:?}", e);
ws_status.set("Verbindungsfehler".to_string());
}
}
websocket.set(Some(socket));
}
Err(e) => {
log::error!("❌ WebSocket Fehler: {:?}", e);
ws_status.set("Verbindungsfehler".to_string());
}
};
})
};
{
let connect_logic = connect_logic.clone();
use_effect(move || {
connect_logic();
});
}
// Wenn eine gepufferte Answer vorhanden ist und später eine Initiator-PC gesetzt wird,
// verarbeite die gepufferte Answer.
{
@ -381,22 +427,6 @@ pub fn ConnectionPanel(
}
}
}
section { class: "connection-card",
header { class: "connection-card__header",
h3 { "Networking" }
}
button {
class: if *connected.read() { "btn btn--connected" } else { "btn" },
disabled: *connected.read(),
onclick: connect_websocket,
if *connected.read() {
"Connected"
} else {
"Connect"
}
}
}
}
}
}

View File

@ -3,7 +3,18 @@ use std::path::Path;
#[derive(Debug, Deserialize, Clone)]
pub struct ServerOptions {
#[serde(default = "default_stun_server")]
pub stun_server: String,
#[serde(default = "default_signaling_url")]
pub signaling_url: String,
}
fn default_stun_server() -> String {
crate::constants::DEFAULT_STUN_SERVER.to_string()
}
fn default_signaling_url() -> String {
crate::constants::DEFAULT_SIGNALING_URL.to_string()
}
#[derive(Debug, Deserialize, Clone)]
@ -32,7 +43,9 @@ pub async fn load_config_from_server() -> Result<Config, Box<dyn std::error::Err
}
// Fallback to fetching appsettings.json from the hosting origin
let resp = gloo_net::http::Request::get("appsettings.json").send().await?;
let resp = gloo_net::http::Request::get("appsettings.json")
.send()
.await?;
let text = resp.text().await?;
let cfg: Config = serde_json::from_str(&text)?;
Ok(cfg)
@ -41,7 +54,6 @@ pub async fn load_config_from_server() -> Result<Config, Box<dyn std::error::Err
// Try to read a JSON config injected into index.html inside a script tag
#[cfg(target_arch = "wasm32")]
async fn load_config_from_html() -> Result<Option<Config>, Box<dyn std::error::Error>> {
use wasm_bindgen::JsCast;
use web_sys::window;
let win = window().ok_or("no window")?;
@ -58,10 +70,9 @@ async fn load_config_from_html() -> Result<Option<Config>, Box<dyn std::error::E
// Synchronous HTML fast-path for WASM: read script#app-config synchronously if present.
#[cfg(target_arch = "wasm32")]
pub fn load_config_from_html_sync() -> Option<Config> {
use wasm_bindgen::JsCast;
use web_sys::window;
let win = window().ok()?;
let win = window()?;
let doc = win.document()?;
let elem = doc.get_element_by_id("app-config")?;
if let Some(text) = elem.text_content() {
@ -91,7 +102,12 @@ pub fn load_config_sync_or_default() -> Config {
}
// Fallback default
Config { server: ServerOptions { stun_server: crate::constants::DEFAULT_STUN_SERVER.to_string() } }
Config {
server: ServerOptions {
stun_server: crate::constants::DEFAULT_STUN_SERVER.to_string(),
signaling_url: crate::constants::DEFAULT_SIGNALING_URL.to_string(),
},
}
}
// Native loader convenience wrapper (blocking-friendly)
@ -109,5 +125,10 @@ pub async fn load_config_or_default() -> Config {
}
// Fallback default
Config { server: ServerOptions { stun_server: crate::constants::DEFAULT_STUN_SERVER.to_string() } }
Config {
server: ServerOptions {
stun_server: crate::constants::DEFAULT_STUN_SERVER.to_string(),
signaling_url: crate::constants::DEFAULT_SIGNALING_URL.to_string(),
},
}
}

View File

@ -1,4 +1,5 @@
// Central constants for niom-webrtc
pub const DEFAULT_STUN_SERVER: &str = "stun:stun.l.google.com:19302";
pub const DEFAULT_SIGNALING_URL: &str = "ws://localhost:3478/ws";
pub const ASSET_FAVICON: &str = "/assets/favicon.ico";
pub const ASSET_MAIN_CSS: &str = "/assets/main.css";

View File

@ -1,9 +1,9 @@
// Library root for niom-webrtc so integration tests and other crates can depend on the modules.
pub mod components;
pub mod models;
pub mod utils;
pub mod config;
pub mod constants;
pub mod models;
pub mod utils;
// Re-export commonly used items if needed in the future
// pub use config::*;

View File

@ -1,13 +1,12 @@
use crate::models::MediaState;
use js_sys::Reflect;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
use web_sys::{
MediaStream, MediaStreamConstraints,
RtcPeerConnection, RtcConfiguration, RtcIceServer,
RtcSessionDescriptionInit, RtcSdpType,
MediaStream, MediaStreamConstraints, RtcConfiguration, RtcIceServer, RtcPeerConnection,
RtcSdpType, RtcSessionDescriptionInit,
};
use js_sys::Reflect;
use crate::models::MediaState;
pub struct MediaManager {
pub state: MediaState,
@ -21,10 +20,10 @@ impl MediaManager {
}
pub fn create_peer_connection() -> Result<RtcPeerConnection, String> {
let ice_server = RtcIceServer::new();
let urls = js_sys::Array::new();
// Use centralized default STUN server constant
urls.push(&JsValue::from_str(crate::constants::DEFAULT_STUN_SERVER));
let ice_server = RtcIceServer::new();
let urls = js_sys::Array::new();
// Use centralized default STUN server constant
urls.push(&JsValue::from_str(crate::constants::DEFAULT_STUN_SERVER));
ice_server.set_urls(&urls.into());
let config = RtcConfiguration::new();
let servers = js_sys::Array::new();
@ -50,7 +49,7 @@ impl MediaManager {
.ok_or_else(|| "SDP field was not a string".to_string())?;
// 3. Init-Objekt bauen und SDP setzen
let init = RtcSessionDescriptionInit::new(RtcSdpType::Offer);
let init = RtcSessionDescriptionInit::new(RtcSdpType::Offer);
init.set_sdp(&sdp);
// 4. Local Description setzen
@ -59,7 +58,10 @@ impl MediaManager {
.map_err(|e| format!("set_local_description failed: {:?}", e))?;
log::info!("✅ Offer SDP length: {}", sdp.len());
log::debug!("📋 SDP-Preview: {}...", &sdp[..std::cmp::min(150, sdp.len())]);
log::debug!(
"📋 SDP-Preview: {}...",
&sdp[..std::cmp::min(150, sdp.len())]
);
Ok(sdp)
}
@ -96,7 +98,7 @@ impl MediaManager {
pub async fn handle_answer(pc: &RtcPeerConnection, answer_sdp: &str) -> Result<(), String> {
log::info!("📨 Handling received answer...");
// **DEBUG:** State vor Answer-Verarbeitung
// Use the signaling_state() result for debug but avoid importing the enum type locally.
let state = pc.signaling_state();
@ -105,14 +107,14 @@ impl MediaManager {
if state != web_sys::RtcSignalingState::HaveLocalOffer {
return Err(format!("❌ Falscher State für Answer: {:?}", state));
}
let init = RtcSessionDescriptionInit::new(RtcSdpType::Answer);
let init = RtcSessionDescriptionInit::new(RtcSdpType::Answer);
init.set_sdp(answer_sdp);
JsFuture::from(pc.set_remote_description(&init))
.await
.map_err(|e| format!("set_remote_answer desc failed: {:?}", e))?;
log::info!("✅ Handled answer, WebRTC handshake complete!");
Ok(())
}
@ -129,19 +131,20 @@ impl MediaManager {
return Err("WebRTC not supported".into());
}
self.state = MediaState::Requesting;
let navigator = web_sys::window()
.ok_or("No window")?
.navigator();
let navigator = web_sys::window().ok_or("No window")?.navigator();
let devices = navigator
.media_devices()
.map_err(|_| "MediaDevices not available")?;
let constraints = MediaStreamConstraints::new();
constraints.set_audio(&JsValue::from(true));
constraints.set_video(&JsValue::from(false));
let js_stream = JsFuture::from(devices.get_user_media_with_constraints(&constraints)
.map_err(|e| format!("getUserMedia error: {:?}", e))?)
.await
.map_err(|e| format!("getUserMedia promise rejected: {:?}", e))?;
let js_stream = JsFuture::from(
devices
.get_user_media_with_constraints(&constraints)
.map_err(|e| format!("getUserMedia error: {:?}", e))?,
)
.await
.map_err(|e| format!("getUserMedia promise rejected: {:?}", e))?;
let stream: MediaStream = js_stream
.dyn_into()
.map_err(|_| "Failed to cast to MediaStream")?;
@ -156,9 +159,8 @@ impl MediaManager {
// 1. JsValue holen
let js_val = tracks.get(i);
// 2. In MediaStreamTrack casten
let track: web_sys::MediaStreamTrack = js_val
.dyn_into()
.expect("Expected MediaStreamTrack");
let track: web_sys::MediaStreamTrack =
js_val.dyn_into().expect("Expected MediaStreamTrack");
// 3. Stoppen
track.stop();
log::info!("\u{1F6D1} Track gestoppt: {}", track.label());
@ -184,9 +186,16 @@ impl MediaManager {
// RtcRtpSender zurück; wir ignorieren den Rückgabewert hier.
// `addTrack` ist in manchen web-sys-Versionen nicht direkt verfügbar.
// Wir rufen die JS-Funktion dynamisch auf: pc.addTrack(track, stream)
let add_fn = js_sys::Reflect::get(pc.as_ref(), &JsValue::from_str("addTrack")).map_err(|_| "Failed to get addTrack function".to_string())?;
let func: js_sys::Function = add_fn.dyn_into().map_err(|_| "addTrack is not a function".to_string())?;
let _ = func.call2(pc.as_ref(), &JsValue::from(track.clone()), &JsValue::from(stream.clone()));
let add_fn = js_sys::Reflect::get(pc.as_ref(), &JsValue::from_str("addTrack"))
.map_err(|_| "Failed to get addTrack function".to_string())?;
let func: js_sys::Function = add_fn
.dyn_into()
.map_err(|_| "addTrack is not a function".to_string())?;
let _ = func.call2(
pc.as_ref(),
&JsValue::from(track.clone()),
&JsValue::from(stream.clone()),
);
log::info!("\u{2705} Track hinzugefügt: {}", track.label());
}
Ok(())
@ -220,7 +229,9 @@ impl MediaManager {
// Call pc.addIceCandidate(obj) dynamically
let add_fn = js_sys::Reflect::get(pc.as_ref(), &JsValue::from_str("addIceCandidate"))
.map_err(|_| "Failed to get addIceCandidate function".to_string())?;
let func: js_sys::Function = add_fn.dyn_into().map_err(|_| "addIceCandidate is not a function".to_string())?;
let func: js_sys::Function = add_fn
.dyn_into()
.map_err(|_| "addIceCandidate is not a function".to_string())?;
let _ = func.call1(pc.as_ref(), &obj);
Ok(())
}

View File

@ -6,7 +6,14 @@ fn sync_loader_returns_default_when_no_file() {
// Ensure there's no appsettings.json in CWD for this test
let _ = fs::remove_file("appsettings.json");
let cfg = load_config_sync_or_default();
assert_eq!(cfg.server.stun_server, niom_webrtc::constants::DEFAULT_STUN_SERVER.to_string());
assert_eq!(
cfg.server.stun_server,
niom_webrtc::constants::DEFAULT_STUN_SERVER.to_string()
);
assert_eq!(
cfg.server.signaling_url,
niom_webrtc::constants::DEFAULT_SIGNALING_URL.to_string()
);
}
// This test ensures the function compiles and returns a Config; on native it will use the file-path,
@ -16,4 +23,5 @@ fn sync_loader_api_callable() {
let cfg = load_config_sync_or_default();
// At minimum we have a non-empty stun_server
assert!(!cfg.server.stun_server.is_empty());
assert!(!cfg.server.signaling_url.is_empty());
}

View File

@ -1,5 +1,5 @@
use niom_webrtc::constants::DEFAULT_STUN_SERVER;
use niom_webrtc::config::Config;
use niom_webrtc::constants::{DEFAULT_SIGNALING_URL, DEFAULT_STUN_SERVER};
#[test]
fn default_stun_server_present() {
@ -10,8 +10,16 @@ fn default_stun_server_present() {
fn config_from_file_roundtrip() {
// Create a temporary JSON in /tmp and read it via Config::from_file
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
let cfg_json = r#"{ "server": { "stun_server": "stun:example.org:3478" } }"#;
let cfg_json = r#"{ "server": { "stun_server": "stun:example.org:3478", "signaling_url": "ws://example.org/ws" } }"#;
std::fs::write(tmp.path(), cfg_json).expect("write");
let cfg = Config::from_file(tmp.path()).expect("load");
assert_eq!(cfg.server.stun_server, "stun:example.org:3478");
assert_eq!(cfg.server.signaling_url, "ws://example.org/ws");
// Missing fields fall back to defaults
let cfg_json_missing = r#"{ "server": { "stun_server": "stun:another.org:9999" } }"#;
std::fs::write(tmp.path(), cfg_json_missing).expect("write missing");
let cfg_missing = Config::from_file(tmp.path()).expect("load missing");
assert_eq!(cfg_missing.server.stun_server, "stun:another.org:9999");
assert_eq!(cfg_missing.server.signaling_url, DEFAULT_SIGNALING_URL);
}