-
-
Notifications
You must be signed in to change notification settings - Fork 55
Hello world
Two peer connections in one process, negotiating with each other and passing a message. About sixty lines, and no signalling server, no second machine, no network and no camera.
It is deliberately the smallest thing that can fail for a real reason. Restoring and compiling
prove almost nothing — NuGet falls back to the net10.0 slice when a platform slice is missing, so
a package with no Android binding in it compiles perfectly and then has nothing behind the
interface. This does not compile-check anything. It runs, and if it prints a message then on your
platform:
- the native half loaded —
libwebrtc.aar,WebRTC.xcframework,WebRTC.framework,WebRtcInterop.dll, or the browser's own WebRTC; - the binding marshalled SDP in both directions;
- ICE candidates survived the round trip;
- DTLS and SCTP completed.
That is the whole negotiation core. Everything else in WebRTC is built on it.
Signalling gets talked about as though it were part of WebRTC. It is not: WebRTC never says how an offer reaches the other side. A signalling server is just a courier, and if both peers live in the same process you can be the courier yourself, in two event handlers.
sequenceDiagram
autonumber
participant C as caller
participant Y as your code<br/>(the "signalling server")
participant E as callee
Y->>C: CreateDataChannel("hello")
Y->>C: CreateOffer()
C-->>Y: offer (SDP)
Y->>C: SetLocalDescription(offer)
Y->>E: SetRemoteDescription(offer)
Y->>E: CreateAnswer()
E-->>Y: answer (SDP)
Y->>E: SetLocalDescription(answer)
Y->>C: SetRemoteDescription(answer)
par candidates, both ways
C-->>Y: OnIceCandidate
Y->>E: AddIceCandidate
and
E-->>Y: OnIceCandidate
Y->>C: AddIceCandidate
end
Note over C,E: ICE connects · DTLS handshake · SCTP
E-->>Y: OnDataChannel
C-->>Y: OnOpen
Y->>C: Send("hello from the other side")
C->>E: over the data channel
E-->>Y: OnMessage
Replace the middle column with a websocket and you have a real signalling server. That is all one is.
One file, no dependencies beyond the WebRTCme package.
using System.Text;
using Microsoft.JSInterop;
using WebRTCme;
public static class Loopback
{
// Long enough for DTLS on a slow phone, short enough that a hang is a failure.
static readonly TimeSpan Patience = TimeSpan.FromSeconds(30);
/// <param name="jsRuntime">
/// Blazor only — the browser's JS runtime. The other four platforms take it and ignore it,
/// so passing it unconditionally is correct everywhere.
/// </param>
public static async Task<string> RunAsync(IJSRuntime jsRuntime = null)
{
var window = CrossWebRtc.Current.Window(jsRuntime);
// No STUN server: over loopback, host candidates are all that is needed, and reaching for
// a public one would make this depend on the internet being up.
var configuration = new RTCConfiguration
{
IceServers = [],
IceTransportPolicy = RTCIceTransportPolicy.All
};
using var caller = window.RTCPeerConnection(configuration);
using var callee = window.RTCPeerConnection(configuration);
// Signalling, in two handlers. This is all a signalling server ever does with candidates,
// minus the JSON and the websocket.
caller.OnIceCandidate += async (_, e) =>
{
if (e.Candidate is not null) await callee.AddIceCandidate(Init(e.Candidate));
};
callee.OnIceCandidate += async (_, e) =>
{
if (e.Candidate is not null) await caller.AddIceCandidate(Init(e.Candidate));
};
var received = new TaskCompletionSource<string>();
var calleeOpen = new TaskCompletionSource<bool>();
callee.OnDataChannel += (_, e) =>
{
var incoming = e.Channel;
incoming.OnMessage += (_, m) => received.TrySetResult(
m.Data as string ?? Encoding.UTF8.GetString((byte[])m.Data));
// ReadyState first, and this is not defensive padding — waiting only on OnOpen hangs.
// The channel routinely opens before this handler has subscribed. Same shape as the
// browser API, and the same fix.
if (incoming.ReadyState == RTCDataChannelState.Open) calleeOpen.TrySetResult(true);
else incoming.OnOpen += (_, _) => calleeOpen.TrySetResult(true);
};
var channel = caller.CreateDataChannel("hello");
var callerOpen = new TaskCompletionSource<bool>();
channel.OnOpen += (_, _) => callerOpen.TrySetResult(true);
if (channel.ReadyState == RTCDataChannelState.Open) callerOpen.TrySetResult(true);
// Offer, answer, and the two descriptions swapped over.
var offer = await caller.CreateOffer();
await caller.SetLocalDescription(offer);
await callee.SetRemoteDescription(offer);
var answer = await callee.CreateAnswer();
await callee.SetLocalDescription(answer);
await caller.SetRemoteDescription(answer);
await callerOpen.Task.WaitAsync(Patience);
await calleeOpen.Task.WaitAsync(Patience);
channel.Send("hello from the other side");
return await received.Task.WaitAsync(Patience);
}
/// <summary>The three fields a candidate needs to be re-added at the other end.</summary>
static RTCIceCandidateInit Init(IRTCIceCandidate candidate) => new()
{
Candidate = candidate.Candidate,
SdpMid = candidate.SdpMid,
SdpMLineIndex = candidate.SdpMLineIndex
};
}That file compiles unchanged against all five slices — net10.0, net10.0-android,
net10.0-ios, net10.0-maccatalyst and net10.0-windows10.0.22621.0.
You need Getting started done first — in particular the script tag on Blazor, and the MAUI version pin. No camera or microphone permission is needed: this touches neither.
@page "/hello"
@inject IJSRuntime JsRuntime
<button @onclick="Run">Run loopback</button>
<p>@result</p>
@code {
string result = "";
async Task Run()
{
try { result = await Loopback.RunAsync(JsRuntime); }
catch (Exception ex) { result = ex.ToString(); }
}
}async void OnRunClicked(object sender, EventArgs e)
{
try { ResultLabel.Text = await Loopback.RunAsync(); }
catch (Exception ex) { ResultLabel.Text = ex.ToString(); }
}Either way, success looks like this:
hello from the other side
ReadyState is checked before subscribing to OnOpen. The data channel routinely opens
before your handler has subscribed — the native observer is registered when the wrapper is built,
on a callback thread, while your handler is posted to the dispatcher. Wait only on the event and
you will hang, intermittently, on some platforms and not others. The browser API has the same race
and the same fix.
Every wait is bounded. An unbounded await on a native callback that never arrives gives you a
frozen app and nothing to read. WaitAsync turns that into a TimeoutException with a stack, which
is the difference between a bug you can report and a bug you cannot.
The IJSRuntime argument is passed on every platform. Blazor needs it — it is JSInterop over
the browser's WebRTC, and there is no other way for a plain class to reach the runtime. The other
four bindings take the argument and ignore it, so one signature works everywhere.
| What you see | Almost certainly |
|---|---|
| Nothing happens, no exception, app never starts (Windows) | Windows App Runtime missing — Platform prerequisites |
JsInterop is not defined, or nothing at all (Blazor) |
The <script> tag is missing or after blazor.webassembly.js
|
DllNotFoundException, or TypeInitializationException on CrossWebRtc.Current
|
The native payload did not arrive — the package's platform slice is missing or you are on a fallback slice |
| Connects, then times out waiting for the channel | Real, and worth reporting — open an issue with your platform and the exception |
More in Troubleshooting.
The only thing standing between this and a call between two devices is the middle column of that
diagram — a transport for the offer, the answer and the candidates. Nothing else changes:
CreateOffer, SetLocalDescription, AddIceCandidate are identical.
Two things do get added once the peers are on different machines:
A STUN server, so each peer can discover its public address. IceServers was empty above
because loopback needs only host candidates; a real call wants at least one:
IceServers = [new RTCIceServer { Urls = ["stun:stun.l.google.com:19302"] }]A TURN server, once the two peers are behind NATs that will not let them reach each other directly — which is a large share of real-world pairs, and the usual reason a call "works on my LAN" and then does not. See Connection: Signaling.
You can write that transport yourself, or use the one in the box:
| Connection: Signaling | Mesh / peer-to-peer, with a SignalR signalling server you deploy yourself. This repository contains the server. |
| Connection: MediaSoup | An SFU, for group calls that a mesh cannot carry. The server is versatica's mediasoup-demo, deployed separately. |
| Demo apps | Both of the above, working, in two complete applications. |
It is a reduction of Tests/WebRTCme.DeviceTests.Core/LoopbackScenarios.cs, which is the same
negotiation wrapped in a result type and run by CI on Windows, Android, iOS, Mac Catalyst and
Blazor on every release. If this page's sample ever stops working, that suite is what should have
caught it first — see Testing.
Start here
The stack
- WebRTCnative
- Bindings
- The unified API
- Middleware
- Connection
- · Signaling (mesh)
- · MediaSoup (SFU)
- Demo apps
Reference
When it breaks