Compare commits

..

No commits in common. "0df86e79ffd1fd0479fa50aa518a58c07ee0cca6" and "accbfdb1eafe5ebb4e1bceb2ba70cd1289772139" have entirely different histories.

2 changed files with 98 additions and 250 deletions

View file

@ -1,6 +1,5 @@
use axum::{ use axum::{
extract::{ extract::{
connect_info::ConnectInfo,
ws::{Message, WebSocket, WebSocketUpgrade}, ws::{Message, WebSocket, WebSocketUpgrade},
State, State,
}, },
@ -50,26 +49,11 @@ struct Args {
#[arg(short, long, conflicts_with = "verbose")] #[arg(short, long, conflicts_with = "verbose")]
quiet: bool, quiet: bool,
/// TLS certificate file (PEM format)
///
/// When provided along with --key, the server will use HTTPS/WSS.
/// A plain HTTP listener is also started on --http-port for clients
/// that cannot validate the certificate (e.g. iOS with self-signed certs).
/// If omitted, the server runs over plain HTTP only.
#[arg(short, long)] #[arg(short, long)]
certificate: Option<PathBuf>, certificate: PathBuf,
/// TLS private key file (PEM format)
#[arg(short, long)] #[arg(short, long)]
key: Option<PathBuf>, key: PathBuf,
/// Port for the plain HTTP listener (used alongside HTTPS)
///
/// When TLS is enabled, a second HTTP-only listener is started on this
/// port so that iOS devices (which reject self-signed WSS certs) can
/// connect via ws:// instead.
#[arg(long, default_value_t = 8081, value_name = "PORT")]
http_port: u16,
} }
/// Messages sent between peers via the signaling server /// Messages sent between peers via the signaling server
@ -79,7 +63,6 @@ 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)
@ -90,7 +73,6 @@ 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 {
@ -138,7 +120,6 @@ 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,
} }
@ -146,7 +127,6 @@ 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>,
@ -170,7 +150,6 @@ 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,
}) })
@ -214,8 +193,6 @@ async fn main() {
.parse() .parse()
.expect("Invalid host:port combination"); .expect("Invalid host:port combination");
let use_tls = args.certificate.is_some() && args.key.is_some();
println!(); println!();
println!(" ╭─────────────────────────────────────────╮"); println!(" ╭─────────────────────────────────────────╮");
println!("\x1b[1;33mLAN Share\x1b[0m │"); println!("\x1b[1;33mLAN Share\x1b[0m │");
@ -223,25 +200,10 @@ async fn main() {
println!(" ╰─────────────────────────────────────────╯"); println!(" ╰─────────────────────────────────────────╯");
println!(); println!();
println!(" \x1b[1mServer running at:\x1b[0m"); println!(" \x1b[1mServer running at:\x1b[0m");
println!(" → http://{}", addr);
if use_tls { if args.host == "0.0.0.0" {
println!(" → https://{}", addr); if let Ok(local_ip) = local_ip_address::local_ip() {
let http_addr: SocketAddr = format!("{}:{}", args.host, args.http_port) println!(" → http://{}:{}", local_ip, args.port);
.parse()
.expect("Invalid host:http_port combination");
println!(" → http://{} \x1b[90m(plain HTTP for iOS)\x1b[0m", http_addr);
if args.host == "0.0.0.0" {
if let Ok(local_ip) = local_ip_address::local_ip() {
println!(" → https://{}:{}", local_ip, args.port);
println!(" → http://{}:{} \x1b[90m(plain HTTP for iOS)\x1b[0m", local_ip, args.http_port);
}
}
} else {
println!(" → http://{}", addr);
if args.host == "0.0.0.0" {
if let Ok(local_ip) = local_ip_address::local_ip() {
println!(" → http://{}:{}", local_ip, args.port);
}
} }
} }
println!(); println!();
@ -251,53 +213,22 @@ async fn main() {
info!("Server starting on {}", addr); info!("Server starting on {}", addr);
if use_tls {
let cert = args.certificate.unwrap();
let key = args.key.unwrap();
let config = RustlsConfig::from_pem_file(cert, key).await.unwrap();
// Start a plain HTTP listener alongside HTTPS so that iOS devices let config = RustlsConfig::from_pem_file(args.certificate, args.key).await.unwrap();
// (which silently reject self-signed WSS certificates) can connect
// via ws:// instead.
let http_addr: SocketAddr = format!("{}:{}", args.host, args.http_port)
.parse()
.expect("Invalid host:http_port combination");
let http_app = app.clone();
info!("Plain HTTP listener on {}", http_addr);
tokio::spawn(async move {
let listener = tokio::net::TcpListener::bind(http_addr).await.unwrap();
axum::serve(listener, http_app.into_make_service_with_connect_info::<SocketAddr>())
.await
.unwrap();
});
axum_server::bind_rustls(addr, config) axum_server::bind_rustls(addr, config).serve(app.into_make_service()).await.unwrap();
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
.await
.unwrap();
} else {
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, 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( async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<AppState>>) -> impl IntoResponse {
ws: WebSocketUpgrade, ws.on_upgrade(move |socket| handle_socket(socket, state))
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>, addr: SocketAddr) { async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
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
@ -305,7 +236,6 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, addr: SocketAddr
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(),
@ -320,7 +250,6 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, addr: SocketAddr
// 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(),
}; };
@ -390,11 +319,6 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, addr: SocketAddr
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();
} }
@ -402,7 +326,6 @@ 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),
); );

