mirror of
https://github.com/JBuildr/nubix.git
synced 2026-10-17 13:00:49 +02:00
491 lines
20 KiB
C++
491 lines
20 KiB
C++
// Nubix — xCloud wire protocol helpers.
|
|
//
|
|
// Input packet layout, control/message JSON and the /sdp + /ice bodies are ported from
|
|
// green-nx (GPL-3.0): src/core/xcloud_protocol.cpp and
|
|
// src/core/session.cpp. The offer template combines GreenOvercast's buildCompatibleOffer
|
|
// (src/session/webrtc_session.zig, MPL-2.0; session header + data-channel m-line, proven with
|
|
// libdatachannel) with green-nx's media m-lines (deps/patches/libpeer-switch.patch,
|
|
// sdp_append_h264/sdp_append_opus + session.cpp sdp_scale_video_caps_1080).
|
|
// Portions derived from green-nx (https://github.com/rmrf404/green-nx), Copyright (C) the green-nx authors, GPL-3.0; modified by Nubix contributors, 2026.
|
|
// Portions derived from GreenOvercast (https://github.com/Producdevity/GreenOvercast), MPL-2.0, used here under GPL-3.0 (MPL-2.0 section 3.3).
|
|
// Copyright (C) 2026 Nubix contributors
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
#include "core/protocol.hpp"
|
|
|
|
#include <arpa/inet.h>
|
|
#include <netinet/in.h>
|
|
#include <sys/socket.h>
|
|
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <mutex>
|
|
#include <random>
|
|
#include <sstream>
|
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
namespace xc {
|
|
namespace proto {
|
|
|
|
using nlohmann::json;
|
|
|
|
// Serialize without ever throwing: invalid UTF-8 (e.g. from a hostile server payload echoed
|
|
// back) is replaced with U+FFFD instead of raising json::type_error.
|
|
constexpr json::error_handler_t kReplace = json::error_handler_t::replace;
|
|
|
|
namespace {
|
|
|
|
// ---- little-endian writers (explicit byte order, independent of host endianness) ----------
|
|
|
|
void putU8(std::vector<uint8_t>& out, uint8_t v) { out.push_back(v); }
|
|
|
|
void putU16(std::vector<uint8_t>& out, uint16_t v) {
|
|
out.push_back(uint8_t(v & 0xFF));
|
|
out.push_back(uint8_t(v >> 8));
|
|
}
|
|
|
|
void putU32(std::vector<uint8_t>& out, uint32_t v) {
|
|
for (int i = 0; i < 4; ++i) out.push_back(uint8_t((v >> (8 * i)) & 0xFF));
|
|
}
|
|
|
|
void putU32BE(std::vector<uint8_t>& out, uint32_t v) {
|
|
for (int i = 3; i >= 0; --i) out.push_back(uint8_t((v >> (8 * i)) & 0xFF));
|
|
}
|
|
|
|
void putF64(std::vector<uint8_t>& out, double v) {
|
|
uint64_t bits;
|
|
static_assert(sizeof(bits) == sizeof(v), "double must be 64-bit");
|
|
std::memcpy(&bits, &v, sizeof(bits));
|
|
for (int i = 0; i < 8; ++i) out.push_back(uint8_t((bits >> (8 * i)) & 0xFF));
|
|
}
|
|
|
|
uint16_t getU16(const uint8_t* p) { return uint16_t(p[0] | (uint16_t(p[1]) << 8)); }
|
|
|
|
uint32_t getU32(const uint8_t* p) {
|
|
return uint32_t(p[0]) | (uint32_t(p[1]) << 8) | (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24);
|
|
}
|
|
|
|
// ---- string helpers -----------------------------------------------------------------------
|
|
|
|
std::string trimmed(const std::string& s) {
|
|
size_t b = 0, e = s.size();
|
|
while (b < e && (s[b] == ' ' || s[b] == '\t' || s[b] == '\r' || s[b] == '\n')) ++b;
|
|
while (e > b && (s[e - 1] == ' ' || s[e - 1] == '\t' || s[e - 1] == '\r' || s[e - 1] == '\n')) --e;
|
|
return s.substr(b, e - b);
|
|
}
|
|
|
|
std::vector<std::string> splitWs(const std::string& s) {
|
|
std::istringstream is(s);
|
|
std::vector<std::string> out;
|
|
std::string tok;
|
|
while (is >> tok) out.push_back(tok);
|
|
return out;
|
|
}
|
|
|
|
bool startsWith(const std::string& s, const char* prefix) { return s.rfind(prefix, 0) == 0; }
|
|
|
|
bool iequals(const std::string& a, const char* b) {
|
|
size_t n = std::strlen(b);
|
|
if (a.size() != n) return false;
|
|
for (size_t i = 0; i < n; ++i) {
|
|
char x = a[i], y = b[i];
|
|
if (x >= 'A' && x <= 'Z') x = char(x - 'A' + 'a');
|
|
if (y >= 'A' && y <= 'Z') y = char(y - 'A' + 'a');
|
|
if (x != y) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// ============================================================================================
|
|
// Input channel
|
|
// ============================================================================================
|
|
|
|
InputSerializer::InputSerializer() : start_(std::chrono::steady_clock::now()) {}
|
|
|
|
void InputSerializer::reset() {
|
|
seq_ = 0;
|
|
start_ = std::chrono::steady_clock::now();
|
|
}
|
|
|
|
double InputSerializer::elapsedMs() const {
|
|
auto d = std::chrono::steady_clock::now() - start_;
|
|
double ms = std::chrono::duration<double, std::milli>(d).count();
|
|
return ms < 0.0 ? 0.0 : ms;
|
|
}
|
|
|
|
std::vector<uint8_t> InputSerializer::clientMetadata(uint8_t maxTouch) {
|
|
std::vector<uint8_t> out;
|
|
out.reserve(kClientMetadataPacketSize);
|
|
putU16(out, report::ClientMetadata);
|
|
putU32(out, ++seq_);
|
|
putF64(out, elapsedMs());
|
|
putU8(out, maxTouch);
|
|
return out;
|
|
}
|
|
|
|
std::vector<uint8_t> InputSerializer::gamepad(const GamepadState& st, uint8_t index) {
|
|
std::vector<uint8_t> out;
|
|
out.reserve(kGamepadPacketSize);
|
|
// 14-byte header
|
|
putU16(out, report::Gamepad);
|
|
putU32(out, ++seq_);
|
|
putF64(out, elapsedMs());
|
|
// Gamepad section: u8 count, then one 23-byte frame (offsets relative to the frame start).
|
|
putU8(out, 1);
|
|
putU8(out, index); // +0 gamepadIndex
|
|
putU16(out, st.buttons); // +1 buttons
|
|
putU16(out, uint16_t(st.lx)); // +3 LeftThumbX
|
|
putU16(out, uint16_t(st.ly)); // +5 LeftThumbY (Xbox convention, up = +)
|
|
putU16(out, uint16_t(st.rx)); // +7 RightThumbX
|
|
putU16(out, uint16_t(st.ry)); // +9 RightThumbY
|
|
putU16(out, st.lt); // +11 LeftTrigger
|
|
putU16(out, st.rt); // +13 RightTrigger
|
|
putU32(out, 1); // +15 PhysicalPhysicality, little-endian 1
|
|
putU32BE(out, 1); // +19 VirtualPhysicality, big-endian 1 (packet.ts setUint32(.., 1, false))
|
|
return out;
|
|
}
|
|
|
|
void InputSerializer::rollback() {
|
|
if (seq_ > 0) --seq_;
|
|
}
|
|
|
|
// ============================================================================================
|
|
// Control / message channel JSON
|
|
// ============================================================================================
|
|
|
|
std::string newUuid() {
|
|
static std::mutex m;
|
|
static std::mt19937_64 rng = [] {
|
|
std::vector<uint32_t> seed;
|
|
try {
|
|
std::random_device rd; // arc4random on FreeBSD/PS5 libc++, /dev/urandom elsewhere
|
|
for (int i = 0; i < 8; ++i) seed.push_back(rd());
|
|
} catch (...) {
|
|
// no entropy source: fall back to clocks + addresses (uniqueness, not secrecy, matters)
|
|
}
|
|
const auto now = uint64_t(std::chrono::steady_clock::now().time_since_epoch().count());
|
|
const auto wall = uint64_t(std::chrono::system_clock::now().time_since_epoch().count());
|
|
const auto addr = uint64_t(reinterpret_cast<uintptr_t>(&seed));
|
|
for (uint64_t v : {now, wall, addr}) {
|
|
seed.push_back(uint32_t(v));
|
|
seed.push_back(uint32_t(v >> 32));
|
|
}
|
|
std::seed_seq seq(seed.begin(), seed.end());
|
|
return std::mt19937_64(seq);
|
|
}();
|
|
uint8_t b[16];
|
|
{
|
|
std::lock_guard<std::mutex> lock(m);
|
|
uint64_t hi = rng(), lo = rng();
|
|
for (int i = 0; i < 8; ++i) {
|
|
b[i] = uint8_t(hi >> (8 * i));
|
|
b[8 + i] = uint8_t(lo >> (8 * i));
|
|
}
|
|
}
|
|
b[6] = uint8_t((b[6] & 0x0F) | 0x40); // version 4
|
|
b[8] = uint8_t((b[8] & 0x3F) | 0x80); // RFC 4122 variant
|
|
char s[37];
|
|
std::snprintf(s, sizeof(s), "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", b[0], b[1],
|
|
b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]);
|
|
return std::string(s);
|
|
}
|
|
|
|
std::string messageHandshake() {
|
|
return json{{"type", "Handshake"}, {"version", "messageV1"}, {"id", newUuid()}, {"cv", "0"}}.dump(-1, ' ', false, kReplace);
|
|
}
|
|
|
|
std::string controlAuthorization() {
|
|
return json{{"message", "authorizationRequest"}, {"accessKey", kControlAccessKey}}.dump(-1, ' ', false, kReplace);
|
|
}
|
|
|
|
std::string controlGamepadChanged(int idx, bool added) {
|
|
return json{{"message", "gamepadChanged"}, {"gamepadIndex", idx}, {"wasAdded", added}}.dump(-1, ' ', false, kReplace);
|
|
}
|
|
|
|
std::string controlResolution(const std::string& alias) {
|
|
return json{{"message", "userRequestedResolutionUpdate"}, {"resolutionAlias", alias}}.dump(-1, ' ', false, kReplace);
|
|
}
|
|
|
|
std::string controlKeyframeRequest() {
|
|
return json{{"message", "videoKeyframeRequested"}, {"ifrRequested", true}}.dump(-1, ' ', false, kReplace);
|
|
}
|
|
|
|
std::string messageEnvelope(const std::string& target, const std::string& contentJson) {
|
|
return json{{"type", "Message"}, {"content", contentJson}, {"id", newUuid()}, {"target", target}, {"cv", ""}}
|
|
.dump(-1, ' ', false, kReplace);
|
|
}
|
|
|
|
std::vector<std::string> messageInitSequence(const Settings& s, const std::string& installId, int w, int h) {
|
|
const int fps = 60;
|
|
const int kbps = s.bitrateKbps > 0 ? s.bitrateKbps : 12000;
|
|
const std::string install = installId.empty() ? newUuid() : installId;
|
|
std::vector<std::string> out;
|
|
out.push_back(messageEnvelope("/streaming/systemUi/configuration",
|
|
json{{"version", {0, 2, 0}}, {"systemUis", json::array()}}.dump(-1, ' ', false, kReplace)));
|
|
out.push_back(messageEnvelope("/streaming/properties/clientappinstallidchanged",
|
|
json{{"clientAppInstallId", install}}.dump(-1, ' ', false, kReplace)));
|
|
out.push_back(messageEnvelope("/streaming/characteristics/orientationchanged", json{{"orientation", 0}}.dump(-1, ' ', false, kReplace)));
|
|
out.push_back(messageEnvelope("/streaming/characteristics/touchinputenabledchanged",
|
|
json{{"touchInputEnabled", false}}.dump(-1, ' ', false, kReplace)));
|
|
out.push_back(messageEnvelope("/streaming/characteristics/clientdevicecapabilities",
|
|
json{{"supportsCustomResolution", true},
|
|
{"supportsHevc", false},
|
|
{"supportsHdr", false},
|
|
{"supportsFps", fps},
|
|
{"maxWidth", w},
|
|
{"maxHeight", h},
|
|
{"maxBitrateKbps", kbps},
|
|
{"video",
|
|
{{"width", w},
|
|
{"height", h},
|
|
{"maxWidth", w},
|
|
{"maxHeight", h},
|
|
{"maxBitrateKbps", kbps}}}}
|
|
.dump(-1, ' ', false, kReplace)));
|
|
out.push_back(messageEnvelope("/streaming/characteristics/dimensionschanged",
|
|
json{{"horizontal", w},
|
|
{"vertical", h},
|
|
{"preferredWidth", w},
|
|
{"preferredHeight", h},
|
|
{"safeAreaLeft", 0},
|
|
{"safeAreaTop", 0},
|
|
{"safeAreaRight", w},
|
|
{"safeAreaBottom", h},
|
|
{"supportsCustomResolution", true}}
|
|
.dump(-1, ' ', false, kReplace)));
|
|
return out;
|
|
}
|
|
|
|
std::string transactionComplete(const std::string& id, const std::string& contentJson, const std::string& cv) {
|
|
return json{{"type", "TransactionComplete"}, {"id", id}, {"content", contentJson}, {"cv", cv}}.dump(-1, ' ', false, kReplace);
|
|
}
|
|
|
|
std::string transactionComplete(const std::string& id, const std::string& contentJson) {
|
|
return transactionComplete(id, contentJson, "");
|
|
}
|
|
|
|
std::string messageUnhandled(const std::string& id, const std::string& target, const std::string& cv) {
|
|
return json{{"type", "Unhandled"}, {"id", id}, {"target", target}, {"cv", cv}}.dump(-1, ' ', false, kReplace);
|
|
}
|
|
|
|
bool parseChannelMessage(const std::string& text, ChannelMessage& out) {
|
|
out = ChannelMessage{};
|
|
json j = json::parse(text, nullptr, false);
|
|
if (j.is_discarded() || !j.is_object()) return false;
|
|
auto it = j.find("type");
|
|
if (it == j.end() || !it->is_string()) return false;
|
|
out.type = it->get<std::string>();
|
|
auto str = [&](const char* key) -> std::string {
|
|
auto f = j.find(key);
|
|
if (f == j.end() || f->is_null()) return std::string();
|
|
if (f->is_string()) return f->get<std::string>();
|
|
return f->dump();
|
|
};
|
|
out.id = str("id");
|
|
out.target = str("target");
|
|
out.content = str("content");
|
|
out.cv = str("cv");
|
|
return true;
|
|
}
|
|
|
|
bool isHandshakeAck(const std::string& text) {
|
|
ChannelMessage m;
|
|
return parseChannelMessage(text, m) && m.type == "HandshakeAck";
|
|
}
|
|
|
|
// ============================================================================================
|
|
// Server -> client input packets
|
|
// ============================================================================================
|
|
|
|
bool parseVibration(const uint8_t* d, size_t n, Vibration& out) {
|
|
if (!d || n < 2) return false;
|
|
const uint16_t type = getU16(d);
|
|
if (!(type & report::Vibration)) return false;
|
|
size_t off = 2;
|
|
if (type & report::ServerMetadata) off += 8; // sections follow in bit order
|
|
if (n < off + 11) return false;
|
|
const uint8_t* p = d + off;
|
|
// p[0] = rumbleType (0 = FourMotorRumble), ignored like every reference client.
|
|
out.pad = p[1];
|
|
out.left = p[2];
|
|
out.right = p[3];
|
|
out.lt = p[4];
|
|
out.rt = p[5];
|
|
out.durationMs = getU16(p + 6);
|
|
out.delayMs = getU16(p + 8);
|
|
out.repeat = p[10];
|
|
return true;
|
|
}
|
|
|
|
bool parseServerMetadata(const uint8_t* d, size_t n, uint32_t& width, uint32_t& height) {
|
|
width = height = 0;
|
|
if (!d || n < 10) return false;
|
|
if (!(getU16(d) & report::ServerMetadata)) return false;
|
|
height = getU32(d + 2); // height first
|
|
width = getU32(d + 6);
|
|
return true;
|
|
}
|
|
|
|
// ============================================================================================
|
|
// Teredo
|
|
// ============================================================================================
|
|
|
|
bool teredoDecode(const std::string& ipv6, std::string& ipv4, uint16_t& port) {
|
|
ipv4.clear();
|
|
port = 0;
|
|
std::string addr = trimmed(ipv6);
|
|
if (!addr.empty() && addr.front() == '[' && addr.back() == ']') addr = addr.substr(1, addr.size() - 2);
|
|
size_t pct = addr.find('%');
|
|
if (pct != std::string::npos) addr.resize(pct);
|
|
if (addr.find(':') == std::string::npos) return false;
|
|
struct in6_addr a;
|
|
if (inet_pton(AF_INET6, addr.c_str(), &a) != 1) return false;
|
|
const uint8_t* b = reinterpret_cast<const uint8_t*>(&a);
|
|
// Teredo prefix 2001:0000::/32
|
|
if (b[0] != 0x20 || b[1] != 0x01 || b[2] != 0x00 || b[3] != 0x00) return false;
|
|
port = uint16_t(((b[10] ^ 0xFF) << 8) | (b[11] ^ 0xFF));
|
|
char buf[16];
|
|
std::snprintf(buf, sizeof(buf), "%u.%u.%u.%u", unsigned(b[12] ^ 0xFF), unsigned(b[13] ^ 0xFF),
|
|
unsigned(b[14] ^ 0xFF), unsigned(b[15] ^ 0xFF));
|
|
ipv4 = buf;
|
|
return true;
|
|
}
|
|
|
|
std::vector<std::string> expandTeredoCandidates(const std::vector<std::string>& candidates) {
|
|
std::vector<std::string> out;
|
|
out.reserve(candidates.size() * 3);
|
|
int foundation = 20; // synthetic foundations, as in green-nx receive_ice_candidates
|
|
for (const std::string& line : candidates) {
|
|
std::string body = trimmed(line);
|
|
const bool hasA = startsWith(body, "a=");
|
|
if (hasA) body = body.substr(2);
|
|
if (!startsWith(body, "candidate:")) {
|
|
out.push_back(line);
|
|
continue;
|
|
}
|
|
// candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> ...
|
|
std::vector<std::string> f = splitWs(body);
|
|
std::string ipv4;
|
|
uint16_t port = 0;
|
|
if (f.size() >= 8 && iequals(f[2], "udp") && teredoDecode(f[4], ipv4, port)) {
|
|
const std::string prefix = hasA ? "a=candidate:" : "candidate:";
|
|
for (uint16_t p : {port, uint16_t(9002)}) {
|
|
out.push_back(prefix + std::to_string(foundation++) + " " + f[1] + " UDP 1 " + ipv4 + " " +
|
|
std::to_string(p) + " typ host");
|
|
}
|
|
}
|
|
out.push_back(line);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ============================================================================================
|
|
// SDP offer + signalling bodies
|
|
// ============================================================================================
|
|
|
|
std::string buildOffer(const std::string& ufrag, const std::string& pwd, const std::string& fingerprint, bool home,
|
|
const std::string& resolution) {
|
|
const std::string u = trimmed(ufrag), p = trimmed(pwd);
|
|
std::string fp = trimmed(fingerprint);
|
|
if (startsWith(fp, "sha-256 ")) fp = trimmed(fp.substr(8));
|
|
if (u.empty() || p.empty() || fp.empty()) return std::string();
|
|
|
|
// Video caps: green-nx ships the 720p template verbatim for the 720 tier and for xHome
|
|
// (where the console agent also wants H.264 level 3.2, 42e020); 1080 tiers declare
|
|
// 1920x1080@60 decode capability (8160 MBs, 489600 MB/s).
|
|
const bool is720 = startsWith(resolution, "720");
|
|
const char* profile = home ? "42e020" : "42e01f";
|
|
const char* caps = (home || is720) ? "max-fs=3600;max-mbps=108000" : "max-fs=8160;max-mbps=489600";
|
|
|
|
std::string s;
|
|
s.reserve(1600);
|
|
auto L = [&s](const std::string& line) {
|
|
s += line;
|
|
s += "\r\n";
|
|
};
|
|
L("v=0");
|
|
L("o=- 4611731400430051 2 IN IP4 127.0.0.1");
|
|
L("s=-");
|
|
L("t=0 0");
|
|
L("a=group:BUNDLE video audio 0");
|
|
L("a=ice-ufrag:" + u);
|
|
L("a=ice-pwd:" + p);
|
|
L("a=fingerprint:sha-256 " + fp);
|
|
L("a=setup:actpass");
|
|
// video (green-nx sdp_append_h264)
|
|
L("m=video 9 UDP/TLS/RTP/SAVPF 102");
|
|
L("c=IN IP4 0.0.0.0");
|
|
L("a=mid:video");
|
|
L("a=rtcp-mux");
|
|
L("a=rtcp-rsize");
|
|
L("a=rtpmap:102 H264/90000");
|
|
L(std::string("a=fmtp:102 level-asymmetry-allowed=0;packetization-mode=1;profile-level-id=") + profile + ";" +
|
|
caps);
|
|
L("a=rtcp-fb:102 goog-remb");
|
|
L("a=rtcp-fb:102 ccm fir");
|
|
L("a=rtcp-fb:102 nack");
|
|
L("a=rtcp-fb:102 nack pli");
|
|
L("a=recvonly");
|
|
// audio (green-nx sdp_append_opus)
|
|
L("m=audio 9 UDP/TLS/RTP/SAVPF 111");
|
|
L("c=IN IP4 0.0.0.0");
|
|
L("a=mid:audio");
|
|
L("a=rtcp-mux");
|
|
L("a=rtcp-rsize");
|
|
L("a=rtpmap:111 opus/48000/2");
|
|
L("a=fmtp:111 minptime=10;useinbandfec=1;stereo=1");
|
|
L("a=recvonly");
|
|
// data channels (GreenOvercast; libdatachannel assigns the SCTP m-line mid "0")
|
|
L("m=application 9 UDP/DTLS/SCTP webrtc-datachannel");
|
|
L("c=IN IP4 0.0.0.0");
|
|
L("a=mid:0");
|
|
L("a=sctp-port:5000");
|
|
L("a=max-message-size:262144");
|
|
return s;
|
|
}
|
|
|
|
std::string sdpPostBody(const std::string& offer) {
|
|
json body = {
|
|
{"messageType", "offer"},
|
|
{"sdp", offer},
|
|
{"requestId", "1"},
|
|
{"configuration",
|
|
{{"chatConfiguration",
|
|
{{"bytesPerSample", 2},
|
|
{"expectedClipDurationMs", 20},
|
|
{"format", {{"codec", "opus"}, {"container", "webm"}}},
|
|
{"numChannels", 1},
|
|
{"sampleFrequencyHz", 24000}}},
|
|
{"chat", {{"minVersion", 1}, {"maxVersion", 1}}},
|
|
{"control", {{"minVersion", 1}, {"maxVersion", 3}}},
|
|
{"input", {{"minVersion", 1}, {"maxVersion", 9}}},
|
|
{"message", {{"minVersion", 1}, {"maxVersion", 1}}},
|
|
{"reliableinput", {{"minVersion", 9}, {"maxVersion", 9}}},
|
|
{"unreliableinput", {{"minVersion", 9}, {"maxVersion", 9}}}}},
|
|
};
|
|
return body.dump(-1, ' ', false, kReplace);
|
|
}
|
|
|
|
std::string icePostBody(const std::vector<std::string>& candidates, const std::string& ufrag) {
|
|
auto entry = [&ufrag](const std::string& cand) {
|
|
return json{{"candidate", cand}, {"sdpMid", "0"}, {"sdpMLineIndex", 0}, {"usernameFragment", ufrag}}.dump(-1, ' ', false, kReplace);
|
|
};
|
|
json list = json::array();
|
|
for (const std::string& raw : candidates) {
|
|
std::string c = trimmed(raw);
|
|
if (startsWith(c, "a=")) c = c.substr(2);
|
|
if (!startsWith(c, "candidate:")) continue;
|
|
std::vector<std::string> f = splitWs(c);
|
|
if (f.size() < 8) continue;
|
|
if (iequals(f[2], "tcp")) continue; // xCloud only does UDP (nano-rs filters TCP too)
|
|
list.push_back(entry(c));
|
|
}
|
|
list.push_back(entry("a=end-of-candidates"));
|
|
return json{{"messageType", "iceCandidate"}, {"candidate", list}}.dump(-1, ' ', false, kReplace);
|
|
}
|
|
|
|
} // namespace proto
|
|
} // namespace xc
|