Split peerConnection into separate outgoing/incoming connections
The root cause of both bugs was that each peer had a single peerConnection field used for both sending and receiving streams: 1. Starting a share while receiving another's stream failed because sendOfferTo() saw the existing incoming PC and bailed out, so the outgoing stream was never sent. 2. Stopping a share killed the other person's stream because stopSharing() closed ALL peer connections, including the incoming one used to receive their stream. Fix by splitting into outgoingPC (created via sendOfferTo, used for sending our stream) and incomingPC (created via handleOffer, used for receiving their stream). ICE candidates now embed a direction flag so the remote side routes them to the correct PC. https://claude.ai/code/session_01ALSwS4S8EHiP81i2KMsb9Y
This commit is contained in:
parent
4c223b47ed
commit
6ca45de838
1 changed files with 64 additions and 49 deletions
|
|
@ -430,7 +430,7 @@
|
||||||
peerId: null,
|
peerId: null,
|
||||||
peerIp: null, // Our own IP as seen by the server
|
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, ip, isSharing, hasAudio, stream, peerConnection }
|
peers: new Map(), // peer_id -> { name, ip, isSharing, hasAudio, stream, outgoingPC, incomingPC }
|
||||||
localStream: null,
|
localStream: null,
|
||||||
isSharing: false,
|
isSharing: false,
|
||||||
includeAudio: true,
|
includeAudio: true,
|
||||||
|
|
@ -601,9 +601,11 @@
|
||||||
peer.isSharing = false;
|
peer.isSharing = false;
|
||||||
peer.hasAudio = false;
|
peer.hasAudio = false;
|
||||||
peer.stream = null;
|
peer.stream = null;
|
||||||
if (peer.peerConnection) {
|
// Only close the incoming connection (their stream to us).
|
||||||
peer.peerConnection.close();
|
// Keep our outgoing connection if we're sharing to them.
|
||||||
peer.peerConnection = null;
|
if (peer.incomingPC) {
|
||||||
|
peer.incomingPC.close();
|
||||||
|
peer.incomingPC = null;
|
||||||
}
|
}
|
||||||
if (state.selectedPeerId === msg.peer_id) {
|
if (state.selectedPeerId === msg.peer_id) {
|
||||||
state.selectedPeerId = null;
|
state.selectedPeerId = null;
|
||||||
|
|
@ -648,16 +650,16 @@
|
||||||
isSharing,
|
isSharing,
|
||||||
hasAudio,
|
hasAudio,
|
||||||
stream: null,
|
stream: null,
|
||||||
peerConnection: null,
|
outgoingPC: null, // PC where we send our stream to this peer
|
||||||
|
incomingPC: null, // PC where we receive this peer's stream
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function removePeer(peerId) {
|
function removePeer(peerId) {
|
||||||
const peer = state.peers.get(peerId);
|
const peer = state.peers.get(peerId);
|
||||||
if (peer) {
|
if (peer) {
|
||||||
if (peer.peerConnection) {
|
if (peer.outgoingPC) peer.outgoingPC.close();
|
||||||
peer.peerConnection.close();
|
if (peer.incomingPC) peer.incomingPC.close();
|
||||||
}
|
|
||||||
state.peers.delete(peerId);
|
state.peers.delete(peerId);
|
||||||
|
|
||||||
if (state.selectedPeerId === peerId) {
|
if (state.selectedPeerId === peerId) {
|
||||||
|
|
@ -668,45 +670,47 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== WebRTC ====================
|
// ==================== WebRTC ====================
|
||||||
function createPeerConnection(peerId) {
|
// direction: 'outgoing' (we send offer) or 'incoming' (we receive offer)
|
||||||
|
function createPeerConnection(peerId, direction) {
|
||||||
const pc = new RTCPeerConnection(rtcConfig);
|
const pc = new RTCPeerConnection(rtcConfig);
|
||||||
|
|
||||||
// Debug: Log ICE gathering state changes
|
// Debug: Log ICE gathering state changes
|
||||||
pc.onicegatheringstatechange = () => {
|
pc.onicegatheringstatechange = () => {
|
||||||
console.log(`[${peerId.slice(0,8)}] ICE gathering state: ${pc.iceGatheringState}`);
|
console.log(`[${peerId.slice(0,8)}:${direction}] ICE gathering state: ${pc.iceGatheringState}`);
|
||||||
if (pc.iceGatheringState === 'complete') {
|
if (pc.iceGatheringState === 'complete') {
|
||||||
const sdp = pc.localDescription?.sdp || '';
|
const sdp = pc.localDescription?.sdp || '';
|
||||||
const candidates = extractCandidatesFromSDP(sdp);
|
const candidates = extractCandidatesFromSDP(sdp);
|
||||||
console.log(`[${peerId.slice(0,8)}] Final SDP has ${candidates.length} candidates:`);
|
console.log(`[${peerId.slice(0,8)}:${direction}] Final SDP has ${candidates.length} candidates:`);
|
||||||
candidates.forEach(c => console.log(` ${c}`));
|
candidates.forEach(c => console.log(` ${c}`));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debug: Log ICE connection state
|
// Debug: Log ICE connection state
|
||||||
pc.oniceconnectionstatechange = () => {
|
pc.oniceconnectionstatechange = () => {
|
||||||
console.log(`[${peerId.slice(0,8)}] ICE connection state: ${pc.iceConnectionState}`);
|
console.log(`[${peerId.slice(0,8)}:${direction}] ICE connection state: ${pc.iceConnectionState}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Trickle ICE - send candidates as they're discovered
|
// Trickle ICE - send candidates as they're discovered
|
||||||
pc.onicecandidate = (event) => {
|
pc.onicecandidate = (event) => {
|
||||||
if (event.candidate) {
|
if (event.candidate) {
|
||||||
const c = event.candidate;
|
const c = event.candidate;
|
||||||
console.log(`[${peerId.slice(0,8)}] Local candidate: ${c.candidate}`);
|
console.log(`[${peerId.slice(0,8)}:${direction}] 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
|
// Replace mDNS .local addresses with our real IP for direct LAN connectivity
|
||||||
const candidateObj = event.candidate.toJSON();
|
const candidateObj = event.candidate.toJSON();
|
||||||
candidateObj.candidate = replaceMdnsWithIp(candidateObj.candidate, state.peerIp);
|
candidateObj.candidate = replaceMdnsWithIp(candidateObj.candidate, state.peerIp);
|
||||||
|
// Embed direction so the remote side routes to the right PC
|
||||||
send({
|
send({
|
||||||
type: 'ice_candidate',
|
type: 'ice_candidate',
|
||||||
from: state.peerId,
|
from: state.peerId,
|
||||||
to: peerId,
|
to: peerId,
|
||||||
candidate: JSON.stringify(candidateObj),
|
candidate: JSON.stringify({ ...candidateObj, _dir: direction }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`[${peerId.slice(0,8)}] ICE candidate gathering complete (null candidate)`);
|
console.log(`[${peerId.slice(0,8)}:${direction}] ICE candidate gathering complete (null candidate)`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -733,7 +737,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.onconnectionstatechange = () => {
|
pc.onconnectionstatechange = () => {
|
||||||
console.log(`[${peerId.slice(0,8)}] Connection state: ${pc.connectionState}`);
|
console.log(`[${peerId.slice(0,8)}:${direction}] Connection state: ${pc.connectionState}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return pc;
|
return pc;
|
||||||
|
|
@ -782,15 +786,15 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't create a new connection if we already have one that's not closed
|
// Don't create a new outgoing connection if we already have one
|
||||||
if (peer.peerConnection && peer.peerConnection.connectionState !== 'closed') {
|
if (peer.outgoingPC && peer.outgoingPC.connectionState !== 'closed') {
|
||||||
console.log(`[${peerId.slice(0,8)}] sendOfferTo: already have connection`);
|
console.log(`[${peerId.slice(0,8)}] sendOfferTo: already have outgoing connection`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[${peerId.slice(0,8)}] sendOfferTo: creating offer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`);
|
console.log(`[${peerId.slice(0,8)}] sendOfferTo: creating offer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`);
|
||||||
const pc = createPeerConnection(peerId);
|
const pc = createPeerConnection(peerId, 'outgoing');
|
||||||
peer.peerConnection = pc;
|
peer.outgoingPC = pc;
|
||||||
|
|
||||||
// Add local stream tracks
|
// Add local stream tracks
|
||||||
if (state.localStream) {
|
if (state.localStream) {
|
||||||
|
|
@ -828,14 +832,14 @@
|
||||||
|
|
||||||
const peer = state.peers.get(fromPeerId);
|
const peer = state.peers.get(fromPeerId);
|
||||||
|
|
||||||
// Close existing connection if any (new offer = renegotiation)
|
// Close existing incoming connection if any (new offer = renegotiation)
|
||||||
if (peer.peerConnection) {
|
if (peer.incomingPC) {
|
||||||
peer.peerConnection.close();
|
peer.incomingPC.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, 'incoming');
|
||||||
peer.peerConnection = pc;
|
peer.incomingPC = pc;
|
||||||
|
|
||||||
// Replace any mDNS .local addresses in the remote SDP with the peer's real IP
|
// 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);
|
||||||
|
|
@ -869,14 +873,14 @@
|
||||||
|
|
||||||
async function handleAnswer(fromPeerId, sdpJson) {
|
async function handleAnswer(fromPeerId, sdpJson) {
|
||||||
const peer = state.peers.get(fromPeerId);
|
const peer = state.peers.get(fromPeerId);
|
||||||
if (!peer || !peer.peerConnection) {
|
if (!peer || !peer.outgoingPC) {
|
||||||
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no peer connection`);
|
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no outgoing 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.outgoingPC.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.outgoingPC.signalingState}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -888,22 +892,32 @@
|
||||||
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.outgoingPC.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleIceCandidate(fromPeerId, candidateJson) {
|
function handleIceCandidate(fromPeerId, candidateJson) {
|
||||||
const peer = state.peers.get(fromPeerId);
|
const peer = state.peers.get(fromPeerId);
|
||||||
if (!peer || !peer.peerConnection) {
|
if (!peer) {
|
||||||
console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no peer connection`);
|
console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: peer not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(candidateJson);
|
||||||
|
const dir = data._dir;
|
||||||
|
delete data._dir;
|
||||||
|
|
||||||
|
// Their outgoing PC corresponds to our incoming PC (and vice versa)
|
||||||
|
const pc = dir === 'outgoing' ? peer.incomingPC : peer.outgoingPC;
|
||||||
|
if (!pc) {
|
||||||
|
console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no matching PC for direction ${dir}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace any mDNS .local addresses with the peer's real IP
|
// Replace any mDNS .local addresses with the peer's real IP
|
||||||
const candidate = JSON.parse(candidateJson);
|
data.candidate = replaceMdnsWithIp(data.candidate, peer.ip);
|
||||||
candidate.candidate = replaceMdnsWithIp(candidate.candidate, peer.ip);
|
console.log(`[${fromPeerId.slice(0,8)}:${dir}] Remote candidate: ${data.candidate}`);
|
||||||
console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.candidate}`);
|
|
||||||
|
|
||||||
peer.peerConnection.addIceCandidate(new RTCIceCandidate(candidate))
|
pc.addIceCandidate(new RTCIceCandidate(data))
|
||||||
.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));
|
||||||
}
|
}
|
||||||
|
|
@ -953,11 +967,12 @@
|
||||||
|
|
||||||
state.isSharing = false;
|
state.isSharing = false;
|
||||||
|
|
||||||
// Close all peer connections we initiated
|
// Only close outgoing connections (where we were sending our stream).
|
||||||
|
// Keep incoming connections alive so we can still view others' streams.
|
||||||
for (const [peerId, peer] of state.peers) {
|
for (const [peerId, peer] of state.peers) {
|
||||||
if (peer.peerConnection) {
|
if (peer.outgoingPC) {
|
||||||
peer.peerConnection.close();
|
peer.outgoingPC.close();
|
||||||
peer.peerConnection = null;
|
peer.outgoingPC = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue