11#include " whip-output.h"
22#include " whip-utils.h"
33
4+ #include < regex>
45#include < obs.hpp>
56
67/*
@@ -34,6 +35,17 @@ WHIPOutput::WHIPOutput(obs_data_t *, obs_output_t *output)
3435 endpoint_url(),
3536 bearer_token(),
3637 resource_url(),
38+ ice_gathering_mutex(),
39+ ice_gathering_cv(),
40+ ice_gathering_complete(false ),
41+ has_first_candidate(false ),
42+ offer_sent(false ),
43+ has_ice_servers(false ),
44+ ice_ufrag(),
45+ ice_pwd(),
46+ pending_candidates(),
47+ pending_candidates_mutex(),
48+ post_response_gather_started(false ),
3749 running(false ),
3850 start_stop_mutex(),
3951 start_stop_thread(),
@@ -266,6 +278,67 @@ bool WHIPOutput::Init()
266278 return true ;
267279}
268280
281+ /* *
282+ * @brief Fetch ICE servers via OPTIONS request to WHIP endpoint.
283+ *
284+ * Per WHIP spec, the endpoint may provide STUN/TURN servers via Link headers
285+ * in response to an OPTIONS request. This allows ICE gathering to begin
286+ * before the offer is sent, enabling P2P connections behind NAT.
287+ *
288+ * @param iceServers Vector to populate with discovered ICE servers
289+ * @return bool True if request succeeded (even if no ICE servers found)
290+ */
291+ bool WHIPOutput::FetchIceServersViaOptions (std::vector<rtc::IceServer> &iceServers)
292+ {
293+ struct curl_slist *headers = nullptr ;
294+ headers = curl_slist_append (headers, " Accept: application/sdp" );
295+ headers = curl_slist_append (headers, user_agent.c_str ());
296+
297+ if (!bearer_token.empty ()) {
298+ auto bearer_token_header = std::string (" Authorization: Bearer " ) + bearer_token;
299+ headers = curl_slist_append (headers, bearer_token_header.c_str ());
300+ }
301+
302+ std::vector<std::string> http_headers;
303+
304+ CURL *c = curl_easy_init ();
305+ curl_easy_setopt (c, CURLOPT_HTTPHEADER, headers);
306+ curl_easy_setopt (c, CURLOPT_URL, endpoint_url.c_str ());
307+ curl_easy_setopt (c, CURLOPT_CUSTOMREQUEST, " OPTIONS" );
308+ curl_easy_setopt (c, CURLOPT_NOBODY, 1L );
309+ curl_easy_setopt (c, CURLOPT_TIMEOUT, 5L );
310+ curl_easy_setopt (c, CURLOPT_HEADERFUNCTION, curl_header_function);
311+ curl_easy_setopt (c, CURLOPT_HEADERDATA, (void *)&http_headers);
312+
313+ CURLcode res = curl_easy_perform (c);
314+ curl_easy_cleanup (c);
315+ curl_slist_free_all (headers);
316+
317+ if (res != CURLE_OK) {
318+ do_log (LOG_DEBUG, " OPTIONS request failed: %s (will proceed without pre-configured ICE servers)" ,
319+ curl_easy_strerror (res));
320+ return false ;
321+ }
322+
323+ for (auto &http_header : http_headers) {
324+ auto value = value_for_header (" link" , http_header);
325+ if (value.empty ())
326+ continue ;
327+
328+ for (auto end = value.find (" ," ); end != std::string::npos; end = value.find (" ," )) {
329+ this ->ParseLinkHeader (value.substr (0 , end), iceServers);
330+ value = value.substr (end + 1 );
331+ }
332+ this ->ParseLinkHeader (value, iceServers);
333+ }
334+
335+ if (!iceServers.empty ()) {
336+ do_log (LOG_INFO, " Discovered %zu ICE server(s) via OPTIONS request" , iceServers.size ());
337+ }
338+
339+ return true ;
340+ }
341+
269342/* *
270343 * @brief Set up the PeerConnection and media tracks.
271344 *
@@ -275,12 +348,82 @@ bool WHIPOutput::Setup()
275348{
276349 rtc::Configuration cfg;
277350
351+ // Fetch ICE servers via OPTIONS request (per WHIP spec section 4.4)
352+ std::vector<rtc::IceServer> iceServers;
353+ FetchIceServersViaOptions (iceServers);
354+ has_ice_servers = !iceServers.empty ();
355+ if (has_ice_servers) {
356+ cfg.iceServers = iceServers;
357+ }
358+
278359#if RTC_VERSION_MAJOR == 0 && RTC_VERSION_MINOR > 20 || RTC_VERSION_MAJOR > 0
279- cfg.disableAutoGathering = true ;
360+ // Enable auto-gathering if we have ICE servers from OPTIONS
361+ cfg.disableAutoGathering = iceServers.empty ();
280362#endif
281363
364+ ice_gathering_complete = false ;
365+ has_first_candidate = false ;
366+ offer_sent = false ;
367+ post_response_gather_started = false ;
368+ ice_ufrag.clear ();
369+ ice_pwd.clear ();
370+ first_mid.clear ();
371+ {
372+ std::lock_guard<std::mutex> lock (pending_candidates_mutex);
373+ pending_candidates.clear ();
374+ }
375+
282376 peer_connection = std::make_shared<rtc::PeerConnection>(cfg);
283377
378+ // Track when we receive our first ICE candidate
379+ peer_connection->onLocalCandidate ([this ](rtc::Candidate candidate) {
380+ {
381+ std::lock_guard<std::mutex> lock (ice_gathering_mutex);
382+ if (!has_first_candidate) {
383+ has_first_candidate = true ;
384+ first_mid = candidate.mid (); // Saved for end-of-candidates signal
385+ ice_gathering_cv.notify_one ();
386+ }
387+ }
388+ // If offer already sent, trickle this candidate immediately
389+ // Otherwise queue it - it will be sent after POST completes
390+ // Only trickle if we have ICE servers (trickle ICE not needed for host-only)
391+ if (has_ice_servers) {
392+ bool should_send = false ;
393+ // Lock to synchronize with the flush in Connect()
394+ // This prevents candidates from being lost between setting
395+ // offer_sent and flushing the queue
396+ {
397+ std::lock_guard<std::mutex> lock (pending_candidates_mutex);
398+ if (offer_sent) {
399+ should_send = true ;
400+ } else {
401+ pending_candidates.push_back (candidate);
402+ }
403+ }
404+ if (should_send) {
405+ SendTrickleCandidate (candidate);
406+ }
407+ }
408+ });
409+
410+ // Set up async ICE gathering completion notification
411+ peer_connection->onGatheringStateChange ([this ](rtc::PeerConnection::GatheringState state) {
412+ if (state == rtc::PeerConnection::GatheringState::Complete) {
413+ {
414+ std::lock_guard<std::mutex> lock (ice_gathering_mutex);
415+ ice_gathering_complete = true ;
416+ ice_gathering_cv.notify_one ();
417+ }
418+ // Only send end-of-candidates after the final (post-response) gather
419+ // completes, not after the pre-offer OPTIONS gather. This ensures
420+ // candidates from POST response ICE servers aren't ignored (RFC 8840).
421+ if (has_ice_servers && post_response_gather_started) {
422+ SendEndOfCandidates ();
423+ }
424+ }
425+ });
426+
284427 peer_connection->onStateChange ([this ](rtc::PeerConnection::State state) {
285428 switch (state) {
286429 case rtc::PeerConnection::State::New:
@@ -400,8 +543,34 @@ bool WHIPOutput::Connect()
400543 std::string read_buffer;
401544 std::vector<std::string> http_headers;
402545
546+ #if RTC_VERSION_MAJOR == 0 && RTC_VERSION_MINOR > 20 || RTC_VERSION_MAJOR > 0
547+ // Smart waiting: if we have ICE servers, wait for first candidate OR 150ms.
548+ // This gets us at least host candidates quickly, and likely some STUN
549+ // candidates too. Any candidates gathered after offer is sent will be
550+ // trickled via PATCH.
551+ if (has_ice_servers) {
552+ std::unique_lock<std::mutex> lock (ice_gathering_mutex);
553+ if (!has_first_candidate) {
554+ // 150ms balances latency vs. candidate coverage; typically enough for host + STUN
555+ auto timeout = std::chrono::milliseconds (150 );
556+ ice_gathering_cv.wait_for (lock, timeout, [this ] { return has_first_candidate.load (); });
557+ }
558+ }
559+ #endif
560+
403561 auto offer_sdp = std::string (peer_connection->localDescription ().value ());
404562
563+ // Extract ICE credentials for trickle PATCH requests
564+ std::regex re_ufrag (" a=ice-ufrag:([^\\ r\\ n]+)" );
565+ std::regex re_pwd (" a=ice-pwd:([^\\ r\\ n]+)" );
566+ std::smatch match;
567+ if (std::regex_search (offer_sdp, match, re_ufrag)) {
568+ ice_ufrag = match[1 ];
569+ }
570+ if (std::regex_search (offer_sdp, match, re_pwd)) {
571+ ice_pwd = match[1 ];
572+ }
573+
405574#ifdef DEBUG_SDP
406575 do_log (LOG_DEBUG, " Offer SDP:\n %s" , offer_sdp.c_str ());
407576#endif
@@ -531,6 +700,23 @@ bool WHIPOutput::Connect()
531700 do_log (LOG_DEBUG, " WHIP Resource URL is: %s" , resource_url.c_str ());
532701 curl_url_cleanup (url_builder);
533702
703+ // Flush any candidates that arrived during the POST request (trickle ICE only)
704+ // Set offer_sent inside the lock to prevent race with onLocalCandidate callback
705+ if (has_ice_servers) {
706+ std::vector<rtc::Candidate> candidates_to_send;
707+ {
708+ std::lock_guard<std::mutex> lock (pending_candidates_mutex);
709+ offer_sent = true ;
710+ candidates_to_send = std::move (pending_candidates);
711+ pending_candidates.clear ();
712+ }
713+ for (const auto &candidate : candidates_to_send) {
714+ SendTrickleCandidate (candidate);
715+ }
716+ } else {
717+ offer_sent = true ;
718+ }
719+
534720#ifdef DEBUG_SDP
535721 do_log (LOG_DEBUG, " Answer SDP:\n %s" , read_buffer.c_str ());
536722#endif
@@ -574,6 +760,11 @@ bool WHIPOutput::Connect()
574760 doCleanup (false );
575761
576762#if RTC_VERSION_MAJOR == 0 && RTC_VERSION_MINOR > 20 || RTC_VERSION_MAJOR > 0
763+ // Always gather with POST response servers to:
764+ // 1. Get host candidates even if no ICE servers provided
765+ // 2. Incorporate any TURN servers/credentials from the POST response
766+ // Mark that this is the final gather - end-of-candidates will be sent when complete
767+ post_response_gather_started = true ;
577768 peer_connection->gatherLocalCandidates (iceServers);
578769#endif
579770
@@ -718,6 +909,90 @@ void WHIPOutput::Send(void *data, uintptr_t size, uint64_t duration, std::shared
718909 }
719910}
720911
912+ void WHIPOutput::SendTrickleIcePatch (const std::string &sdp_frag)
913+ {
914+ struct curl_slist *headers = NULL ;
915+ headers = curl_slist_append (headers, " Content-Type: application/trickle-ice-sdpfrag" );
916+ if (!bearer_token.empty ()) {
917+ auto bearer_token_header = std::string (" Authorization: Bearer " ) + bearer_token;
918+ headers = curl_slist_append (headers, bearer_token_header.c_str ());
919+ }
920+ headers = curl_slist_append (headers, user_agent.c_str ());
921+
922+ char error_buffer[CURL_ERROR_SIZE] = {};
923+
924+ CURL *c = curl_easy_init ();
925+ curl_easy_setopt (c, CURLOPT_HTTPHEADER, headers);
926+ curl_easy_setopt (c, CURLOPT_URL, resource_url.c_str ());
927+ curl_easy_setopt (c, CURLOPT_CUSTOMREQUEST, " PATCH" );
928+ curl_easy_setopt (c, CURLOPT_COPYPOSTFIELDS, sdp_frag.c_str ());
929+ curl_easy_setopt (c, CURLOPT_TIMEOUT, 8L );
930+ curl_easy_setopt (c, CURLOPT_FOLLOWLOCATION, 1L );
931+ curl_easy_setopt (c, CURLOPT_UNRESTRICTED_AUTH, 1L );
932+ curl_easy_setopt (c, CURLOPT_ERRORBUFFER, error_buffer);
933+
934+ CURLcode res = curl_easy_perform (c);
935+ if (res != CURLE_OK) {
936+ do_log (LOG_WARNING, " Trickle ICE PATCH failed: %s" ,
937+ error_buffer[0 ] ? error_buffer : curl_easy_strerror (res));
938+ } else {
939+ long response_code = 0 ;
940+ curl_easy_getinfo (c, CURLINFO_RESPONSE_CODE, &response_code);
941+ if (response_code < 200 || response_code >= 300 ) {
942+ do_log (LOG_WARNING, " Trickle ICE PATCH returned HTTP %ld" , response_code);
943+ }
944+ }
945+
946+ curl_easy_cleanup (c);
947+ curl_slist_free_all (headers);
948+ }
949+
950+ void WHIPOutput::SendTrickleCandidate (const rtc::Candidate &candidate)
951+ {
952+ // Guard: credentials not yet extracted from offer SDP
953+ if (resource_url.empty () || ice_ufrag.empty () || ice_pwd.empty ()) {
954+ return ;
955+ }
956+
957+ // Build SDP fragment with single candidate (RFC 8840 compliant)
958+ std::string sdp_frag;
959+ sdp_frag.append (" a=ice-ufrag:" + ice_ufrag + " \r\n " );
960+ sdp_frag.append (" a=ice-pwd:" + ice_pwd + " \r\n " );
961+ std::string mid = candidate.mid ();
962+ if (!mid.empty ()) {
963+ sdp_frag.append (" a=mid:" + mid + " \r\n " );
964+ }
965+ sdp_frag.append (" a=" + candidate.candidate () + " \r\n " );
966+
967+ do_log (LOG_DEBUG, " Trickle ICE candidate (mid=%s): %s" , mid.c_str (), candidate.candidate ().c_str ());
968+ SendTrickleIcePatch (sdp_frag);
969+ }
970+
971+ void WHIPOutput::SendEndOfCandidates ()
972+ {
973+ // Guard: credentials not yet extracted from offer SDP
974+ if (resource_url.empty () || ice_ufrag.empty () || ice_pwd.empty ()) {
975+ return ;
976+ }
977+
978+ // Build SDP fragment with end-of-candidates marker (RFC 8840 compliant)
979+ std::string sdp_frag;
980+ sdp_frag.append (" a=ice-ufrag:" + ice_ufrag + " \r\n " );
981+ sdp_frag.append (" a=ice-pwd:" + ice_pwd + " \r\n " );
982+ std::string mid;
983+ {
984+ std::lock_guard<std::mutex> lock (ice_gathering_mutex);
985+ mid = first_mid;
986+ }
987+ if (!mid.empty ()) {
988+ sdp_frag.append (" a=mid:" + mid + " \r\n " );
989+ }
990+ sdp_frag.append (" a=end-of-candidates\r\n " );
991+
992+ do_log (LOG_DEBUG, " Sending end-of-candidates" );
993+ SendTrickleIcePatch (sdp_frag);
994+ }
995+
721996void register_whip_output ()
722997{
723998 const uint32_t base_flags = OBS_OUTPUT_ENCODED | OBS_OUTPUT_SERVICE | OBS_OUTPUT_MULTI_TRACK_AV;
0 commit comments