Skip to main content

tauri_plugin_webrtc/
lib.rs

1//! WebRTC for Tauri webviews that lack it (WebKitGTK on Linux).
2//!
3//! Injects a W3C-shaped `RTCPeerConnection` / `RTCDataChannel` shim that is
4//! installed only when the webview has no native implementation, and backs it
5//! with a pure Rust engine ([qrtc](https://github.com/crabnebula-dev/qrtc), str0m-based).
6//!
7//! ```ignore
8//! tauri::Builder::default().plugin(tauri_plugin_webrtc::init())
9//! ```
10
11use serde::Serialize;
12use std::collections::HashMap;
13use std::sync::atomic::{AtomicU32, Ordering};
14use std::sync::{Arc, Mutex};
15use tauri::ipc::{Channel, InvokeBody, InvokeResponseBody, Request};
16use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
17use tauri::webview::PageLoadEvent;
18use tauri::{Manager, Runtime, State, Webview};
19use qrtc::*;
20
21const SHIM: &str = include_str!("../guest-js/shim.js");
22
23/// Error returned to the shim, which rethrows it as a `DOMException`.
24#[derive(Debug, Serialize)]
25pub struct DomError {
26    name: &'static str,
27    message: String,
28}
29
30impl From<Error> for DomError {
31    fn from(e: Error) -> Self {
32        DomError {
33            name: e.dom_name(),
34            message: e.to_string(),
35        }
36    }
37}
38
39fn dom(name: &'static str, message: impl Into<String>) -> DomError {
40    DomError {
41        name,
42        message: message.into(),
43    }
44}
45
46type CmdResult<T> = std::result::Result<T, DomError>;
47
48struct Entry {
49    peer: Arc<dyn Peer>,
50    webview: String,
51}
52
53/// Plugin state: the engine and every live peer connection.
54pub struct WebrtcState {
55    engine: std::result::Result<Arc<dyn PeerEngine>, String>,
56    peers: Mutex<HashMap<u32, Entry>>,
57    next_id: AtomicU32,
58    max_peers_per_webview: usize,
59}
60
61impl WebrtcState {
62    fn peer(&self, webview: &str, id: u32) -> CmdResult<Arc<dyn Peer>> {
63        let peers = self.peers.lock().unwrap();
64        match peers.get(&id) {
65            Some(e) if e.webview == webview => Ok(e.peer.clone()),
66            _ => Err(dom(
67                "InvalidStateError",
68                format!("unknown peer connection {id}"),
69            )),
70        }
71    }
72
73    /// Close every peer connection owned by a webview (navigation, reload, destroy).
74    fn close_webview(&self, webview: &str) {
75        let closing: Vec<Arc<dyn Peer>> = {
76            let mut peers = self.peers.lock().unwrap();
77            let ids: Vec<u32> = peers
78                .iter()
79                .filter(|(_, e)| e.webview == webview)
80                .map(|(id, _)| *id)
81                .collect();
82            ids.into_iter()
83                .filter_map(|id| peers.remove(&id))
84                .map(|e| e.peer)
85                .collect()
86        };
87        if !closing.is_empty() {
88            log::debug!(
89                "closing {} peer connection(s) for webview {webview}",
90                closing.len()
91            );
92            std::thread::spawn(move || closing.iter().for_each(|p| p.close()));
93        }
94    }
95}
96
97async fn blocking<T: Send + 'static>(
98    f: impl FnOnce() -> Result<T> + Send + 'static,
99) -> CmdResult<T> {
100    tauri::async_runtime::spawn_blocking(f)
101        .await
102        .map_err(|e| dom("OperationError", e.to_string()))?
103        .map_err(DomError::from)
104}
105
106/// Media frame on the peer's channel:
107/// `[u32 LE tx][u8 2][u8 flags: bit0 keyframe][u8 codec][u8 outbound][u64 LE timestamp_us][payload]`.
108/// `outbound` 1 marks an engine-encoded frame for a sender in transform mode.
109fn media_frame(f: EncodedFrame, outbound: bool) -> Vec<u8> {
110    let mut buf = Vec::with_capacity(16 + f.data.len());
111    buf.extend_from_slice(&f.tx.to_le_bytes());
112    buf.push(2);
113    buf.push(f.keyframe as u8);
114    buf.push(f.codec.wire_id());
115    buf.push(outbound as u8);
116    buf.extend_from_slice(&f.timestamp_us.to_le_bytes());
117    buf.extend_from_slice(&f.data);
118    buf
119}
120
121/// Decoded audio on the peer's channel:
122/// `[u32 LE tx][u8 3][u8 0][u8 0][u8 0][u64 0][i16 LE samples, 48 kHz mono]`.
123fn audio_frame(tx: TxId, samples: &[i16]) -> Vec<u8> {
124    let mut buf = Vec::with_capacity(16 + samples.len() * 2);
125    buf.extend_from_slice(&tx.to_le_bytes());
126    buf.extend_from_slice(&[3, 0, 0, 0]);
127    buf.extend_from_slice(&0u64.to_le_bytes());
128    for s in samples {
129        buf.extend_from_slice(&s.to_le_bytes());
130    }
131    buf
132}
133
134/// Wire format for data channel messages on the peer's channel:
135/// `[u32 LE handle][u8 kind: 0 text, 1 binary][payload]`.
136fn frame(handle: DcHandle, payload: Payload) -> Vec<u8> {
137    let (kind, bytes) = match payload {
138        Payload::Text(s) => (0u8, s.into_bytes()),
139        Payload::Binary(b) => (1u8, b),
140    };
141    let mut buf = Vec::with_capacity(5 + bytes.len());
142    buf.extend_from_slice(&handle.to_le_bytes());
143    buf.push(kind);
144    buf.extend_from_slice(&bytes);
145    buf
146}
147
148#[tauri::command]
149async fn pc_create<R: Runtime>(
150    webview: Webview<R>,
151    state: State<'_, WebrtcState>,
152    config: RtcConfiguration,
153    channel: Channel<InvokeResponseBody>,
154) -> CmdResult<u32> {
155    let engine = state
156        .engine
157        .clone()
158        .map_err(|e| dom("NotSupportedError", e))?;
159    let label = webview.label().to_string();
160    {
161        let peers = state.peers.lock().unwrap();
162        if peers.values().filter(|e| e.webview == label).count() >= state.max_peers_per_webview {
163            return Err(dom(
164                "OperationError",
165                "too many peer connections for this webview",
166            ));
167        }
168    }
169    // One ordered channel carries both JSON events and raw message frames,
170    // so a data channel's `open` can never overtake its first message.
171    let sink: EventSink = Arc::new(move |e: PeerEvent| {
172        let body = match e {
173            PeerEvent::DcMessage { handle, payload } => {
174                InvokeResponseBody::Raw(frame(handle, payload))
175            }
176            PeerEvent::MediaFrame(f) => InvokeResponseBody::Raw(media_frame(f, false)),
177            PeerEvent::EncodedOut(f) => InvokeResponseBody::Raw(media_frame(f, true)),
178            PeerEvent::AudioPcm { tx, samples } => {
179                InvokeResponseBody::Raw(audio_frame(tx, &samples))
180            }
181            other => match serde_json::to_string(&other) {
182                Ok(s) => InvokeResponseBody::Json(s),
183                Err(err) => {
184                    log::warn!("event serialise: {err}");
185                    return;
186                }
187            },
188        };
189        let _ = channel.send(body);
190    });
191    let peer: Arc<dyn Peer> =
192        blocking(move || engine.create_peer(&config, sink).map(Arc::from)).await?;
193    let id = state.next_id.fetch_add(1, Ordering::Relaxed);
194    state.peers.lock().unwrap().insert(
195        id,
196        Entry {
197            peer,
198            webview: label,
199        },
200    );
201    Ok(id)
202}
203
204#[tauri::command]
205async fn pc_create_offer<R: Runtime>(
206    webview: Webview<R>,
207    state: State<'_, WebrtcState>,
208    id: u32,
209) -> CmdResult<SessionDescription> {
210    let p = state.peer(webview.label(), id)?;
211    blocking(move || p.create_offer()).await
212}
213
214#[tauri::command]
215async fn pc_create_answer<R: Runtime>(
216    webview: Webview<R>,
217    state: State<'_, WebrtcState>,
218    id: u32,
219) -> CmdResult<SessionDescription> {
220    let p = state.peer(webview.label(), id)?;
221    blocking(move || p.create_answer()).await
222}
223
224#[tauri::command]
225async fn pc_set_local<R: Runtime>(
226    webview: Webview<R>,
227    state: State<'_, WebrtcState>,
228    id: u32,
229    desc: SessionDescription,
230) -> CmdResult<Vec<TransceiverState>> {
231    let p = state.peer(webview.label(), id)?;
232    blocking(move || p.set_local_description(&desc)).await
233}
234
235#[tauri::command]
236async fn pc_set_remote<R: Runtime>(
237    webview: Webview<R>,
238    state: State<'_, WebrtcState>,
239    id: u32,
240    desc: SessionDescription,
241) -> CmdResult<Vec<TransceiverState>> {
242    let p = state.peer(webview.label(), id)?;
243    blocking(move || p.set_remote_description(&desc)).await
244}
245
246#[tauri::command]
247async fn pc_add_ice<R: Runtime>(
248    webview: Webview<R>,
249    state: State<'_, WebrtcState>,
250    id: u32,
251    candidate: IceCandidate,
252) -> CmdResult<()> {
253    let p = state.peer(webview.label(), id)?;
254    blocking(move || p.add_ice_candidate(&candidate)).await
255}
256
257#[tauri::command]
258async fn pc_get_stats<R: Runtime>(
259    webview: Webview<R>,
260    state: State<'_, WebrtcState>,
261    id: u32,
262) -> CmdResult<serde_json::Value> {
263    let p = state.peer(webview.label(), id)?;
264    blocking(move || p.stats()).await
265}
266
267#[tauri::command]
268async fn pc_close<R: Runtime>(
269    webview: Webview<R>,
270    state: State<'_, WebrtcState>,
271    id: u32,
272) -> CmdResult<()> {
273    let removed = {
274        let mut peers = state.peers.lock().unwrap();
275        match peers.get(&id) {
276            Some(e) if e.webview == webview.label() => peers.remove(&id),
277            _ => None,
278        }
279    };
280    if let Some(e) = removed {
281        blocking(move || {
282            e.peer.close();
283            Ok(())
284        })
285        .await?;
286    }
287    Ok(())
288}
289
290#[tauri::command]
291async fn dc_create<R: Runtime>(
292    webview: Webview<R>,
293    state: State<'_, WebrtcState>,
294    id: u32,
295    label: String,
296    init: DataChannelInit,
297) -> CmdResult<DataChannelInfo> {
298    let p = state.peer(webview.label(), id)?;
299    blocking(move || p.create_data_channel(&label, &init)).await
300}
301
302fn header<T: std::str::FromStr>(req: &Request<'_>, name: &str) -> CmdResult<T> {
303    req.headers()
304        .get(name)
305        .and_then(|v| v.to_str().ok())
306        .and_then(|v| v.parse().ok())
307        .ok_or_else(|| dom("TypeError", format!("missing or invalid header {name}")))
308}
309
310/// Parse a batch of outgoing messages: repeated `[u8 kind][u32 LE len][bytes]`.
311fn parse_batch(mut body: &[u8]) -> CmdResult<Vec<Payload>> {
312    let mut out = Vec::new();
313    while !body.is_empty() {
314        if body.len() < 5 {
315            return Err(dom("TypeError", "truncated dc_send batch"));
316        }
317        let kind = body[0];
318        let len = u32::from_le_bytes([body[1], body[2], body[3], body[4]]) as usize;
319        let rest = &body[5..];
320        if rest.len() < len {
321            return Err(dom("TypeError", "truncated dc_send record"));
322        }
323        let bytes = rest[..len].to_vec();
324        out.push(match kind {
325            0 => Payload::Text(
326                String::from_utf8(bytes)
327                    .map_err(|_| dom("TypeError", "text payload is not UTF-8"))?,
328            ),
329            _ => Payload::Binary(bytes),
330        });
331        body = &rest[len..];
332    }
333    Ok(out)
334}
335
336/// Send a batch of messages on one data channel, in order.
337///
338/// Raw body: see [`parse_batch`]. Headers: `x-pc`, `x-dc`. The shim keeps
339/// one call in flight per channel and batches behind it, because concurrent
340/// IPC calls are not guaranteed to complete in order.
341#[tauri::command]
342async fn dc_send<R: Runtime>(
343    webview: Webview<R>,
344    state: State<'_, WebrtcState>,
345    request: Request<'_>,
346) -> CmdResult<u64> {
347    let id: u32 = header(&request, "x-pc")?;
348    let handle: DcHandle = header(&request, "x-dc")?;
349    let InvokeBody::Raw(body) = request.body() else {
350        return Err(dom("TypeError", "dc_send expects a raw body"));
351    };
352    let batch = parse_batch(body)?;
353    let p = state.peer(webview.label(), id)?;
354    // The engine's send only queues a command; no need for the blocking pool.
355    for payload in batch {
356        p.dc_send(handle, payload)?;
357    }
358    // Report the engine-side buffer so the shim can keep bufferedAmount honest.
359    p.dc_buffered_amount(handle).map_err(DomError::from)
360}
361
362#[tauri::command]
363async fn pc_upsert_transceiver<R: Runtime>(
364    webview: Webview<R>,
365    state: State<'_, WebrtcState>,
366    id: u32,
367    spec: TransceiverSpec,
368) -> CmdResult<()> {
369    let p = state.peer(webview.label(), id)?;
370    blocking(move || p.upsert_transceiver(spec)).await
371}
372
373#[tauri::command]
374async fn pc_insert_dtmf<R: Runtime>(
375    webview: Webview<R>,
376    state: State<'_, WebrtcState>,
377    id: u32,
378    tx: TxId,
379    event: u8,
380    duration_ms: u32,
381) -> CmdResult<()> {
382    state
383        .peer(webview.label(), id)?
384        .insert_dtmf(tx, event, duration_ms)
385        .map_err(DomError::from)
386}
387
388#[tauri::command]
389async fn pc_set_transform<R: Runtime>(
390    webview: Webview<R>,
391    state: State<'_, WebrtcState>,
392    id: u32,
393    tx: TxId,
394    send: bool,
395    recv: bool,
396) -> CmdResult<()> {
397    state
398        .peer(webview.label(), id)?
399        .set_transform(tx, send, recv)
400        .map_err(DomError::from)
401}
402
403#[tauri::command]
404async fn pc_audio_processing<R: Runtime>(
405    webview: Webview<R>,
406    state: State<'_, WebrtcState>,
407    id: u32,
408    tx: TxId,
409    echo_cancellation: bool,
410    noise_suppression: bool,
411    auto_gain_control: bool,
412) -> CmdResult<()> {
413    state
414        .peer(webview.label(), id)?
415        .set_audio_processing(tx, echo_cancellation, noise_suppression, auto_gain_control)
416        .map_err(DomError::from)
417}
418
419#[tauri::command]
420async fn pc_request_keyframe<R: Runtime>(
421    webview: Webview<R>,
422    state: State<'_, WebrtcState>,
423    id: u32,
424    tx: TxId,
425) -> CmdResult<()> {
426    state
427        .peer(webview.label(), id)?
428        .request_keyframe(tx)
429        .map_err(DomError::from)
430}
431
432#[tauri::command]
433async fn pc_restart_ice<R: Runtime>(
434    webview: Webview<R>,
435    state: State<'_, WebrtcState>,
436    id: u32,
437) -> CmdResult<()> {
438    state
439        .peer(webview.label(), id)?
440        .restart_ice()
441        .map_err(DomError::from)
442}
443
444/// Encoded frames from the page (its encoder, or its transform). Headers:
445/// `x-pc`, `x-tx`, `x-codec` (wire id), and `x-dir: recv` for received
446/// frames coming back from a transform for engine decoding. Raw body: frames
447/// back to back, each `[u8 keyframe][u64 LE timestamp_us][u32 LE len][bytes]`.
448/// One call carries whatever queued up, so IPC latency cannot throttle the
449/// frame rate.
450#[tauri::command]
451async fn media_push<R: Runtime>(
452    webview: Webview<R>,
453    state: State<'_, WebrtcState>,
454    request: Request<'_>,
455) -> CmdResult<()> {
456    let id: u32 = header(&request, "x-pc")?;
457    let tx: TxId = header(&request, "x-tx")?;
458    let codec: u8 = header(&request, "x-codec")?;
459    let codec = CodecName::from_wire_id(codec).ok_or_else(|| dom("TypeError", "unknown codec"))?;
460    let InvokeBody::Raw(body) = request.body() else {
461        return Err(dom("TypeError", "media_push expects a raw body"));
462    };
463    let recv = request
464        .headers()
465        .get("x-dir")
466        .map(|v| v == "recv")
467        .unwrap_or(false);
468    let p = state.peer(webview.label(), id)?;
469    let mut rest = &body[..];
470    while !rest.is_empty() {
471        if rest.len() < 13 {
472            return Err(dom("TypeError", "truncated media frame header"));
473        }
474        let key = rest[0] == 1;
475        let ts = u64::from_le_bytes(rest[1..9].try_into().expect("8 bytes"));
476        let len = u32::from_le_bytes(rest[9..13].try_into().expect("4 bytes")) as usize;
477        let Some(data) = rest.get(13..13 + len) else {
478            return Err(dom("TypeError", "truncated media frame"));
479        };
480        rest = &rest[13 + len..];
481        if recv {
482            // A received frame back from the page's transform, for engine decode.
483            p.decode_audio(tx, data.to_vec()).map_err(DomError::from)?;
484        } else {
485            p.send_frame(EncodedFrame {
486                tx,
487                codec,
488                keyframe: key,
489                timestamp_us: ts,
490                data: data.into(),
491            })
492            .map_err(DomError::from)?;
493        }
494    }
495    Ok(())
496}
497
498/// Captured PCM for an audio sender. Raw body: i16 LE, 48 kHz mono.
499/// Headers: `x-pc`, `x-tx`.
500#[tauri::command]
501async fn audio_push<R: Runtime>(
502    webview: Webview<R>,
503    state: State<'_, WebrtcState>,
504    request: Request<'_>,
505) -> CmdResult<()> {
506    let id: u32 = header(&request, "x-pc")?;
507    let tx: TxId = header(&request, "x-tx")?;
508    let InvokeBody::Raw(body) = request.body() else {
509        return Err(dom("TypeError", "audio_push expects a raw body"));
510    };
511    let samples: Vec<i16> = body
512        .chunks_exact(2)
513        .map(|c| i16::from_le_bytes([c[0], c[1]]))
514        .collect();
515    state
516        .peer(webview.label(), id)?
517        .push_pcm(tx, samples)
518        .map_err(DomError::from)
519}
520
521#[tauri::command]
522async fn dc_close<R: Runtime>(
523    webview: Webview<R>,
524    state: State<'_, WebrtcState>,
525    id: u32,
526    handle: DcHandle,
527) -> CmdResult<()> {
528    state
529        .peer(webview.label(), id)?
530        .dc_close(handle)
531        .map_err(DomError::from)
532}
533
534#[tauri::command]
535async fn dc_buffered_amount<R: Runtime>(
536    webview: Webview<R>,
537    state: State<'_, WebrtcState>,
538    id: u32,
539    handle: DcHandle,
540) -> CmdResult<u64> {
541    state
542        .peer(webview.label(), id)?
543        .dc_buffered_amount(handle)
544        .map_err(DomError::from)
545}
546
547#[tauri::command]
548async fn dc_set_threshold<R: Runtime>(
549    webview: Webview<R>,
550    state: State<'_, WebrtcState>,
551    id: u32,
552    handle: DcHandle,
553    threshold: u64,
554) -> CmdResult<()> {
555    state
556        .peer(webview.label(), id)?
557        .dc_set_buffered_amount_low_threshold(handle, threshold)
558        .map_err(DomError::from)
559}
560
561/// Plugin configuration.
562pub struct Builder {
563    force_shim: bool,
564    max_peers_per_webview: usize,
565    engine: Option<Arc<dyn PeerEngine>>,
566}
567
568impl Default for Builder {
569    fn default() -> Self {
570        Self {
571            force_shim: false,
572            max_peers_per_webview: 16,
573            engine: None,
574        }
575    }
576}
577
578impl Builder {
579    pub fn new() -> Self {
580        Self::default()
581    }
582
583    /// Install the shim even when the webview has a native `RTCPeerConnection`.
584    /// For testing the shim on webviews that already support WebRTC.
585    pub fn force_shim(mut self, force: bool) -> Self {
586        self.force_shim = force;
587        self
588    }
589
590    /// Cap on concurrent peer connections per webview. Default 16.
591    pub fn max_peers_per_webview(mut self, n: usize) -> Self {
592        self.max_peers_per_webview = n;
593        self
594    }
595
596    /// Use a different engine than the default (native, pure Rust) one.
597    pub fn engine(mut self, engine: Arc<dyn PeerEngine>) -> Self {
598        self.engine = Some(engine);
599        self
600    }
601
602    pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
603        let engine: std::result::Result<Arc<dyn PeerEngine>, String> = match self.engine {
604            Some(e) => Ok(e),
605            None => default_engine(self.force_shim),
606        };
607        if let Err(e) = &engine {
608            log::warn!("tauri-plugin-webrtc: engine unavailable, shim disabled: {e}");
609        }
610        let info = serde_json::json!({
611            "available": engine.is_ok(),
612            "engine": engine.as_ref().map(|e| e.name()).ok(),
613            "error": engine.as_ref().err(),
614            "force": self.force_shim,
615        });
616        let script = format!("window.__TAURI_WEBRTC__ = {info};\n{SHIM}");
617        let max = self.max_peers_per_webview;
618        let engine = Mutex::new(Some(engine));
619
620        PluginBuilder::new("webrtc")
621            // All frames: Element Call runs in a same-origin widget iframe.
622            .js_init_script_on_all_frames(script)
623            .invoke_handler(tauri::generate_handler![
624                pc_create,
625                pc_create_offer,
626                pc_create_answer,
627                pc_set_local,
628                pc_set_remote,
629                pc_add_ice,
630                pc_get_stats,
631                pc_close,
632                pc_upsert_transceiver,
633                pc_request_keyframe,
634                pc_set_transform,
635                pc_insert_dtmf,
636                pc_audio_processing,
637                pc_restart_ice,
638                media_push,
639                audio_push,
640                dc_create,
641                dc_send,
642                dc_close,
643                dc_buffered_amount,
644                dc_set_threshold,
645            ])
646            .setup(move |app, _api| {
647                let engine = engine.lock().unwrap().take().expect("setup runs once");
648                app.manage(WebrtcState {
649                    engine,
650                    peers: Mutex::new(HashMap::new()),
651                    next_id: AtomicU32::new(1),
652                    max_peers_per_webview: max,
653                });
654                Ok(())
655            })
656            .on_page_load(|webview, payload| {
657                if payload.event() == PageLoadEvent::Started {
658                    webview
659                        .state::<WebrtcState>()
660                        .close_webview(webview.label());
661                }
662            })
663            .on_event(|app, event| {
664                // A destroyed window takes its webview (same label) with it.
665                if let tauri::RunEvent::WindowEvent {
666                    label,
667                    event: tauri::WindowEvent::Destroyed,
668                    ..
669                } = event
670                {
671                    app.state::<WebrtcState>().close_webview(label);
672                }
673            })
674            .on_drop(|app| {
675                let state = app.state::<WebrtcState>();
676                let all: Vec<Arc<dyn Peer>> = state
677                    .peers
678                    .lock()
679                    .unwrap()
680                    .drain()
681                    .map(|(_, e)| e.peer)
682                    .collect();
683                all.iter().for_each(|p| p.close());
684            })
685            .build()
686    }
687}
688
689// The engine starts on Linux, where WebKitGTK lacks WebRTC. Elsewhere the webview
690// has native WebRTC, so the engine only starts when the shim is forced (tests).
691fn default_engine(force: bool) -> std::result::Result<Arc<dyn PeerEngine>, String> {
692    if !cfg!(target_os = "linux") && !force {
693        return Err("no default engine on this platform; the webview has native WebRTC".into());
694    }
695    qrtc::native::NativeEngine::new()
696        .map(|e| Arc::new(e) as Arc<dyn PeerEngine>)
697        .map_err(|e| e.to_string())
698}
699
700/// Plugin with default settings.
701pub fn init<R: Runtime>() -> TauriPlugin<R> {
702    Builder::default().build()
703}