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 │
│ (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

View file

@ -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 ====================