webrtc: add runtime config README example; provider/consumer docs; minor fixes

This commit is contained in:
ghost 2025-09-30 14:19:50 +02:00
parent 9fa1c22c5e
commit 40a909c991
9 changed files with 246 additions and 41 deletions

1
Cargo.lock generated
View File

@ -2686,6 +2686,7 @@ dependencies = [
"log",
"serde",
"serde_json",
"tempfile",
"tracing",
"wasm-bindgen",
"wasm-bindgen-futures",

View File

@ -53,6 +53,9 @@ serde_json = "1.0.100"
futures = "0.3.31"
gloo-net = "0.6"
[dev-dependencies]
tempfile = "3.6"
[features]
default = ["web"]
web = ["dioxus/web"]

View File

@ -23,3 +23,93 @@ To run for a different platform, use the `--platform platform` flag. E.g.
dx serve --platform desktop
```
## Configuration (appsettings.json and WASM fast-path)
This project supports an `appsettings.json`-style configuration that is loaded at runtime.
- Native (desktop/server): the app tries to read `appsettings.json` from the working directory.
- WASM (web): the loader will first try a small HTML-injection fast-path (see below). If that
is not present it will fetch `appsettings.json` from the hosting origin.
Example `appsettings.json`:
```json
{
"server": {
"stun_server": "stun:stun.l.google.com:19302"
}
}
```
HTML injection fast-path (for embedding a small config directly in `index.html`):
```html
<script id="app-config" type="application/json">{
"server": { "stun_server": "stun:stun.l.google.com:19302" }
}</script>
```
This is useful for static hosting scenarios where you want to provide a different runtime
configuration without rebuilding the WASM artifact.
## Consuming the Config in the UI (Dioxus)
The Dioxus app provides a `Signal<Config>` via the hooks context. Child components can consume
it with:
```rust
let cfg_signal = use_context::<Signal<niom_webrtc::config::Config>>();
let cfg = cfg_signal.get(); // or cfg_signal.read()/() depending on the signal API
// use cfg.server.stun_server
```
The app uses an async loader (via `use_resource`) and overwrites the initial default config when
the runtime-loaded config becomes available. This gives instant sensible defaults while still
allowing runtime overrides.
### Example: Provider + Consumer (Dioxus)
Below is a minimal example showing how the app creates a `Signal<Config>` provider and how a
child component consumes it. This pattern gives children an immediately-available default
config while an async fetch can replace it at runtime.
```rust
use dioxus::prelude::*;
use dioxus_hooks::use_resource;
use niom_webrtc::config;
#[allow(non_snake_case)]
fn ConfigProvider(cx: Scope) -> Element {
// synchronous immediate default
let default_cfg = config::load_config_sync_or_default();
// a reactive signal holding the current config
let cfg_signal = use_state(cx, || default_cfg.clone());
// async resource that loads runtime config (WASM fetch or native file)
let resource = use_resource(cx, (), |_| async move { config::load_config_or_default().await });
// when resource completes, update the signal so children react
if let Some(cfg) = resource.value() {
cfg_signal.set(cfg.clone());
}
// provide the signal to descendants
use_context_provider(cx, || cfg_signal.clone());
cx.render(rsx! { children(&cx) })
}
fn SomeChild(cx: Scope) -> Element {
let cfg_signal = use_context::<UseStateHandle<config::Config>>(cx);
let cfg = cfg_signal.get();
cx.render(rsx!(div { "STUN: {cfg.server.stun_server}" }))
}
```
Notes:
- `load_config_sync_or_default()` returns a sensible default immediately (plus HTML fast-path on WASM).
- `load_config_or_default()` is async and will perform a network fetch on WASM if the HTML fast-path wasn't present.

View File

@ -26,13 +26,74 @@ impl Config {
// WASM runtime loader: fetch `appsettings.json` from the hosting origin
#[cfg(target_arch = "wasm32")]
pub async fn load_config_from_server() -> Result<Config, Box<dyn std::error::Error>> {
// lazy import gloo-net to avoid non-wasm compile errors
// First try HTML-injection fast-path: look for a <script id="app-config"> element
if let Ok(Some(cfg)) = load_config_from_html().await {
return Ok(cfg);
}
// Fallback to fetching appsettings.json from the hosting origin
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)
}
// 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")?;
let doc = win.document().ok_or("no document")?;
if let Some(elem) = doc.get_element_by_id("app-config") {
if let Some(text) = elem.text_content() {
let cfg: Config = serde_json::from_str(&text)?;
return Ok(Some(cfg));
}
}
Ok(None)
}
// 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 doc = win.document()?;
let elem = doc.get_element_by_id("app-config")?;
if let Some(text) = elem.text_content() {
if let Ok(cfg) = serde_json::from_str::<Config>(&text) {
return Some(cfg);
}
}
None
}
/// Synchronous loader that prefers the HTML-injection fast-path on WASM, otherwise reads file on native.
pub fn load_config_sync_or_default() -> Config {
// WASM: try HTML-injection first
#[cfg(target_arch = "wasm32")]
{
if let Some(cfg) = load_config_from_html_sync() {
return cfg;
}
}
// Native: try reading appsettings.json from file
#[cfg(not(target_arch = "wasm32"))]
{
if let Ok(cfg) = Config::from_file("appsettings.json") {
return cfg;
}
}
// Fallback default
Config { server: ServerOptions { stun_server: crate::constants::DEFAULT_STUN_SERVER.to_string() } }
}
// Native loader convenience wrapper (blocking-friendly)
#[cfg(not(target_arch = "wasm32"))]
pub async fn load_config_from_server() -> Result<Config, Box<dyn std::error::Error>> {

10
src/lib.rs Normal file
View File

@ -0,0 +1,10 @@
// 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;
// Re-export commonly used items if needed in the future
// pub use config::*;
// pub use constants::*;

View File

@ -1,16 +1,11 @@
#![allow(non_snake_case)]
mod components;
mod models;
mod utils;
mod config;
mod constants;
use dioxus::prelude::*;
use log::Level;
use console_log::init_with_level;
use components::{ConnectionPanel, CallControls, StatusDisplay};
use niom_webrtc::components::{ConnectionPanel, CallControls, StatusDisplay};
use web_sys::{RtcPeerConnection, WebSocket as BrowserWebSocket, MediaStream};
// config functions used via fully-qualified paths below
const FAVICON: Asset = asset!("/assets/favicon.ico");
const MAIN_CSS: Asset = asset!("/assets/main.css");
@ -22,31 +17,47 @@ fn main() {
}
#[component]
fn App(cx: Scope) -> Element {
// Component state for loaded config
let cfg_state = use_state(cx, || None::<crate::config::Config>);
// Kick off async config load once
use_future(cx, (), |_| {
let cfg_state = cfg_state.clone();
async move {
let cfg = crate::config::load_config_or_default().await;
cfg_state.set(Some(cfg));
}
});
match cfg_state.get().as_ref() {
None => rsx!( div { "Lade Konfiguration…" } ),
Some(_cfg) => rsx! {
document::Link { rel: "icon", href: FAVICON }
document::Link { rel: "stylesheet", href: MAIN_CSS }
Content {}
}
fn App() -> Element {
rsx! {
document::Link { rel: "icon", href: FAVICON }
document::Link { rel: "stylesheet", href: MAIN_CSS }
ConfigProvider {}
}
}
#[component]
fn ConfigProvider() -> Element {
// Start with a synchronous default so the UI has a sensible config immediately.
let default_cfg = niom_webrtc::config::load_config_sync_or_default();
// A signal holding the active config; children will consume a Signal<Config> via use_context.
let mut cfg_signal = use_signal(|| default_cfg.clone());
// Spawn an async resource to load the config at runtime (WASM fetch / native file). When it
// finishes, overwrite the signal so the rest of the app sees the updated config.
let resource = use_resource(|| async move {
// load_config_or_default is async and returns a Config
niom_webrtc::config::load_config_or_default().await
});
// When the resource value becomes available, update the cfg_signal. This effect will run
// reactively whenever the resource's value changes.
// If the async resource has produced a value, apply it to the signal immediately so
// downstream consumers see the updated configuration.
if let Some(cfg) = resource.value().read_unchecked().as_ref() {
cfg_signal.set(cfg.clone());
}
// Provide the Signal<Config> to downstream components.
use_context_provider(|| cfg_signal.clone());
rsx! { Content {} }
}
#[component]
pub fn Content() -> Element {
// Config is provided by ConfigProvider via provide_context; components can use use_context to read it.
let peer_id = use_signal(|| "peer-loading...".to_string());
let remote_id = use_signal(|| String::new());
let connected = use_signal(|| false);

View File

@ -2,11 +2,9 @@ use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
use web_sys::{
MediaStream, MediaStreamConstraints, Navigator, Window,
MediaStream, MediaStreamConstraints,
RtcPeerConnection, RtcConfiguration, RtcIceServer,
// SDP-Types
RtcSignalingState,
RtcSessionDescription, RtcSessionDescriptionInit, RtcSdpType,
RtcSessionDescriptionInit, RtcSdpType,
};
use js_sys::Reflect;
use crate::models::MediaState;
@ -100,17 +98,12 @@ impl MediaManager {
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();
log::info!("🔍 PeerConnection state before answer: {:?}", state);
// **NUR** verarbeiten wenn im korrekten State
match state {
web_sys::RtcSignalingState::HaveLocalOffer => {
log::info!("✅ Korrekter State - verarbeite Answer");
}
_ => {
return Err(format!("❌ Falscher State für Answer: {:?}", state));
}
// Only proceed if in HaveLocalOffer
if state != web_sys::RtcSignalingState::HaveLocalOffer {
return Err(format!("❌ Falscher State für Answer: {:?}", state));
}
let init = RtcSessionDescriptionInit::new(RtcSdpType::Answer);

View File

@ -0,0 +1,19 @@
use niom_webrtc::config::load_config_sync_or_default;
use std::fs;
#[test]
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());
}
// This test ensures the function compiles and returns a Config; on native it will use the file-path,
// on wasm it would read the HTML-injection. We call it to assert the API surface.
#[test]
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());
}

17
tests/config_tests.rs Normal file
View File

@ -0,0 +1,17 @@
use niom_webrtc::constants::DEFAULT_STUN_SERVER;
use niom_webrtc::config::Config;
#[test]
fn default_stun_server_present() {
assert!(DEFAULT_STUN_SERVER.contains("stun:"));
}
#[test]
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" } }"#;
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");
}