Signed-off-by: Marcel Müller <neikos@neikos.email>
This commit is contained in:
Marcel Müller 2026-02-09 08:22:15 +01:00
parent c23d7d06e0
commit accbfdb1ea
2 changed files with 127 additions and 34 deletions

View file

@ -105,8 +105,7 @@ lanshare -v
│ Signaling Server │ │ Signaling Server │
│ (Rust/Axum/WebSocket) │ │ (Rust/Axum/WebSocket) │
│ - Peer discovery │ │ - Peer discovery │
│ - Relays SDP offers/answers │ │ - Relays SDP offers/answers (with bundled candidates) │
│ - Relays ICE candidates │
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
│ │ │ │
│ WebSocket │ WebSocket │ WebSocket │ WebSocket
@ -114,11 +113,15 @@ lanshare -v
▼ ▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Peer A │◄────────────────────►│ Peer B │ │ Peer A │◄────────────────────►│ Peer B │
│ (Browser) │ WebRTC P2P │ (Browser) │ │ (Browser) │ Direct WebRTC │ (Browser) │
└─────────────┘ (video/audio) └─────────────┘ └─────────────┘ (video/audio) └─────────────┘
``` ```
The server only handles signaling — the actual video/audio streams flow directly between browsers via WebRTC. **100% Local Network** — No external services, simple direct connections:
- No STUN/TURN servers needed
- No trickle ICE — candidates bundled directly in SDP
- One offer + one answer = connected
- Video/audio streams stay entirely on your LAN
## Browser Support ## Browser Support

View file