View file

@ -428,9 +428,8 @@
// ==================== 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, ip, isSharing, hasAudio, stream, outgoingPC, incomingPC } peers: new Map(), // peer_id -> { name, isSharing, hasAudio, stream, peerConnection }
localStream: null, localStream: null,
isSharing: false, isSharing: false,
includeAudio: true, includeAudio: true,
@ -457,14 +456,6 @@
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 ====================
@ -500,44 +491,25 @@
function connect() { function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`; const wsUrl = `${protocol}//${window.location.host}/ws`;
connectionStatus.textContent = 'Establishing WebSocket connection...'; connectionStatus.textContent = 'Establishing WebSocket connection...';
state.ws = new WebSocket(wsUrl); state.ws = new WebSocket(wsUrl);
// iOS Safari silently rejects wss:// connections to servers with
// self-signed certificates — onopen never fires and the UI hangs.
// Use a timeout to detect this and show a helpful message.
const connectTimeout = setTimeout(() => {
if (state.ws && state.ws.readyState !== WebSocket.OPEN) {
console.warn('WebSocket connection timed out');
state.ws.close();
if (window.location.protocol === 'https:') {
const httpPort = parseInt(window.location.port || '443', 10) + 1;
const httpUrl = `http://${window.location.hostname}:${httpPort}`;
connectionStatus.innerHTML =
'Connection failed — iOS may not support self-signed WSS certificates.<br>' +
'Try the plain HTTP URL instead: <a href="' + httpUrl + '" style="color:#60a5fa;text-decoration:underline">' + httpUrl + '</a>';
}
}
}, 5000);
state.ws.onopen = () => { state.ws.onopen = () => {
clearTimeout(connectTimeout);
console.log('WebSocket connected'); console.log('WebSocket connected');
state.reconnectAttempts = 0; state.reconnectAttempts = 0;
connectionStatus.textContent = 'Connected, waiting for welcome...'; connectionStatus.textContent = 'Connected, waiting for welcome...';
}; };
state.ws.onmessage = (event) => { state.ws.onmessage = (event) => {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
handleSignal(msg); handleSignal(msg);
}; };
state.ws.onclose = () => { state.ws.onclose = () => {
clearTimeout(connectTimeout);
console.log('WebSocket disconnected'); console.log('WebSocket disconnected');
updateConnectionStatus(false); updateConnectionStatus(false);
if (state.reconnectAttempts < state.maxReconnectAttempts) { if (state.reconnectAttempts < state.maxReconnectAttempts) {
state.reconnectAttempts++; state.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, state.reconnectAttempts), 30000); const delay = Math.min(1000 * Math.pow(2, state.reconnectAttempts), 30000);
@ -546,9 +518,8 @@
setTimeout(connect, delay); setTimeout(connect, delay);
} }
}; };
state.ws.onerror = (err) => { state.ws.onerror = (err) => {
clearTimeout(connectTimeout);
console.error('WebSocket error:', err); console.error('WebSocket error:', err);
}; };
} }
@ -566,22 +537,20 @@
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.ip, peer.is_sharing, peer.has_audio); addPeer(peer.peer_id, peer.name, 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);
@ -591,7 +560,7 @@
break; break;
case 'peer_joined': case 'peer_joined':
addPeer(msg.peer_id, msg.name, msg.ip, false, false); addPeer(msg.peer_id, msg.name, 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);
@ -621,11 +590,9 @@
peer.isSharing = false; peer.isSharing = false;
peer.hasAudio = false; peer.hasAudio = false;
peer.stream = null; peer.stream = null;
// Only close the incoming connection (their stream to us). if (peer.peerConnection) {
// Keep our outgoing connection if we're sharing to them. peer.peerConnection.close();
if (peer.incomingPC) { peer.peerConnection = null;
peer.incomingPC.close();
peer.incomingPC = null;
} }
if (state.selectedPeerId === msg.peer_id) { if (state.selectedPeerId === msg.peer_id) {
state.selectedPeerId = null; state.selectedPeerId = null;
@ -640,7 +607,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)}`, null, false, false); addPeer(msg.from, `Peer ${msg.from.slice(0, 4)}`, false, false);
} }
await sendOfferTo(msg.from); await sendOfferTo(msg.from);
} }
@ -661,27 +628,26 @@
} }
// ==================== Peer Management ==================== // ==================== Peer Management ====================
function addPeer(peerId, name, ip, isSharing, hasAudio) { function addPeer(peerId, name, 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,
outgoingPC: null, // PC where we send our stream to this peer peerConnection: null,
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.outgoingPC) peer.outgoingPC.close(); if (peer.peerConnection) {
if (peer.incomingPC) peer.incomingPC.close(); peer.peerConnection.close();
}
state.peers.delete(peerId); state.peers.delete(peerId);
if (state.selectedPeerId === peerId) { if (state.selectedPeerId === peerId) {
state.selectedPeerId = null; state.selectedPeerId = null;
mainVideo.srcObject = null; mainVideo.srcObject = null;
@ -690,47 +656,42 @@
} }
// ==================== WebRTC ==================== // ==================== WebRTC ====================
// direction: 'outgoing' (we send offer) or 'incoming' (we receive offer) function createPeerConnection(peerId) {
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)}:${direction}] ICE gathering state: ${pc.iceGatheringState}`); console.log(`[${peerId.slice(0,8)}] 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)}:${direction}] Final SDP has ${candidates.length} candidates:`); console.log(`[${peerId.slice(0,8)}] 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)}:${direction}] ICE connection state: ${pc.iceConnectionState}`); console.log(`[${peerId.slice(0,8)}] 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)}:${direction}] 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);
// 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, _dir: direction }), candidate: JSON.stringify(event.candidate),
}); });
} }
} else { } else {
console.log(`[${peerId.slice(0,8)}:${direction}] ICE candidate gathering complete (null candidate)`); console.log(`[${peerId.slice(0,8)}] ICE candidate gathering complete (null candidate)`);
} }
}; };
@ -738,17 +699,9 @@
console.log(`[${peerId.slice(0,8)}] Received track:`, event.track.kind); 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) {
// Use the associated stream, or build one from the track peer.stream = event.streams[0];
// (event.streams can be empty in some WebRTC edge cases)
if (event.streams[0]) {
peer.stream = event.streams[0];
} else if (!peer.stream) {
peer.stream = new MediaStream([event.track]);
} else {
peer.stream.addTrack(event.track);
}
updateUI(); updateUI();
// Auto-select if this is the first/only stream // Auto-select if this is the first/only stream
if (!state.selectedPeerId) { if (!state.selectedPeerId) {
selectPeer(peerId); selectPeer(peerId);
@ -757,7 +710,7 @@
}; };
pc.onconnectionstatechange = () => { pc.onconnectionstatechange = () => {
console.log(`[${peerId.slice(0,8)}:${direction}] Connection state: ${pc.connectionState}`); console.log(`[${peerId.slice(0,8)}] Connection state: ${pc.connectionState}`);
}; };
return pc; return pc;
@ -805,16 +758,16 @@
console.warn('sendOfferTo: peer not found:', peerId); console.warn('sendOfferTo: peer not found:', peerId);
return; return;
} }
// Don't create a new outgoing connection if we already have one // Don't create a new connection if we already have one that's not closed
if (peer.outgoingPC && peer.outgoingPC.connectionState !== 'closed') { if (peer.peerConnection && peer.peerConnection.connectionState !== 'closed') {
console.log(`[${peerId.slice(0,8)}] sendOfferTo: already have outgoing connection`); console.log(`[${peerId.slice(0,8)}] sendOfferTo: already have 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, 'outgoing'); const pc = createPeerConnection(peerId);
peer.outgoingPC = pc; peer.peerConnection = pc;
// Add local stream tracks // Add local stream tracks
if (state.localStream) { if (state.localStream) {
@ -831,11 +784,10 @@
await waitForIceGathering(pc, peerId); await waitForIceGathering(pc, peerId);
} }
// Replace mDNS .local addresses with our real IP for direct LAN connectivity const sdpToSend = pc.localDescription;
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,
@ -847,42 +799,39 @@
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)}`, null, true, false); addPeer(fromPeerId, `Peer ${fromPeerId.slice(0, 4)}`, true, false);
} }
const peer = state.peers.get(fromPeerId); const peer = state.peers.get(fromPeerId);
// Close existing incoming connection if any (new offer = renegotiation) // Close existing connection if any (new offer = renegotiation)
if (peer.incomingPC) { if (peer.peerConnection) {
peer.incomingPC.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, 'incoming'); const pc = createPeerConnection(fromPeerId);
peer.incomingPC = 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);
} }
// Replace mDNS .local addresses with our real IP for direct LAN connectivity const sdpToSend = pc.localDescription;
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,
@ -893,51 +842,37 @@
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.outgoingPC) { if (!peer || !peer.peerConnection) {
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no outgoing 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.outgoingPC.signalingState !== 'have-local-offer') { if (peer.peerConnection.signalingState !== 'have-local-offer') {
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.outgoingPC.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.outgoingPC.setRemoteDescription(new RTCSessionDescription(sdp)); await peer.peerConnection.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) { if (!peer || !peer.peerConnection) {
console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: peer not found`); console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no peer connection`);
return; return;
} }
const data = JSON.parse(candidateJson); const candidate = JSON.parse(candidateJson);
const dir = data._dir; console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.candidate}`);
delete data._dir;
peer.peerConnection.addIceCandidate(new RTCIceCandidate(candidate))
// 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;
}
// Replace any mDNS .local addresses with the peer's real IP
data.candidate = replaceMdnsWithIp(data.candidate, peer.ip);
console.log(`[${fromPeerId.slice(0,8)}:${dir}] Remote candidate: ${data.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));
} }
@ -955,21 +890,13 @@
stopSharing(); stopSharing();
}; };
// Notify server // Notify server - other peers will send request_stream in response
send({ send({
type: 'started_sharing', type: 'started_sharing',
peer_id: state.peerId, peer_id: state.peerId,
has_audio: stream.getAudioTracks().length > 0, has_audio: stream.getAudioTracks().length > 0,
}); });
// Proactively send offers to all connected peers so they
// receive the stream immediately. Without this, we rely on
// a request_stream round-trip that can silently fail because
// handleSignal is async but never awaited by ws.onmessage.
for (const [peerId] of state.peers) {
sendOfferTo(peerId);
}
updateUI(); updateUI();
} catch (err) { } catch (err) {
console.error('Error starting screen share:', err); console.error('Error starting screen share:', err);
@ -984,15 +911,14 @@
state.localStream.getTracks().forEach(track => track.stop()); state.localStream.getTracks().forEach(track => track.stop());
state.localStream = null; state.localStream = null;
} }
state.isSharing = false; state.isSharing = false;
// Only close outgoing connections (where we were sending our stream). // Close all peer connections we initiated
// 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.outgoingPC) { if (peer.peerConnection) {
peer.outgoingPC.close(); peer.peerConnection.close();
peer.outgoingPC = null; peer.peerConnection = null;
} }
} }
@ -1024,9 +950,8 @@
// Update peer count // Update peer count
peerCount.textContent = state.peers.size; peerCount.textContent = state.peers.size;
// Update sharing buttons (hide entirely if browser lacks getDisplayMedia) // Update sharing buttons
const canShare = !!navigator.mediaDevices?.getDisplayMedia; startShareBtn.classList.toggle('hidden', state.isSharing);
startShareBtn.classList.toggle('hidden', state.isSharing || !canShare);
stopShareBtn.classList.toggle('hidden', !state.isSharing); stopShareBtn.classList.toggle('hidden', !state.isSharing);
// Update screen list // Update screen list