Merge pull request #1 from TheNeikos/claude/fix-ice-direct-ip-Q0OBq
Add IP address tracking and mDNS resolution for direct LAN connectivity
This commit is contained in:
commit
6193fe4cf1
2 changed files with 78 additions and 33 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{
|
extract::{
|
||||||
|
connect_info::ConnectInfo,
|
||||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
State,
|
State,
|
||||||
},
|
},
|
||||||
|
|
@ -63,6 +64,7 @@ enum SignalMessage {
|
||||||
/// Server assigns an ID to a new peer
|
/// Server assigns an ID to a new peer
|
||||||
Welcome {
|
Welcome {
|
||||||
peer_id: String,
|
peer_id: String,
|
||||||
|
ip: String,
|
||||||
peers: Vec<PeerInfo>,
|
peers: Vec<PeerInfo>,
|
||||||
},
|
},
|
||||||
/// Peer announces themselves (with optional name)
|
/// Peer announces themselves (with optional name)
|
||||||
|
|
@ -73,6 +75,7 @@ enum SignalMessage {
|
||||||
PeerJoined {
|
PeerJoined {
|
||||||
peer_id: String,
|
peer_id: String,
|
||||||
name: String,
|
name: String,
|
||||||
|
ip: String,
|
||||||
},
|
},
|
||||||
/// Broadcast: a peer left
|
/// Broadcast: a peer left
|
||||||
PeerLeft {
|
PeerLeft {
|
||||||
|
|
@ -120,6 +123,7 @@ enum SignalMessage {
|
||||||
struct PeerInfo {
|
struct PeerInfo {
|
||||||
peer_id: String,
|
peer_id: String,
|
||||||
name: String,
|
name: String,
|
||||||
|
ip: String,
|
||||||
is_sharing: bool,
|
is_sharing: bool,
|
||||||
has_audio: bool,
|
has_audio: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -127,6 +131,7 @@ struct PeerInfo {
|
||||||
/// State for a connected peer
|
/// State for a connected peer
|
||||||
struct PeerState {
|
struct PeerState {
|
||||||
name: String,
|
name: String,
|
||||||
|
ip: String,
|
||||||
is_sharing: bool,
|
is_sharing: bool,
|
||||||
has_audio: bool,
|
has_audio: bool,
|
||||||
tx: broadcast::Sender<SignalMessage>,
|
tx: broadcast::Sender<SignalMessage>,
|
||||||
|
|
@ -150,6 +155,7 @@ impl AppState {
|
||||||
.map(|entry| PeerInfo {
|
.map(|entry| PeerInfo {
|
||||||
peer_id: entry.key().clone(),
|
peer_id: entry.key().clone(),
|
||||||
name: entry.value().name.clone(),
|
name: entry.value().name.clone(),
|
||||||
|
ip: entry.value().ip.clone(),
|
||||||
is_sharing: entry.value().is_sharing,
|
is_sharing: entry.value().is_sharing,
|
||||||
has_audio: entry.value().has_audio,
|
has_audio: entry.value().has_audio,
|
||||||
})
|
})
|
||||||
|
|
@ -216,19 +222,27 @@ async fn main() {
|
||||||
|
|
||||||
let config = RustlsConfig::from_pem_file(args.certificate, args.key).await.unwrap();
|
let config = RustlsConfig::from_pem_file(args.certificate, args.key).await.unwrap();
|
||||||
|
|
||||||
axum_server::bind_rustls(addr, config).serve(app.into_make_service()).await.unwrap();
|
axum_server::bind_rustls(addr, config)
|
||||||
|
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_index() -> impl IntoResponse {
|
async fn serve_index() -> impl IntoResponse {
|
||||||
Html(include_str!("../static/index.html"))
|
Html(include_str!("../static/index.html"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
async fn ws_handler(
|
||||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
ws: WebSocketUpgrade,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
ws.on_upgrade(move |socket| handle_socket(socket, state, addr))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, addr: SocketAddr) {
|
||||||
let peer_id = Uuid::new_v4().to_string();
|
let peer_id = Uuid::new_v4().to_string();
|
||||||
|
let peer_ip = addr.ip().to_string();
|
||||||
let (tx, _) = broadcast::channel::<SignalMessage>(64);
|
let (tx, _) = broadcast::channel::<SignalMessage>(64);
|
||||||
|
|
||||||
// Add peer to state with default name
|
// Add peer to state with default name
|
||||||
|
|
@ -236,6 +250,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
||||||
peer_id.clone(),
|
peer_id.clone(),
|
||||||
PeerState {
|
PeerState {
|
||||||
name: format!("Peer {}", &peer_id[..8]),
|
name: format!("Peer {}", &peer_id[..8]),
|
||||||
|
ip: peer_ip.clone(),
|
||||||
is_sharing: false,
|
is_sharing: false,
|
||||||
has_audio: false,
|
has_audio: false,
|
||||||
tx: tx.clone(),
|
tx: tx.clone(),
|
||||||
|
|
@ -250,6 +265,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
||||||
// Send welcome message with current peer list
|
// Send welcome message with current peer list
|
||||||
let welcome = SignalMessage::Welcome {
|
let welcome = SignalMessage::Welcome {
|
||||||
peer_id: peer_id.clone(),
|
peer_id: peer_id.clone(),
|
||||||
|
ip: peer_ip,
|
||||||
peers: peers.into_iter().filter(|p| p.peer_id != peer_id).collect(),
|
peers: peers.into_iter().filter(|p| p.peer_id != peer_id).collect(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -319,6 +335,11 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
||||||
async fn handle_signal(state: &AppState, from_peer: &str, signal: SignalMessage) {
|
async fn handle_signal(state: &AppState, from_peer: &str, signal: SignalMessage) {
|
||||||
match signal {
|
match signal {
|
||||||
SignalMessage::Announce { name } => {
|
SignalMessage::Announce { name } => {
|
||||||
|
let ip = state
|
||||||
|
.peers
|
||||||
|
.get(from_peer)
|
||||||
|
.map(|p| p.ip.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
if let Some(mut peer) = state.peers.get_mut(from_peer) {
|
if let Some(mut peer) = state.peers.get_mut(from_peer) {
|
||||||
peer.name = name.clone();
|
peer.name = name.clone();
|
||||||
}
|
}
|
||||||
|
|
@ -326,6 +347,7 @@ async fn handle_signal(state: &AppState, from_peer: &str, signal: SignalMessage)
|
||||||
SignalMessage::PeerJoined {
|
SignalMessage::PeerJoined {
|
||||||
peer_id: from_peer.to_string(),
|
peer_id: from_peer.to_string(),
|
||||||
name,
|
name,
|
||||||
|
ip,
|
||||||
},
|
},
|
||||||
Some(from_peer),
|
Some(from_peer),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -428,8 +428,9 @@
|
||||||
// ==================== State ====================
|
// ==================== State ====================
|
||||||
const state = {
|
const state = {
|
||||||
peerId: null,
|
peerId: null,
|
||||||
|
peerIp: null, // Our own IP as seen by the server
|
||||||
peerName: localStorage.getItem('lanshare_name') || null,
|
peerName: localStorage.getItem('lanshare_name') || null,
|
||||||
peers: new Map(), // peer_id -> { name, isSharing, hasAudio, stream, peerConnection }
|
peers: new Map(), // peer_id -> { name, ip, isSharing, hasAudio, stream, peerConnection }
|
||||||
localStream: null,
|
localStream: null,
|
||||||
isSharing: false,
|
isSharing: false,
|
||||||
includeAudio: true,
|
includeAudio: true,
|
||||||
|
|
@ -456,6 +457,14 @@
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replace mDNS .local addresses with a real IP.
|
||||||
|
// Modern browsers obfuscate local IPs as UUIDs ending in .local for privacy,
|
||||||
|
// which breaks direct LAN connections when mDNS resolution isn't available.
|
||||||
|
function replaceMdnsWithIp(str, realIp) {
|
||||||
|
if (!str || !realIp) return str;
|
||||||
|
return str.replace(/[a-f0-9-]+\.local/gi, realIp);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`ICE mode: ${USE_TRICKLE_ICE ? 'TRICKLE' : 'BUNDLED'}`);
|
console.log(`ICE mode: ${USE_TRICKLE_ICE ? 'TRICKLE' : 'BUNDLED'}`);
|
||||||
|
|
||||||
// ==================== DOM Elements ====================
|
// ==================== DOM Elements ====================
|
||||||
|
|
@ -537,20 +546,22 @@
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'welcome':
|
case 'welcome':
|
||||||
state.peerId = msg.peer_id;
|
state.peerId = msg.peer_id;
|
||||||
|
state.peerIp = msg.ip;
|
||||||
|
console.log(`Our IP as seen by server: ${msg.ip}`);
|
||||||
connectingOverlay.style.display = 'none';
|
connectingOverlay.style.display = 'none';
|
||||||
updateConnectionStatus(true);
|
updateConnectionStatus(true);
|
||||||
|
|
||||||
// Set name
|
// Set name
|
||||||
if (!state.peerName) {
|
if (!state.peerName) {
|
||||||
state.peerName = prompt('Enter your name:', `User ${msg.peer_id.slice(0, 4)}`) || `User ${msg.peer_id.slice(0, 4)}`;
|
state.peerName = prompt('Enter your name:', `User ${msg.peer_id.slice(0, 4)}`) || `User ${msg.peer_id.slice(0, 4)}`;
|
||||||
localStorage.setItem('lanshare_name', state.peerName);
|
localStorage.setItem('lanshare_name', state.peerName);
|
||||||
}
|
}
|
||||||
|
|
||||||
send({ type: 'announce', name: state.peerName });
|
send({ type: 'announce', name: state.peerName });
|
||||||
|
|
||||||
// Add existing peers
|
// Add existing peers
|
||||||
for (const peer of msg.peers) {
|
for (const peer of msg.peers) {
|
||||||
addPeer(peer.peer_id, peer.name, peer.is_sharing, peer.has_audio);
|
addPeer(peer.peer_id, peer.name, peer.ip, peer.is_sharing, peer.has_audio);
|
||||||
// Request stream from peers that are already sharing
|
// Request stream from peers that are already sharing
|
||||||
if (peer.is_sharing) {
|
if (peer.is_sharing) {
|
||||||
requestStreamFrom(peer.peer_id);
|
requestStreamFrom(peer.peer_id);
|
||||||
|
|
@ -560,7 +571,7 @@
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'peer_joined':
|
case 'peer_joined':
|
||||||
addPeer(msg.peer_id, msg.name, false, false);
|
addPeer(msg.peer_id, msg.name, msg.ip, false, false);
|
||||||
// If we're sharing, proactively send an offer to the new peer
|
// If we're sharing, proactively send an offer to the new peer
|
||||||
if (state.isSharing && state.localStream) {
|
if (state.isSharing && state.localStream) {
|
||||||
await sendOfferTo(msg.peer_id);
|
await sendOfferTo(msg.peer_id);
|
||||||
|
|
@ -607,7 +618,7 @@
|
||||||
if (state.isSharing && state.localStream) {
|
if (state.isSharing && state.localStream) {
|
||||||
// Ensure we have this peer in our state (handles race condition)
|
// Ensure we have this peer in our state (handles race condition)
|
||||||
if (!state.peers.has(msg.from)) {
|
if (!state.peers.has(msg.from)) {
|
||||||
addPeer(msg.from, `Peer ${msg.from.slice(0, 4)}`, false, false);
|
addPeer(msg.from, `Peer ${msg.from.slice(0, 4)}`, null, false, false);
|
||||||
}
|
}
|
||||||
await sendOfferTo(msg.from);
|
await sendOfferTo(msg.from);
|
||||||
}
|
}
|
||||||
|
|
@ -628,11 +639,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Peer Management ====================
|
// ==================== Peer Management ====================
|
||||||
function addPeer(peerId, name, isSharing, hasAudio) {
|
function addPeer(peerId, name, ip, isSharing, hasAudio) {
|
||||||
if (peerId === state.peerId) return;
|
if (peerId === state.peerId) return;
|
||||||
|
|
||||||
state.peers.set(peerId, {
|
state.peers.set(peerId, {
|
||||||
name,
|
name,
|
||||||
|
ip,
|
||||||
isSharing,
|
isSharing,
|
||||||
hasAudio,
|
hasAudio,
|
||||||
stream: null,
|
stream: null,
|
||||||
|
|
@ -681,13 +693,16 @@
|
||||||
const c = event.candidate;
|
const c = event.candidate;
|
||||||
console.log(`[${peerId.slice(0,8)}] Local candidate: ${c.candidate}`);
|
console.log(`[${peerId.slice(0,8)}] Local candidate: ${c.candidate}`);
|
||||||
console.log(` → type: ${c.type}, protocol: ${c.protocol}, address: ${c.address}, port: ${c.port}`);
|
console.log(` → type: ${c.type}, protocol: ${c.protocol}, address: ${c.address}, port: ${c.port}`);
|
||||||
|
|
||||||
if (USE_TRICKLE_ICE) {
|
if (USE_TRICKLE_ICE) {
|
||||||
|
// Replace mDNS .local addresses with our real IP for direct LAN connectivity
|
||||||
|
const candidateObj = event.candidate.toJSON();
|
||||||
|
candidateObj.candidate = replaceMdnsWithIp(candidateObj.candidate, state.peerIp);
|
||||||
send({
|
send({
|
||||||
type: 'ice_candidate',
|
type: 'ice_candidate',
|
||||||
from: state.peerId,
|
from: state.peerId,
|
||||||
to: peerId,
|
to: peerId,
|
||||||
candidate: JSON.stringify(event.candidate),
|
candidate: JSON.stringify(candidateObj),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -784,10 +799,11 @@
|
||||||
await waitForIceGathering(pc, peerId);
|
await waitForIceGathering(pc, peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sdpToSend = pc.localDescription;
|
// Replace mDNS .local addresses with our real IP for direct LAN connectivity
|
||||||
|
const sdpToSend = { type: pc.localDescription.type, sdp: replaceMdnsWithIp(pc.localDescription.sdp, state.peerIp) };
|
||||||
const candidates = extractCandidatesFromSDP(sdpToSend.sdp);
|
const candidates = extractCandidatesFromSDP(sdpToSend.sdp);
|
||||||
console.log(`[${peerId.slice(0,8)}] Sending offer with ${candidates.length} candidates in SDP`);
|
console.log(`[${peerId.slice(0,8)}] Sending offer with ${candidates.length} candidates in SDP`);
|
||||||
|
|
||||||
send({
|
send({
|
||||||
type: 'offer',
|
type: 'offer',
|
||||||
from: state.peerId,
|
from: state.peerId,
|
||||||
|
|
@ -799,39 +815,42 @@
|
||||||
async function handleOffer(fromPeerId, sdpJson) {
|
async function handleOffer(fromPeerId, sdpJson) {
|
||||||
// Ensure we have this peer in state
|
// Ensure we have this peer in state
|
||||||
if (!state.peers.has(fromPeerId)) {
|
if (!state.peers.has(fromPeerId)) {
|
||||||
addPeer(fromPeerId, `Peer ${fromPeerId.slice(0, 4)}`, true, false);
|
addPeer(fromPeerId, `Peer ${fromPeerId.slice(0, 4)}`, null, true, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const peer = state.peers.get(fromPeerId);
|
const peer = state.peers.get(fromPeerId);
|
||||||
|
|
||||||
// Close existing connection if any (new offer = renegotiation)
|
// Close existing connection if any (new offer = renegotiation)
|
||||||
if (peer.peerConnection) {
|
if (peer.peerConnection) {
|
||||||
peer.peerConnection.close();
|
peer.peerConnection.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[${fromPeerId.slice(0,8)}] handleOffer: creating answer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`);
|
console.log(`[${fromPeerId.slice(0,8)}] handleOffer: creating answer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`);
|
||||||
const pc = createPeerConnection(fromPeerId);
|
const pc = createPeerConnection(fromPeerId);
|
||||||
peer.peerConnection = pc;
|
peer.peerConnection = pc;
|
||||||
|
|
||||||
|
// Replace any mDNS .local addresses in the remote SDP with the peer's real IP
|
||||||
const sdp = JSON.parse(sdpJson);
|
const sdp = JSON.parse(sdpJson);
|
||||||
|
sdp.sdp = replaceMdnsWithIp(sdp.sdp, peer.ip);
|
||||||
const remoteCandidates = extractCandidatesFromSDP(sdp.sdp);
|
const remoteCandidates = extractCandidatesFromSDP(sdp.sdp);
|
||||||
console.log(`[${fromPeerId.slice(0,8)}] Received offer with ${remoteCandidates.length} candidates in SDP:`);
|
console.log(`[${fromPeerId.slice(0,8)}] Received offer with ${remoteCandidates.length} candidates in SDP:`);
|
||||||
remoteCandidates.forEach(c => console.log(` ${c}`));
|
remoteCandidates.forEach(c => console.log(` ${c}`));
|
||||||
|
|
||||||
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
|
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||||
|
|
||||||
const answer = await pc.createAnswer();
|
const answer = await pc.createAnswer();
|
||||||
await pc.setLocalDescription(answer);
|
await pc.setLocalDescription(answer);
|
||||||
|
|
||||||
if (!USE_TRICKLE_ICE) {
|
if (!USE_TRICKLE_ICE) {
|
||||||
// Bundled mode: wait for all candidates to be in SDP
|
// Bundled mode: wait for all candidates to be in SDP
|
||||||
await waitForIceGathering(pc, fromPeerId);
|
await waitForIceGathering(pc, fromPeerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sdpToSend = pc.localDescription;
|
// Replace mDNS .local addresses with our real IP for direct LAN connectivity
|
||||||
|
const sdpToSend = { type: pc.localDescription.type, sdp: replaceMdnsWithIp(pc.localDescription.sdp, state.peerIp) };
|
||||||
const localCandidates = extractCandidatesFromSDP(sdpToSend.sdp);
|
const localCandidates = extractCandidatesFromSDP(sdpToSend.sdp);
|
||||||
console.log(`[${fromPeerId.slice(0,8)}] Sending answer with ${localCandidates.length} candidates in SDP`);
|
console.log(`[${fromPeerId.slice(0,8)}] Sending answer with ${localCandidates.length} candidates in SDP`);
|
||||||
|
|
||||||
send({
|
send({
|
||||||
type: 'answer',
|
type: 'answer',
|
||||||
from: state.peerId,
|
from: state.peerId,
|
||||||
|
|
@ -846,18 +865,20 @@
|
||||||
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no peer connection`);
|
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no peer connection`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only set remote description if we're in the right state
|
// Only set remote description if we're in the right state
|
||||||
if (peer.peerConnection.signalingState !== 'have-local-offer') {
|
if (peer.peerConnection.signalingState !== 'have-local-offer') {
|
||||||
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.peerConnection.signalingState}`);
|
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.peerConnection.signalingState}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replace any mDNS .local addresses in the remote SDP with the peer's real IP
|
||||||
const sdp = JSON.parse(sdpJson);
|
const sdp = JSON.parse(sdpJson);
|
||||||
|
sdp.sdp = replaceMdnsWithIp(sdp.sdp, peer.ip);
|
||||||
const remoteCandidates = extractCandidatesFromSDP(sdp.sdp);
|
const remoteCandidates = extractCandidatesFromSDP(sdp.sdp);
|
||||||
console.log(`[${fromPeerId.slice(0,8)}] Received answer with ${remoteCandidates.length} candidates in SDP:`);
|
console.log(`[${fromPeerId.slice(0,8)}] Received answer with ${remoteCandidates.length} candidates in SDP:`);
|
||||||
remoteCandidates.forEach(c => console.log(` ${c}`));
|
remoteCandidates.forEach(c => console.log(` ${c}`));
|
||||||
|
|
||||||
console.log(`[${fromPeerId.slice(0,8)}] handleAnswer: setting remote description`);
|
console.log(`[${fromPeerId.slice(0,8)}] handleAnswer: setting remote description`);
|
||||||
await peer.peerConnection.setRemoteDescription(new RTCSessionDescription(sdp));
|
await peer.peerConnection.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||||
}
|
}
|
||||||
|
|
@ -868,10 +889,12 @@
|
||||||
console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no peer connection`);
|
console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no peer connection`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replace any mDNS .local addresses with the peer's real IP
|
||||||
const candidate = JSON.parse(candidateJson);
|
const candidate = JSON.parse(candidateJson);
|
||||||
|
candidate.candidate = replaceMdnsWithIp(candidate.candidate, peer.ip);
|
||||||
console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.candidate}`);
|
console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.candidate}`);
|
||||||
|
|
||||||
peer.peerConnection.addIceCandidate(new RTCIceCandidate(candidate))
|
peer.peerConnection.addIceCandidate(new RTCIceCandidate(candidate))
|
||||||
.then(() => console.log(`[${fromPeerId.slice(0,8)}] Successfully added remote candidate`))
|
.then(() => console.log(`[${fromPeerId.slice(0,8)}] Successfully added remote candidate`))
|
||||||
.catch(err => console.error(`[${fromPeerId.slice(0,8)}] Error adding ICE candidate:`, err));
|
.catch(err => console.error(`[${fromPeerId.slice(0,8)}] Error adding ICE candidate:`, err));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue