1use 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#[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
53pub 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 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
106fn 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
121fn 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
134fn 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 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
310fn 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#[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 for payload in batch {
356 p.dc_send(handle, payload)?;
357 }
358 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#[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 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#[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
561pub 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 pub fn force_shim(mut self, force: bool) -> Self {
586 self.force_shim = force;
587 self
588 }
589
590 pub fn max_peers_per_webview(mut self, n: usize) -> Self {
592 self.max_peers_per_webview = n;
593 self
594 }
595
596 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 .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 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
689fn 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
700pub fn init<R: Runtime>() -> TauriPlugin<R> {
702 Builder::default().build()
703}