@ -439,14 +439,25 @@
maxReconnectAttempts: 5, maxReconnectAttempts: 5,
}; };
// WebRTC config - using public STUN servers for ICE // WebRTC config - no STUN/TURN needed for LAN-only connections
// WebRTC will use "host" ICE candidates (local IPs) which work directly on LAN
// WebRTC config - LAN only, no STUN/TURN servers needed
const rtcConfig = { const rtcConfig = {
iceServers: [ iceServers: [],
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
]
}; };
// Toggle this to test bundled vs trickle ICE
const USE_TRICKLE_ICE = true; // Set to false to test bundled approach
// Debug: Parse and log SDP candidates
function extractCandidatesFromSDP(sdp) {
const lines = sdp.split('\r\n');
const candidates = lines.filter(line => line.startsWith('a=candidate:'));
return candidates;
}
console.log(`ICE mode: ${USE_TRICKLE_ICE ? 'TRICKLE' : 'BUNDLED'}`);
// ==================== DOM Elements ==================== // ==================== DOM Elements ====================
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
const connectingOverlay = $('connectingOverlay'); const connectingOverlay = $('connectingOverlay');
@ -611,7 +622,7 @@
break; break;
case 'ice_candidate': case 'ice_candidate':
await handleIceCandidate(msg.from, msg.candidate); handleIceCandidate(msg.from, msg.candidate);
break; break;
} }
} }
@ -648,8 +659,30 @@
function createPeerConnection(peerId) { function createPeerConnection(peerId) {
const pc = new RTCPeerConnection(rtcConfig); const pc = new RTCPeerConnection(rtcConfig);
// Debug: Log ICE gathering state changes
pc.onicegatheringstatechange = () => {
console.log(`[${peerId.slice(0,8)}] ICE gathering state: ${pc.iceGatheringState}`);
if (pc.iceGatheringState === 'complete') {
const sdp = pc.localDescription?.sdp || '';
const candidates = extractCandidatesFromSDP(sdp);
console.log(`[${peerId.slice(0,8)}] Final SDP has ${candidates.length} candidates:`);
candidates.forEach(c => console.log(` ${c}`));
}
};
// Debug: Log ICE connection state
pc.oniceconnectionstatechange = () => {
console.log(`[${peerId.slice(0,8)}] ICE connection state: ${pc.iceConnectionState}`);
};
// Trickle ICE - send candidates as they're discovered
pc.onicecandidate = (event) => { pc.onicecandidate = (event) => {
if (event.candidate) { if (event.candidate) {
const c = event.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}`);
if (USE_TRICKLE_ICE) {
send({ send({
type: 'ice_candidate', type: 'ice_candidate',
from: state.peerId, from: state.peerId,
@ -657,10 +690,13 @@
candidate: JSON.stringify(event.candidate), candidate: JSON.stringify(event.candidate),
}); });
} }
} else {
console.log(`[${peerId.slice(0,8)}] ICE candidate gathering complete (null candidate)`);
}
}; };
pc.ontrack = (event) => { pc.ontrack = (event) => {
console.log('Received track from', peerId); console.log(`[${peerId.slice(0,8)}] Received track:`, event.track.kind);
const peer = state.peers.get(peerId); const peer = state.peers.get(peerId);
if (peer) { if (peer) {
peer.stream = event.streams[0]; peer.stream = event.streams[0];
@ -674,7 +710,7 @@
}; };
pc.onconnectionstatechange = () => { pc.onconnectionstatechange = () => {
console.log(`Connection state with ${peerId}: ${pc.connectionState}`); console.log(`[${peerId.slice(0,8)}] Connection state: ${pc.connectionState}`);
}; };
return pc; return pc;
@ -688,6 +724,34 @@
}); });
} }
// Wait for ICE gathering to complete (for bundled mode)
function waitForIceGathering(pc, peerId) {
return new Promise((resolve) => {
if (pc.iceGatheringState === 'complete') {
console.log(`[${peerId.slice(0,8)}] ICE already complete`);
resolve();
return;
}
const checkState = () => {
if (pc.iceGatheringState === 'complete') {
pc.removeEventListener('icegatheringstatechange', checkState);
console.log(`[${peerId.slice(0,8)}] ICE gathering finished`);
resolve();
}
};
pc.addEventListener('icegatheringstatechange', checkState);
// Timeout after 2s
setTimeout(() => {
pc.removeEventListener('icegatheringstatechange', checkState);
console.log(`[${peerId.slice(0,8)}] ICE gathering timeout (state: ${pc.iceGatheringState})`);
resolve();
}, 2000);
});
}
async function sendOfferTo(peerId) { async function sendOfferTo(peerId) {
const peer = state.peers.get(peerId); const peer = state.peers.get(peerId);
if (!peer) { if (!peer) {
@ -697,11 +761,11 @@
// Don't create a new connection if we already have one that's not closed // Don't create a new connection if we already have one that's not closed
if (peer.peerConnection && peer.peerConnection.connectionState !== 'closed') { if (peer.peerConnection && peer.peerConnection.connectionState !== 'closed') {
console.log('sendOfferTo: already have connection to', peerId); console.log(`[${peerId.slice(0,8)}] sendOfferTo: already have connection`);
return; return;
} }
console.log('sendOfferTo: creating offer for', peerId); console.log(`[${peerId.slice(0,8)}] sendOfferTo: creating offer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`);
const pc = createPeerConnection(peerId); const pc = createPeerConnection(peerId);
peer.peerConnection = pc; peer.peerConnection = pc;
@ -715,11 +779,20 @@
const offer = await pc.createOffer(); const offer = await pc.createOffer();
await pc.setLocalDescription(offer); await pc.setLocalDescription(offer);
if (!USE_TRICKLE_ICE) {
// Bundled mode: wait for all candidates to be in SDP
await waitForIceGathering(pc, peerId);
}
const sdpToSend = pc.localDescription;
const candidates = extractCandidatesFromSDP(sdpToSend.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,
to: peerId, to: peerId,
sdp: JSON.stringify(pc.localDescription), sdp: JSON.stringify(sdpToSend),
}); });
} }
@ -736,55 +809,72 @@
peer.peerConnection.close(); peer.peerConnection.close();
} }
console.log('handleOffer: creating answer for', fromPeerId); 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;
const sdp = JSON.parse(sdpJson); const sdp = JSON.parse(sdpJson);
const remoteCandidates = extractCandidatesFromSDP(sdp.sdp);
console.log(`[${fromPeerId.slice(0,8)}] Received offer with ${remoteCandidates.length} candidates in SDP:`);
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) {
// Bundled mode: wait for all candidates to be in SDP
await waitForIceGathering(pc, fromPeerId);
}
const sdpToSend = pc.localDescription;
const localCandidates = extractCandidatesFromSDP(sdpToSend.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,
to: fromPeerId, to: fromPeerId,
sdp: JSON.stringify(pc.localDescription), sdp: JSON.stringify(sdpToSend),
}); });
} }
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.peerConnection) {
console.warn('handleAnswer: no peer connection for', fromPeerId); 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('handleAnswer: wrong state', peer.peerConnection.signalingState, 'for', fromPeerId); console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.peerConnection.signalingState}`);
return; return;
} }
console.log('handleAnswer: setting remote description for', fromPeerId);
const sdp = JSON.parse(sdpJson); const sdp = JSON.parse(sdpJson);
const remoteCandidates = extractCandidatesFromSDP(sdp.sdp);
console.log(`[${fromPeerId.slice(0,8)}] Received answer with ${remoteCandidates.length} candidates in SDP:`);
remoteCandidates.forEach(c => console.log(` ${c}`));
console.log(`[${fromPeerId.slice(0,8)}] handleAnswer: setting remote description`);
await peer.peerConnection.setRemoteDescription(new RTCSessionDescription(sdp)); await peer.peerConnection.setRemoteDescription(new RTCSessionDescription(sdp));
} }
async 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 || !peer.peerConnection) {
console.warn('handleIceCandidate: no peer connection for', fromPeerId); console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no peer connection`);
return; return;
} }
try {
const candidate = JSON.parse(candidateJson); const candidate = JSON.parse(candidateJson);
await peer.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)); console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.candidate}`);
} catch (err) {
console.warn('handleIceCandidate: error adding candidate:', err); peer.peerConnection.addIceCandidate(new RTCIceCandidate(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));
} }
// ==================== Screen Sharing ==================== // ==================== Screen Sharing ====================