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