diff --git a/lanshare/README.md b/lanshare/README.md index 92cf147..df51b9f 100644 --- a/lanshare/README.md +++ b/lanshare/README.md @@ -105,8 +105,7 @@ lanshare -v │ Signaling Server │ │ (Rust/Axum/WebSocket) │ │ - Peer discovery │ -│ - Relays SDP offers/answers │ -│ - Relays ICE candidates │ +│ - Relays SDP offers/answers (with bundled candidates) │ └─────────────────────────────────────────────────────────────┘ │ │ │ WebSocket │ WebSocket @@ -114,11 +113,15 @@ lanshare -v ▼ ▼ ┌─────────────┐ ┌─────────────┐ │ Peer A │◄────────────────────►│ Peer B │ - │ (Browser) │ WebRTC P2P │ (Browser) │ + │ (Browser) │ Direct WebRTC │ (Browser) │ └─────────────┘ (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 diff --git a/lanshare/static/index.html b/lanshare/static/index.html index 2544928..20efc15 100644 --- a/lanshare/static/index.html +++ b/lanshare/static/index.html @@ -439,13 +439,24 @@ 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 = { - iceServers: [ - { urls: 'stun:stun.l.google.com:19302' }, - { urls: 'stun:stun1.l.google.com:19302' }, - ] + iceServers: [], }; + + // 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 ==================== const $ = id => document.getElementById(id); @@ -611,7 +622,7 @@ break; case 'ice_candidate': - await handleIceCandidate(msg.from, msg.candidate); + handleIceCandidate(msg.from, msg.candidate); break; } } @@ -648,19 +659,44 @@ function createPeerConnection(peerId) { 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) => { if (event.candidate) { - send({ - type: 'ice_candidate', - from: state.peerId, - to: peerId, - candidate: JSON.stringify(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({ + type: 'ice_candidate', + from: state.peerId, + to: peerId, + candidate: JSON.stringify(event.candidate), + }); + } + } else { + console.log(`[${peerId.slice(0,8)}] ICE candidate gathering complete (null candidate)`); } }; 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); if (peer) { peer.stream = event.streams[0]; @@ -674,7 +710,7 @@ }; pc.onconnectionstatechange = () => { - console.log(`Connection state with ${peerId}: ${pc.connectionState}`); + console.log(`[${peerId.slice(0,8)}] Connection state: ${pc.connectionState}`); }; 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) { const peer = state.peers.get(peerId); if (!peer) { @@ -697,11 +761,11 @@ // Don't create a new connection if we already have one that's not 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; } - 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); peer.peerConnection = pc; @@ -715,11 +779,20 @@ const offer = await pc.createOffer(); 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({ type: 'offer', from: state.peerId, to: peerId, - sdp: JSON.stringify(pc.localDescription), + sdp: JSON.stringify(sdpToSend), }); } @@ -736,55 +809,72 @@ 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); peer.peerConnection = pc; 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)); const answer = await pc.createAnswer(); 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({ type: 'answer', from: state.peerId, to: fromPeerId, - sdp: JSON.stringify(pc.localDescription), + sdp: JSON.stringify(sdpToSend), }); } async function handleAnswer(fromPeerId, sdpJson) { const peer = state.peers.get(fromPeerId); if (!peer || !peer.peerConnection) { - console.warn('handleAnswer: no peer connection for', fromPeerId); + console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no peer connection`); return; } // Only set remote description if we're in the right state 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; } - console.log('handleAnswer: setting remote description for', fromPeerId); 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)); } - async function handleIceCandidate(fromPeerId, candidateJson) { + function handleIceCandidate(fromPeerId, candidateJson) { const peer = state.peers.get(fromPeerId); if (!peer || !peer.peerConnection) { - console.warn('handleIceCandidate: no peer connection for', fromPeerId); + console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no peer connection`); return; } - try { - const candidate = JSON.parse(candidateJson); - await peer.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)); - } catch (err) { - console.warn('handleIceCandidate: error adding candidate:', err); - } + const candidate = JSON.parse(candidateJson); + console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.candidate}`); + + 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 ====================