nandi/jolt-nativepublic Fork 0
e86c31ce657330246fa40ebee4ee37bbd4a02950
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

lib.rs · 861 lines · 31.0 KBRust Blame HistoryRaw
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago1//! freeq's AV media plane, for callers that are not Rust.
2//!
3//! sleek reaches its calls by calling Rust from Rust. A client written in
4//! anything else cannot, and this is not a plane to write twice: MoQ over
5//! QUIC, Opus, H.264, camera capture and the SFU's own dial rules come to some
6//! three thousand lines that would say exactly the same thing again in another
7//! language, and be wrong in different places.
8//!
9//! So it was lifted out of sleek to here, and what crosses the boundary is
10//! only what jolt can hold: integers, doubles, borrowed UTF-8 strings, and one
11//! borrowed pixel pointer.
12//!
13//! # What is not here
14//!
15//! **Signaling.** A freeq call is opened, joined and left over IRC TAGMSGs —
16//! `+freeq.at/av-start` and its siblings — and the server answers with
17//! `+freeq.at/av-state`. Any client that can speak IRC already has everything
18//! it needs for that, in whatever language it speaks IRC in; crossing an FFI
19//! boundary to send a TAGMSG would be worse than not. This library begins once
20//! the session id and the SFU token are known, and ends when the call does.
21//!
22//! # The shape of it
23//!
24//! **Nothing calls back.** Status arrives through [`joltmoq_poll_status`],
25//! video through [`joltmoq_frame_poll`], both drained from whatever thread the
26//! caller paints on — see [`jolt_abi`] for why.
27//!
28//! **One call at a time.** There is one microphone, so there is one session,
29//! held in a global. A handle would imply a second call could run beside the
30//! first, which is not something the hardware or the person would enjoy.
31//!
32//! **Frames are borrowed, not copied.** [`joltmoq_frame_rgba`] hands out a
33//! pointer into the decoder's own buffer, good until the next poll. A frame is
34//! a megabyte or two; copying it out so the caller can hand it straight to a
35//! texture upload would be two copies a frame to no end.
36
37pub mod av;
38pub mod av_media;
39mod v4l2cam;
40
41use std::collections::HashMap;
42use std::ffi::{c_char, c_int};
43use std::sync::{Mutex, OnceLock};
44
45use jolt_abi::{borrowed, empty_str, guard, preference, Scratch};
46
47use av::{RgbaVideoFrame, VideoFrameStore};
48use av_media::{AvMediaConfig, AvMediaSession, AvMediaUpdate};
49
50/// One scratch per family of string-returning calls, so that asking for the
51/// video keys does not invalidate a device list the caller is still reading.
52static STATUS_TEXT: Scratch = Scratch::new();
53static FRAME_KEY: Scratch = Scratch::new();
54static VIDEO_KEYS: Scratch = Scratch::new();
55static DEVICES: Scratch = Scratch::new();
56static DIAL: Scratch = Scratch::new();
57
58// ── The one session ─────────────────────────────────────────────────────────
59
60/// Everything a live call holds on this side.
61#[derive(Default)]
62struct Slot {
63 session: Option<AvMediaSession>,
64 /// Status updates from the media task, waiting to be polled.
65 status: Vec<Status>,
66 /// The status most recently handed out, whose fields the accessors read.
67 current: Option<Status>,
68 /// The live session's frame store, and the generation last handed out per
69 /// feed — which is how a poll knows what is new.
70 video: Option<VideoFrameStore>,
71 seen: HashMap<String, u64>,
72 /// The frame most recently handed out. Held so that the pixel pointer the
73 /// caller was given stays alive until the next poll replaces it.
74 frame: Option<(String, RgbaVideoFrame)>,
75}
76
77#[derive(Clone)]
78enum Status {
79 Live { has_camera: bool, has_mic: bool },
80 Ended,
81 Failed(String),
82}
83
84fn session_slot() -> &'static Mutex<Slot> {
85 static SLOT: OnceLock<Mutex<Slot>> = OnceLock::new();
86 SLOT.get_or_init(|| Mutex::new(Slot::default()))
87}
88
Fill the slot, and do not wedge on the way out e86c31c nandi 20d ago89/// A tokio runtime of our own: the caller has no reactor to lend us.
90///
91/// Deliberately *not* in the slot. Tearing a call down needs the runtime and
92/// must not be holding the slot lock while it does — see [`joltmoq_stop`].
93/// Made once and kept, since building one per call would tear worker threads
94/// down and up again on every join.
95fn runtime() -> Option<&'static tokio::runtime::Runtime> {
96 static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new();
97 RUNTIME
98 .get_or_init(|| {
99 tokio::runtime::Builder::new_multi_thread()
100 .enable_all()
101 .thread_name("joltmoq")
102 .build()
103 .map_err(|e| log::error!("joltmoq: no runtime: {e}"))
104 .ok()
105 })
106 .as_ref()
107}
108
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago109/// Run `f` against the one session, catching panics on the way out.
110///
111/// `fallback` answers both a panic and a poisoned lock, and is deliberately
112/// taken by value rather than requiring `Copy`: the answer is sometimes a
113/// `String`. The `Option` is how one value serves both arms without being
114/// moved twice.
115fn with_slot<R>(fallback: R, f: impl FnOnce(&mut Slot) -> R) -> R {
116 guard(None, || match session_slot().lock() {
117 Ok(mut slot) => Some(f(&mut slot)),
118 Err(_) => None,
119 })
120 .unwrap_or(fallback)
121}
122
123// ── Lifecycle ───────────────────────────────────────────────────────────────
124
125/// Turn on logging to stderr, honouring `RUST_LOG`. Safe to call twice.
126///
127/// Worth calling first while a call refuses to connect: the media plane says a
128/// great deal about why, and says none of it otherwise.
129#[no_mangle]
130pub extern "C" fn joltmoq_init_logging() {
131 guard((), || {
132 let _ = env_logger::try_init();
133 })
134}
135
136/// Join the call at `sfu_url` as `nick`, answering 1 when the media task
137/// started.
138///
139/// `sfu_url` is what [`joltmoq_sfu_url`] built, `session_id` is the id the
140/// server broadcast in `+freeq.at/av-id`, and `instance` is the per-device id
141/// the caller put in its own `av-join` — two devices signed in as the same
142/// person need different ones or their broadcast paths collide and each
143/// unpublishes the other.
144///
145/// The rest are pre-call preferences: `muted`, `speaker_muted` and `camera` as
146/// 0 or 1, then three device preferences, each an empty string for "whatever
147/// the system uses".
148///
149/// This returns as soon as the task is spawned. Connecting takes a moment, and
150/// it is [`joltmoq_poll_status`] that says whether it worked. Starting a second
151/// call while one is live is refused; stop the first.
152///
153/// # Safety
154/// Every pointer is null or a NUL-terminated UTF-8 string.
155#[no_mangle]
156#[allow(clippy::too_many_arguments)]
157pub unsafe extern "C" fn joltmoq_start(
158 sfu_url: *const c_char,
159 session_id: *const c_char,
160 nick: *const c_char,
161 instance: *const c_char,
162 muted: c_int,
163 speaker_muted: c_int,
164 camera: c_int,
165 camera_id: *const c_char,
166 mic_id: *const c_char,
167 speaker_id: *const c_char,
168) -> c_int {
169 let sfu_url = borrowed(sfu_url);
170 let session_id = borrowed(session_id);
171 let nick = borrowed(nick);
172 let instance = borrowed(instance);
173 let camera_id = preference(borrowed(camera_id));
174 let mic_id = preference(borrowed(mic_id));
175 let speaker_id = preference(borrowed(speaker_id));
176
Fill the slot, and do not wedge on the way out e86c31c nandi 20d ago177 let Some(runtime) = runtime() else {
178 return 0;
179 };
180
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago181 with_slot(0, |slot| {
182 if slot.session.is_some() {
183 log::warn!("joltmoq: a call is already live; stop it first");
184 return 0;
185 }
186 let Ok(url) = url::Url::parse(&sfu_url) else {
187 log::warn!("joltmoq: not a URL: {sfu_url}");
188 return 0;
189 };
190
191 let config = AvMediaConfig {
192 sfu_url: url,
193 session_id,
194 nick,
195 instance,
196 muted: muted != 0,
197 speaker_muted: speaker_muted != 0,
198 camera_enabled: camera != 0,
199 camera_id,
200 mic_id,
201 speaker_id,
202 };
203
204 // `AvMediaSession::start` spawns onto the ambient runtime, so it has to
205 // be entered. The status closure it takes runs on a worker thread,
206 // which is why all it does is push onto a queue the caller drains.
207 let _entered = runtime.enter();
208 let session = AvMediaSession::start(config, |update| {
209 let status = match update {
210 AvMediaUpdate::Live {
211 has_camera,
212 has_mic,
213 ..
214 } => Status::Live {
215 has_camera,
216 has_mic,
217 },
218 AvMediaUpdate::Ended => Status::Ended,
219 AvMediaUpdate::Failed(e) => Status::Failed(e),
220 };
221 if let Ok(mut slot) = session_slot().lock() {
222 slot.status.push(status);
223 }
224 });
225
226 slot.video = Some(session.video.clone());
227 slot.session = Some(session);
228 slot.seen.clear();
229 slot.frame = None;
230 1
231 })
232}
233
234/// Leave the call: unpublish, drop the devices, forget the frames.
235///
236/// Waits briefly for the media task to tear MoQ down rather than aborting it
237/// outright. An abandoned broadcast lingers on the SFU and peers subscribe to
238/// it, so they see someone present and hear silence.
239#[no_mangle]
240pub extern "C" fn joltmoq_stop() {
Fill the slot, and do not wedge on the way out e86c31c nandi 20d ago241 // Take the session out from under the lock and let the lock go *before*
242 // anything waits on it.
243 //
244 // Waiting while holding it deadlocks, and did: the status callback locks
245 // this same slot from a tokio worker, so a task that still had an update
246 // to deliver could not finish, while the thread that would have released
247 // the lock was waiting for exactly that task to finish. The caller's UI
248 // thread is the one that hangs, which is every thread it has.
249 let session = with_slot(None, |slot| {
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago250 slot.video = None;
251 slot.seen.clear();
252 slot.frame = None;
Fill the slot, and do not wedge on the way out e86c31c nandi 20d ago253 slot.session.take()
254 });
255 let Some(mut session) = session else {
256 return;
257 };
258
259 // Not waited for either. Tearing MoQ down is a network round trip, and the
260 // press that asked for it was on the thread that paints — half a second of
261 // frozen window is not the answer to "leave". The teardown still runs, and
262 // still unpublishes properly: an abandoned broadcast lingers on the SFU and
263 // peers subscribe to it, seeing someone present who is silent.
264 match runtime() {
265 Some(runtime) => {
266 runtime.spawn(async move {
267 session
268 .stop_and_wait(std::time::Duration::from_secs(2))
269 .await;
270 });
271 }
272 // No runtime means no call ever started; nothing to unwind gracefully.
273 None => session.stop(),
274 }
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago275}
276
277/// 1 while a call is live on this side.
278#[no_mangle]
279pub extern "C" fn joltmoq_is_live() -> c_int {
280 with_slot(0, |slot| slot.session.is_some() as c_int)
281}
282
283// ── Controls ────────────────────────────────────────────────────────────────
284
285/// Mute or unmute the microphone. Peers stop hearing you; you still hear them.
286#[no_mangle]
287pub extern "C" fn joltmoq_set_muted(muted: c_int) {
288 with_slot((), |slot| {
289 if let Some(s) = &slot.session {
290 s.set_muted(muted != 0);
291 }
292 })
293}
294
295/// Mute or unmute remote audio — deafen. Deliberately not the same control as
296/// [`joltmoq_set_muted`]: peers still hear you if the microphone is open.
297#[no_mangle]
298pub extern "C" fn joltmoq_set_speaker_muted(muted: c_int) {
299 with_slot((), |slot| {
300 if let Some(s) = &slot.session {
301 s.set_speaker_muted(muted != 0);
302 }
303 })
304}
305
306/// Start or stop publishing camera. The device is only held while publishing,
307/// so turning it off gives the hardware back to the rest of the machine.
308#[no_mangle]
309pub extern "C" fn joltmoq_set_camera(enabled: c_int) {
310 with_slot((), |slot| {
311 if let Some(s) = &slot.session {
312 s.set_camera_enabled(enabled != 0);
313 }
314 })
315}
316
317/// Re-open the camera by id; an empty string means the first available.
318///
319/// # Safety
320/// `id` is null or a NUL-terminated UTF-8 string.
321#[no_mangle]
322pub unsafe extern "C" fn joltmoq_set_camera_device(id: *const c_char) {
323 let id = preference(borrowed(id));
324 with_slot((), |slot| {
325 if let Some(s) = &slot.session {
326 s.set_camera_device(id);
327 }
328 })
329}
330
331/// Switch microphone by name; an empty string means the system default.
332///
333/// # Safety
334/// `id` is null or a NUL-terminated UTF-8 string.
335#[no_mangle]
336pub unsafe extern "C" fn joltmoq_set_mic_device(id: *const c_char) {
337 let id = preference(borrowed(id));
338 with_slot((), |slot| {
339 if let Some(s) = &slot.session {
340 s.set_mic_device(id);
341 }
342 })
343}
344
345/// Switch speaker by name; an empty string means the system default.
346///
347/// # Safety
348/// `id` is null or a NUL-terminated UTF-8 string.
349#[no_mangle]
350pub unsafe extern "C" fn joltmoq_set_speaker_device(id: *const c_char) {
351 let id = preference(borrowed(id));
352 with_slot((), |slot| {
353 if let Some(s) = &slot.session {
354 s.set_speaker_device(id);
355 }
356 })
357}
358
359/// The live microphone envelope, 0.0 to 1.0 — what a level meter draws. 0 when
360/// no call is up.
361#[no_mangle]
362pub extern "C" fn joltmoq_mic_level() -> f64 {
363 with_slot(0.0, |slot| {
364 slot.session
365 .as_ref()
366 .map_or(0.0, |s| s.mic_level.get() as f64)
367 })
368}
369
370// ── Status ──────────────────────────────────────────────────────────────────
371
372/// Nothing waiting.
373pub const JOLTMOQ_STATUS_NONE: c_int = 0;
374/// Connected and publishing.
375pub const JOLTMOQ_STATUS_LIVE: c_int = 1;
376/// The session ended cleanly.
377pub const JOLTMOQ_STATUS_ENDED: c_int = 2;
378/// Connect or runtime failure; [`joltmoq_status_text`] says what.
379pub const JOLTMOQ_STATUS_FAILED: c_int = 3;
380
381/// Dequeue one status update, or 0 when there is none.
382///
383/// The media task produces these on its own threads; this hands them to the
384/// caller's. Drain it until it answers 0 wherever the caller polls — beside a
385/// repaint is the natural place. Missing an update means a call that is up
386/// still looks like it is connecting.
387#[no_mangle]
388pub extern "C" fn joltmoq_poll_status() -> c_int {
389 with_slot(JOLTMOQ_STATUS_NONE, |slot| {
390 if slot.status.is_empty() {
391 slot.current = None;
392 return JOLTMOQ_STATUS_NONE;
393 }
394 let status = slot.status.remove(0);
395 let code = match &status {
396 Status::Live { .. } => JOLTMOQ_STATUS_LIVE,
397 Status::Ended => JOLTMOQ_STATUS_ENDED,
398 Status::Failed(_) => JOLTMOQ_STATUS_FAILED,
399 };
400 slot.current = Some(status);
401 code
402 })
403}
404
405/// Why the last polled status failed; the empty string for any other status.
406///
407/// # Safety
408/// The returned pointer is valid until the next call to this function.
409#[no_mangle]
410pub extern "C" fn joltmoq_status_text() -> *const c_char {
411 let text = with_slot(String::new(), |slot| match &slot.current {
412 Some(Status::Failed(e)) => e.clone(),
413 _ => String::new(),
414 });
415 if text.is_empty() {
416 return empty_str();
417 }
418 STATUS_TEXT.lend(text)
419}
420
421/// 1 when the last polled `live` status found a capture device for this call.
422///
423/// A call can be live with no camera at all, so a caller shows a camera control
424/// only when there is something for it to turn on.
425#[no_mangle]
426pub extern "C" fn joltmoq_status_has_camera() -> c_int {
427 with_slot(0, |slot| match &slot.current {
428 Some(Status::Live { has_camera, .. }) => *has_camera as c_int,
429 _ => 0,
430 })
431}
432
433/// 1 when the last polled `live` status had a real microphone feeding the
434/// outbound track. 0 means listen-only: audio is still published, as silence.
435#[no_mangle]
436pub extern "C" fn joltmoq_status_has_mic() -> c_int {
437 with_slot(0, |slot| match &slot.current {
438 Some(Status::Live { has_mic, .. }) => *has_mic as c_int,
439 _ => 0,
440 })
441}
442
443// ── Video ───────────────────────────────────────────────────────────────────
444
445/// Advance to the next feed carrying a frame the caller has not been given,
446/// answering 1 while there was one.
447///
448/// Loop until it answers 0, reading [`joltmoq_frame_key`],
449/// [`joltmoq_frame_width`], [`joltmoq_frame_height`] and
450/// [`joltmoq_frame_rgba`] for each and handing those to whatever paints. Only
451/// what changed comes over: a participant sitting still costs nothing, and a
452/// decoder running ahead of the window is coalesced to its newest frame rather
453/// than queued behind stale ones.
454///
455/// The key is the participant's nick, or `__local__` for the self-view.
456#[no_mangle]
457pub extern "C" fn joltmoq_frame_poll() -> c_int {
458 with_slot(0, |slot| {
459 slot.frame = None;
460 let Some(video) = slot.video.clone() else {
461 return 0;
462 };
463 let snapshot = video.snapshot();
464 for (key, frame) in &snapshot {
465 if slot.seen.get(key) == Some(&frame.gen) {
466 continue;
467 }
468 slot.seen.insert(key.clone(), frame.gen);
469 slot.frame = Some((key.clone(), frame.clone()));
470 return 1;
471 }
472 // Nothing new. Forget the keys nobody is publishing any more, so that
473 // if they come back the first frame of the new stream reads as new
474 // rather than as one already seen.
475 slot.seen
476 .retain(|k, _| snapshot.iter().any(|(live, _)| live == k));
477 0
478 })
479}
480
481/// Whose picture the last polled frame is: a nick, or `__local__`.
482///
483/// # Safety
484/// The returned pointer is valid until the next call to this function.
485#[no_mangle]
486pub extern "C" fn joltmoq_frame_key() -> *const c_char {
487 let key = with_slot(String::new(), |slot| {
488 slot.frame
489 .as_ref()
490 .map_or(String::new(), |(k, _)| k.clone())
491 });
492 if key.is_empty() {
493 return empty_str();
494 }
495 FRAME_KEY.lend(key)
496}
497
498/// The last polled frame's width in pixels, or 0.
499#[no_mangle]
500pub extern "C" fn joltmoq_frame_width() -> c_int {
501 with_slot(0, |slot| {
502 slot.frame.as_ref().map_or(0, |(_, f)| f.width as c_int)
503 })
504}
505
506/// The last polled frame's height in pixels, or 0.
507#[no_mangle]
508pub extern "C" fn joltmoq_frame_height() -> c_int {
509 with_slot(0, |slot| {
510 slot.frame.as_ref().map_or(0, |(_, f)| f.height as c_int)
511 })
512}
513
514/// The last polled frame's pixels: `width * height * 4` bytes, row-major, 8
515/// bits a channel, un-premultiplied and opaque. Null when there is no frame.
516///
517/// **Borrowed, and only until the next [`joltmoq_frame_poll`].** That is the
518/// point of polling: a frame is a megabyte or two, and copying it into the
519/// caller's memory so it can hand it straight to a texture upload would be two
520/// copies a frame for nothing. Upload it, then poll again.
521///
522/// # Safety
523/// Valid for reads of `width * height * 4` bytes until the next call to
524/// [`joltmoq_frame_poll`] or [`joltmoq_stop`].
525#[no_mangle]
526pub extern "C" fn joltmoq_frame_rgba() -> *const u8 {
527 with_slot(std::ptr::null(), |slot| match &slot.frame {
528 Some((_, frame)) => frame.rgba.as_ptr(),
529 None => std::ptr::null(),
530 })
531}
532
533/// Everyone whose picture the call is currently carrying, one key a line.
534///
535/// A caller painting a tile per feed uses this to notice a tile it should stop
536/// painting: someone who left stops appearing here, while their last frame
537/// would otherwise hang on the wall for the rest of the call.
538///
539/// # Safety
540/// The returned pointer is valid until the next call to this function.
541#[no_mangle]
542pub extern "C" fn joltmoq_video_keys() -> *const c_char {
543 let keys = with_slot(String::new(), |slot| match &slot.video {
544 Some(video) => {
545 let mut keys: Vec<String> = video.snapshot().into_iter().map(|(k, _)| k).collect();
546 keys.sort();
547 keys.join("\n")
548 }
549 None => String::new(),
550 });
551 if keys.is_empty() {
552 return empty_str();
553 }
554 VIDEO_KEYS.lend(keys)
555}
556
557// ── Devices ─────────────────────────────────────────────────────────────────
558
559/// The cameras, one a line, each `id\tname\tdefault`.
560///
561/// Enumerating opens nothing, so this is safe to ask before a call and during
562/// one. Empty on a machine with no camera, which is a normal answer.
563///
564/// # Safety
565/// The returned pointer is valid until the next device-listing call.
566#[no_mangle]
567pub extern "C" fn joltmoq_cameras() -> *const c_char {
568 lend_devices(guard(Vec::new(), av_media::list_cameras))
569}
570
571/// The microphones, one a line, each `id\tname\tdefault`.
572///
573/// # Safety
574/// The returned pointer is valid until the next device-listing call.
575#[no_mangle]
576pub extern "C" fn joltmoq_microphones() -> *const c_char {
577 lend_devices(guard(Vec::new(), av_media::list_microphones))
578}
579
580/// The speakers, one a line, each `id\tname\tdefault`.
581///
582/// # Safety
583/// The returned pointer is valid until the next device-listing call.
584#[no_mangle]
585pub extern "C" fn joltmoq_speakers() -> *const c_char {
586 lend_devices(guard(Vec::new(), av_media::list_speakers))
587}
588
589fn lend_devices(list: Vec<av_media::MediaDevice>) -> *const c_char {
590 let rendered = devices(list);
591 if rendered.is_empty() {
592 return empty_str();
593 }
594 DEVICES.lend(rendered)
595}
596
597/// A device list as lines of `id\tname\tdefault`, where the last column is 1
598/// for the system default and 0 otherwise.
599///
600/// Tab and newline are the delimiters because a device name may contain
601/// anything else — "EMEET SmartCam C960, Mono" has spaces and a comma in it,
602/// and a caller splitting on those gets nonsense. A name that somehow holds a
603/// delimiter has it replaced rather than being allowed to forge a row.
604///
605/// The default is worth a column of its own: a picker that cannot mark it has
606/// to guess, and "Default" is not reliably the first entry or a name anyone
607/// can match on.
608fn devices(list: Vec<av_media::MediaDevice>) -> String {
609 list.into_iter()
610 .map(|d| {
611 format!(
612 "{}\t{}\t{}",
613 d.id.replace(['\t', '\n'], " "),
614 d.name.replace(['\t', '\n'], " "),
615 d.is_default as u8
616 )
617 })
618 .collect::<Vec<_>>()
619 .join("\n")
620}
621
622// ── Dialling ────────────────────────────────────────────────────────────────
623
624/// The SFU URL to hand [`joltmoq_start`], built from the IRC server the caller
625/// is connected to and the JWT the server minted in `+freeq.at/av-token`.
626///
627/// `server` is whatever the client knows the server as — `irc.freeq.at:6697`,
628/// `wss://irc.freeq.at/irc` — and both become `https://irc.freeq.at/av/moq`.
629/// `jwt` and `instance` may be empty. The empty string comes back when the
630/// server is not something a URL can be made of.
631///
632/// This lives here rather than in the caller because the rules are fiddly and
633/// already tested on this side: which scheme maps to which, where the port
634/// goes, and what to do with a path that was there.
635///
636/// # Safety
637/// Every pointer is null or a NUL-terminated UTF-8 string; the returned pointer
638/// is valid until the next call to this function.
639#[no_mangle]
640pub unsafe extern "C" fn joltmoq_sfu_url(
641 server: *const c_char,
642 jwt: *const c_char,
643 instance: *const c_char,
644) -> *const c_char {
645 let server = borrowed(server);
646 let jwt = preference(borrowed(jwt));
647 let instance = preference(borrowed(instance));
648 let url = guard(String::new(), || {
649 av::sfu_moq_dial_url(&server, jwt.as_deref(), instance.as_deref())
650 .map(|u| u.to_string())
651 .unwrap_or_default()
652 });
653 if url.is_empty() {
654 return empty_str();
655 }
656 DIAL.lend(url)
657}
658
659/// 1 when dialling this server is worth attempting.
660///
661/// A remote SFU with no token accepts the connection and closes it, and
662/// moq-lite then retries in a tight loop that looks, from the outside, exactly
663/// like a hang. Asking first is cheaper than explaining that.
664///
665/// # Safety
666/// Both pointers are null or NUL-terminated UTF-8 strings.
667#[no_mangle]
668pub unsafe extern "C" fn joltmoq_can_dial(server: *const c_char, jwt: *const c_char) -> c_int {
669 let server = borrowed(server);
670 let jwt = preference(borrowed(jwt));
671 guard(0, || av::can_dial_sfu(&server, jwt.as_deref()) as c_int)
672}
673
674/// A per-device call instance id — eight hex characters.
675///
676/// Two devices signed in as the same person need different ones, or their MoQ
677/// broadcast paths collide and each unpublishes the other. The caller puts this
678/// in its own `+freeq.at/av-instance` tag and hands the same one to
679/// [`joltmoq_start`].
680///
681/// # Safety
682/// The returned pointer is valid until the next call to this function.
683#[no_mangle]
684pub extern "C" fn joltmoq_new_instance() -> *const c_char {
685 DIAL.lend(guard("00000000".to_owned(), || {
686 format!("{:08x}", rand::random::<u32>())
687 }))
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use std::ffi::{CStr, CString};
694
695 fn read(ptr: *const c_char) -> String {
696 unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned()
697 }
698
699 #[test]
700 fn a_device_list_is_tab_and_newline_delimited() {
701 let list = vec![
702 av_media::MediaDevice {
703 id: "/dev/video0".to_owned(),
704 name: "EMEET SmartCam C960, Mono".to_owned(),
705 is_default: true,
706 },
707 av_media::MediaDevice {
708 id: "/dev/video2".to_owned(),
709 name: "Integrated".to_owned(),
710 is_default: false,
711 },
712 ];
713 assert_eq!(
714 devices(list),
715 "/dev/video0\tEMEET SmartCam C960, Mono\t1\n/dev/video2\tIntegrated\t0"
716 );
717 }
718
719 #[test]
720 fn a_name_holding_a_delimiter_cannot_forge_a_row() {
721 let list = vec![av_media::MediaDevice {
722 id: "a\tb".to_owned(),
723 name: "two\nlines".to_owned(),
724 is_default: false,
725 }];
726 let rendered = devices(list);
727 assert_eq!(rendered, "a b\ttwo lines\t0");
728 assert_eq!(rendered.lines().count(), 1);
729 }
730
731 #[test]
732 fn the_sfu_url_is_built_from_whatever_the_server_is_known_as() {
733 let jwt = CString::new("tok.jwt.value").unwrap();
734 let inst = CString::new("abcd1234").unwrap();
735 for known_as in ["irc.freeq.at:6697", "wss://irc.freeq.at/irc"] {
736 let server = CString::new(known_as).unwrap();
737 let url =
738 read(unsafe { joltmoq_sfu_url(server.as_ptr(), jwt.as_ptr(), inst.as_ptr()) });
739 assert!(url.starts_with("https://irc.freeq.at/av/moq"), "{url}");
740 assert!(url.contains("inst=abcd1234"), "{url}");
741 }
742 }
743
744 #[test]
745 fn a_server_that_is_not_a_url_answers_the_empty_string() {
746 let server = CString::new(" ").unwrap();
747 let url = read(unsafe {
748 joltmoq_sfu_url(server.as_ptr(), std::ptr::null(), std::ptr::null())
749 });
750 assert_eq!(url, "");
751 }
752
753 #[test]
754 fn a_remote_sfu_without_a_token_is_not_worth_dialling() {
755 let remote = CString::new("wss://chat.example.com").unwrap();
756 let local = CString::new("ws://localhost:4443").unwrap();
757 let jwt = CString::new("tok").unwrap();
758 assert_eq!(
759 unsafe { joltmoq_can_dial(remote.as_ptr(), std::ptr::null()) },
760 0
761 );
762 assert_eq!(unsafe { joltmoq_can_dial(remote.as_ptr(), jwt.as_ptr()) }, 1);
763 assert_eq!(
764 unsafe { joltmoq_can_dial(local.as_ptr(), std::ptr::null()) },
765 1
766 );
767 }
768
769 #[test]
770 fn an_instance_id_is_eight_hex_characters_and_differs() {
771 let one = read(joltmoq_new_instance());
772 let two = read(joltmoq_new_instance());
773 assert_eq!(one.len(), 8);
774 assert!(one.chars().all(|c| c.is_ascii_hexdigit()), "{one}");
775 assert_ne!(one, two, "two devices would collide on the SFU");
776 }
777
778 #[test]
779 fn nothing_is_live_before_a_call_and_every_poll_answers_empty() {
780 assert_eq!(joltmoq_is_live(), 0);
781 assert_eq!(joltmoq_poll_status(), JOLTMOQ_STATUS_NONE);
782 assert_eq!(read(joltmoq_status_text()), "");
783 assert_eq!(joltmoq_frame_poll(), 0);
784 assert!(joltmoq_frame_rgba().is_null());
785 assert_eq!(joltmoq_frame_width(), 0);
786 assert_eq!(read(joltmoq_frame_key()), "");
787 assert_eq!(read(joltmoq_video_keys()), "");
788 }
789
790 #[test]
791 fn a_control_with_no_call_under_it_is_inert() {
792 // A UI that sends a mute as the call is ending must not take the
793 // process with it.
794 joltmoq_set_muted(1);
795 joltmoq_set_speaker_muted(1);
796 joltmoq_set_camera(1);
797 unsafe { joltmoq_set_mic_device(std::ptr::null()) };
798 joltmoq_stop();
799 assert_eq!(joltmoq_mic_level(), 0.0);
800 assert_eq!(joltmoq_is_live(), 0);
801 }
802
803 #[test]
Fill the slot, and do not wedge on the way out e86c31c nandi 20d ago804 fn stopping_does_not_hold_the_lock_a_status_callback_needs() {
805 // The deadlock this guards: `stop` used to wait for the media task
806 // while holding the slot, and the task's status callback locks the
807 // slot to deliver an update — so neither could finish. Here a thread
808 // takes the slot the way that callback does, while the main thread
809 // stops. If stop waits under the lock, this never returns.
810 use std::sync::mpsc;
811 use std::time::Duration;
812
813 let (tx, rx) = mpsc::channel();
814 std::thread::spawn(move || {
815 for _ in 0..200 {
816 if let Ok(mut slot) = session_slot().lock() {
817 slot.status.push(Status::Ended);
818 slot.status.clear();
819 }
820 std::thread::sleep(Duration::from_millis(1));
821 }
822 let _ = tx.send(());
823 });
824
825 for _ in 0..50 {
826 joltmoq_stop();
827 }
828 assert_eq!(joltmoq_is_live(), 0);
829 rx.recv_timeout(Duration::from_secs(10))
830 .expect("the status thread never got the lock back");
831 }
832
833 #[test]
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago834 fn a_frame_poll_yields_each_feed_once_until_it_changes() {
835 let video = VideoFrameStore::new();
836 video.set("nandi", 1, 1, std::sync::Arc::from(vec![9u8; 4]));
837 if let Ok(mut slot) = session_slot().lock() {
838 slot.video = Some(video.clone());
839 slot.seen.clear();
840 }
841
842 assert_eq!(joltmoq_frame_poll(), 1);
843 assert_eq!(read(joltmoq_frame_key()), "nandi");
844 assert_eq!(joltmoq_frame_width(), 1);
845 // Same frame: nothing new to hand over.
846 assert_eq!(joltmoq_frame_poll(), 0);
847
848 // A new frame under the same key is new again.
849 video.set("nandi", 1, 1, std::sync::Arc::from(vec![3u8; 4]));
850 assert_eq!(joltmoq_frame_poll(), 1);
851 assert_eq!(read(joltmoq_frame_key()), "nandi");
852
853 assert_eq!(read(joltmoq_video_keys()), "nandi");
854
855 if let Ok(mut slot) = session_slot().lock() {
856 slot.video = None;
857 slot.seen.clear();
858 slot.frame = None;
859 }
860 }
861}