From cc57356a533592c49106085e848915b3c384b9bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 07:38:49 +0000 Subject: [PATCH 1/5] Fix ICE failures by replacing mDNS .local addresses with real peer IPs Modern browsers obfuscate local IP addresses with mDNS hostnames (e.g. abcdef12-3456.local) for privacy. When mDNS resolution is unavailable on the LAN, WebRTC ICE candidates cannot be resolved and the peer connection fails. This fix makes the signaling server extract each client's real IP from the TCP connection and share it via Welcome/PeerJoined messages. The client then replaces any .local mDNS addresses in SDP offers, answers, and trickle ICE candidates with the peer's actual IP address, enabling direct IP connectivity without relying on mDNS. https://claude.ai/code/session_01EZrFdyAR35RLwRdHUA9zPJ --- lanshare/src/main.rs | 30 ++++++++++++-- lanshare/static/index.html | 81 ++++++++++++++++++++++++-------------- 2 files changed, 78 insertions(+), 33 deletions(-) diff --git a/lanshare/src/main.rs b/lanshare/src/main.rs index 270521b..04fd728 100644 --- a/lanshare/src/main.rs +++ b/lanshare/src/main.rs @@ -1,5 +1,6 @@ use axum::{ extract::{ + connect_info::ConnectInfo, ws::{Message, WebSocket, WebSocketUpgrade}, State, }, @@ -63,6 +64,7 @@ enum SignalMessage { /// Server assigns an ID to a new peer Welcome { peer_id: String, + ip: String, peers: Vec, }, /// Peer announces themselves (with optional name) @@ -73,6 +75,7 @@ enum SignalMessage { PeerJoined { peer_id: String, name: String, + ip: String, }, /// Broadcast: a peer left PeerLeft { @@ -120,6 +123,7 @@ enum SignalMessage { struct PeerInfo { peer_id: String, name: String, + ip: String, is_sharing: bool, has_audio: bool, } @@ -127,6 +131,7 @@ struct PeerInfo { /// State for a connected peer struct PeerState { name: String, + ip: String, is_sharing: bool, has_audio: bool, tx: broadcast::Sender, @@ -150,6 +155,7 @@ impl AppState { .map(|entry| PeerInfo { peer_id: entry.key().clone(), name: entry.value().name.clone(), + ip: entry.value().ip.clone(), is_sharing: entry.value().is_sharing, has_audio: entry.value().has_audio, }) @@ -216,19 +222,27 @@ async fn main() { let config = RustlsConfig::from_pem_file(args.certificate, args.key).await.unwrap(); - axum_server::bind_rustls(addr, config).serve(app.into_make_service()).await.unwrap(); + axum_server::bind_rustls(addr, config) + .serve(app.into_make_service_with_connect_info::()) + .await + .unwrap(); } async fn serve_index() -> impl IntoResponse { Html(include_str!("../static/index.html")) } -async fn ws_handler(ws: WebSocketUpgrade, State(state): State>) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_socket(socket, state)) +async fn ws_handler( + ws: WebSocketUpgrade, + ConnectInfo(addr): ConnectInfo, + State(state): State>, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, state, addr)) } -async fn handle_socket(socket: WebSocket, state: Arc) { +async fn handle_socket(socket: WebSocket, state: Arc, addr: SocketAddr) { let peer_id = Uuid::new_v4().to_string(); + let peer_ip = addr.ip().to_string(); let (tx, _) = broadcast::channel::(64); // Add peer to state with default name @@ -236,6 +250,7 @@ async fn handle_socket(socket: WebSocket, state: Arc) { peer_id.clone(), PeerState { name: format!("Peer {}", &peer_id[..8]), + ip: peer_ip.clone(), is_sharing: false, has_audio: false, tx: tx.clone(), @@ -250,6 +265,7 @@ async fn handle_socket(socket: WebSocket, state: Arc) { // Send welcome message with current peer list let welcome = SignalMessage::Welcome { peer_id: peer_id.clone(), + ip: peer_ip, peers: peers.into_iter().filter(|p| p.peer_id != peer_id).collect(), }; @@ -319,6 +335,11 @@ async fn handle_socket(socket: WebSocket, state: Arc) { async fn handle_signal(state: &AppState, from_peer: &str, signal: SignalMessage) { match signal { 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) { peer.name = name.clone(); } @@ -326,6 +347,7 @@ async fn handle_signal(state: &AppState, from_peer: &str, signal: SignalMessage) SignalMessage::PeerJoined { peer_id: from_peer.to_string(), name, + ip, }, Some(from_peer), ); diff --git a/lanshare/static/index.html b/lanshare/static/index.html index 20efc15..60cf32a 100644 --- a/lanshare/static/index.html +++ b/lanshare/static/index.html @@ -428,8 +428,9 @@ // ==================== State ==================== const state = { peerId: null, + peerIp: null, // Our own IP as seen by the server peerName: localStorage.getItem('lanshare_name') || null, - peers: new Map(), // peer_id -> { name, isSharing, hasAudio, stream, peerConnection } + peers: new Map(), // peer_id -> { name, ip, isSharing, hasAudio, stream, peerConnection } localStream: null, isSharing: false, includeAudio: true, @@ -456,6 +457,14 @@ 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'}`); // ==================== DOM Elements ==================== @@ -537,20 +546,22 @@ switch (msg.type) { case 'welcome': state.peerId = msg.peer_id; + state.peerIp = msg.ip; + console.log(`Our IP as seen by server: ${msg.ip}`); connectingOverlay.style.display = 'none'; updateConnectionStatus(true); - + // Set name if (!state.peerName) { 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); } - + send({ type: 'announce', name: state.peerName }); - + // Add existing peers for (const peer of msg.peers) { - addPeer(peer.peer_id, peer.name, peer.is_sharing, peer.has_audio); + addPeer(peer.peer_id, peer.name, peer.ip, peer.is_sharing, peer.has_audio); // Request stream from peers that are already sharing if (peer.is_sharing) { requestStreamFrom(peer.peer_id); @@ -560,7 +571,7 @@ break; case 'peer_joined': - addPeer(msg.peer_id, msg.name, false, false); + addPeer(msg.peer_id, msg.name, msg.ip, false, false); // If we're sharing, proactively send an offer to the new peer if (state.isSharing && state.localStream) { await sendOfferTo(msg.peer_id); @@ -607,7 +618,7 @@ if (state.isSharing && state.localStream) { // Ensure we have this peer in our state (handles race condition) if (!state.peers.has(msg.from)) { - addPeer(msg.from, `Peer ${msg.from.slice(0, 4)}`, false, false); + addPeer(msg.from, `Peer ${msg.from.slice(0, 4)}`, null, false, false); } await sendOfferTo(msg.from); } @@ -628,11 +639,12 @@ } // ==================== Peer Management ==================== - function addPeer(peerId, name, isSharing, hasAudio) { + function addPeer(peerId, name, ip, isSharing, hasAudio) { if (peerId === state.peerId) return; - + state.peers.set(peerId, { name, + ip, isSharing, hasAudio, stream: null, @@ -681,13 +693,16 @@ 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) { + // Replace mDNS .local addresses with our real IP for direct LAN connectivity + const candidateObj = event.candidate.toJSON(); + candidateObj.candidate = replaceMdnsWithIp(candidateObj.candidate, state.peerIp); send({ type: 'ice_candidate', from: state.peerId, to: peerId, - candidate: JSON.stringify(event.candidate), + candidate: JSON.stringify(candidateObj), }); } } else { @@ -784,10 +799,11 @@ await waitForIceGathering(pc, peerId); } - const sdpToSend = pc.localDescription; + // Replace mDNS .local addresses with our real IP for direct LAN connectivity + const sdpToSend = { type: pc.localDescription.type, sdp: replaceMdnsWithIp(pc.localDescription.sdp, state.peerIp) }; 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, @@ -799,39 +815,42 @@ async function handleOffer(fromPeerId, sdpJson) { // Ensure we have this peer in state if (!state.peers.has(fromPeerId)) { - addPeer(fromPeerId, `Peer ${fromPeerId.slice(0, 4)}`, true, false); + addPeer(fromPeerId, `Peer ${fromPeerId.slice(0, 4)}`, null, true, false); } - + const peer = state.peers.get(fromPeerId); - + // Close existing connection if any (new offer = renegotiation) if (peer.peerConnection) { peer.peerConnection.close(); } - + console.log(`[${fromPeerId.slice(0,8)}] handleOffer: creating answer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`); const pc = createPeerConnection(fromPeerId); peer.peerConnection = pc; - + + // Replace any mDNS .local addresses in the remote SDP with the peer's real IP const sdp = JSON.parse(sdpJson); + sdp.sdp = replaceMdnsWithIp(sdp.sdp, peer.ip); 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; + + // Replace mDNS .local addresses with our real IP for direct LAN connectivity + const sdpToSend = { type: pc.localDescription.type, sdp: replaceMdnsWithIp(pc.localDescription.sdp, state.peerIp) }; 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, @@ -846,18 +865,20 @@ 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(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.peerConnection.signalingState}`); return; } - + + // Replace any mDNS .local addresses in the remote SDP with the peer's real IP const sdp = JSON.parse(sdpJson); + sdp.sdp = replaceMdnsWithIp(sdp.sdp, peer.ip); 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)); } @@ -868,10 +889,12 @@ console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no peer connection`); return; } - + + // Replace any mDNS .local addresses with the peer's real IP const candidate = JSON.parse(candidateJson); + candidate.candidate = replaceMdnsWithIp(candidate.candidate, peer.ip); 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)); From 4c223b47edb9f939bcc6beee61740335706291cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 09:16:51 +0000 Subject: [PATCH 2/5] Fix screenshare not visible until viewer reloads When a screenshare was started while both peers were already connected, the viewer never received the video stream. This worked after a reload because the peer_joined handler proactively sends an offer to the new peer. The root cause: startSharing() relied on a request_stream round-trip (viewer sends request_stream, sharer responds with offer). Since handleSignal is async but never awaited by ws.onmessage, this round-trip could silently fail when promises rejected or messages interleaved during the exchange. Fix by having the sharer proactively send offers to all connected peers in startSharing(), matching the existing peer_joined behavior. Also add a fallback in ontrack for when event.streams is empty. https://claude.ai/code/session_01ALSwS4S8EHiP81i2KMsb9Y --- lanshare/static/index.html | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/lanshare/static/index.html b/lanshare/static/index.html index 60cf32a..cf21526 100644 --- a/lanshare/static/index.html +++ b/lanshare/static/index.html @@ -714,9 +714,17 @@ console.log(`[${peerId.slice(0,8)}] Received track:`, event.track.kind); const peer = state.peers.get(peerId); if (peer) { - peer.stream = event.streams[0]; + // Use the associated stream, or build one from the track + // (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(); - + // Auto-select if this is the first/only stream if (!state.selectedPeerId) { selectPeer(peerId); @@ -913,13 +921,21 @@ stopSharing(); }; - // Notify server - other peers will send request_stream in response + // Notify server send({ type: 'started_sharing', peer_id: state.peerId, 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(); } catch (err) { console.error('Error starting screen share:', err); From 6ca45de838dfb10a9bcaf9f6f167ee331bcb6051 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 09:31:08 +0000 Subject: [PATCH 3/5] 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 --- lanshare/static/index.html | 113 +++++++++++++++++++++---------------- 1 file changed, 64 insertions(+), 49 deletions(-) diff --git a/lanshare/static/index.html b/lanshare/static/index.html index cf21526..3221900 100644 --- a/lanshare/static/index.html +++ b/lanshare/static/index.html @@ -430,7 +430,7 @@ peerId: null, peerIp: null, // Our own IP as seen by the server 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, isSharing: false, includeAudio: true, @@ -601,9 +601,11 @@ peer.isSharing = false; peer.hasAudio = false; peer.stream = null; - if (peer.peerConnection) { - peer.peerConnection.close(); - peer.peerConnection = null; + // Only close the incoming connection (their stream to us). + // Keep our outgoing connection if we're sharing to them. + if (peer.incomingPC) { + peer.incomingPC.close(); + peer.incomingPC = null; } if (state.selectedPeerId === msg.peer_id) { state.selectedPeerId = null; @@ -648,18 +650,18 @@ isSharing, hasAudio, 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) { const peer = state.peers.get(peerId); if (peer) { - if (peer.peerConnection) { - peer.peerConnection.close(); - } + if (peer.outgoingPC) peer.outgoingPC.close(); + if (peer.incomingPC) peer.incomingPC.close(); state.peers.delete(peerId); - + if (state.selectedPeerId === peerId) { state.selectedPeerId = null; mainVideo.srcObject = null; @@ -668,45 +670,47 @@ } // ==================== WebRTC ==================== - function createPeerConnection(peerId) { + // direction: 'outgoing' (we send offer) or 'incoming' (we receive offer) + function createPeerConnection(peerId, direction) { const pc = new RTCPeerConnection(rtcConfig); - + // Debug: Log ICE gathering state changes 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') { const sdp = pc.localDescription?.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}`)); } }; - + // Debug: Log ICE connection state 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 pc.onicecandidate = (event) => { if (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}`); 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({ type: 'ice_candidate', from: state.peerId, to: peerId, - candidate: JSON.stringify(candidateObj), + candidate: JSON.stringify({ ...candidateObj, _dir: direction }), }); } } 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 = () => { - console.log(`[${peerId.slice(0,8)}] Connection state: ${pc.connectionState}`); + console.log(`[${peerId.slice(0,8)}:${direction}] Connection state: ${pc.connectionState}`); }; return pc; @@ -781,16 +785,16 @@ console.warn('sendOfferTo: peer not found:', peerId); return; } - - // Don't create a new connection if we already have one that's not closed - if (peer.peerConnection && peer.peerConnection.connectionState !== 'closed') { - console.log(`[${peerId.slice(0,8)}] sendOfferTo: already have connection`); + + // Don't create a new outgoing connection if we already have one + if (peer.outgoingPC && peer.outgoingPC.connectionState !== 'closed') { + console.log(`[${peerId.slice(0,8)}] sendOfferTo: already have outgoing connection`); return; } - + console.log(`[${peerId.slice(0,8)}] sendOfferTo: creating offer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`); - const pc = createPeerConnection(peerId); - peer.peerConnection = pc; + const pc = createPeerConnection(peerId, 'outgoing'); + peer.outgoingPC = pc; // Add local stream tracks if (state.localStream) { @@ -828,14 +832,14 @@ const peer = state.peers.get(fromPeerId); - // Close existing connection if any (new offer = renegotiation) - if (peer.peerConnection) { - peer.peerConnection.close(); + // Close existing incoming connection if any (new offer = renegotiation) + if (peer.incomingPC) { + peer.incomingPC.close(); } console.log(`[${fromPeerId.slice(0,8)}] handleOffer: creating answer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`); - const pc = createPeerConnection(fromPeerId); - peer.peerConnection = pc; + const pc = createPeerConnection(fromPeerId, 'incoming'); + peer.incomingPC = pc; // Replace any mDNS .local addresses in the remote SDP with the peer's real IP const sdp = JSON.parse(sdpJson); @@ -869,14 +873,14 @@ async function handleAnswer(fromPeerId, sdpJson) { const peer = state.peers.get(fromPeerId); - if (!peer || !peer.peerConnection) { - console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no peer connection`); + if (!peer || !peer.outgoingPC) { + console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no outgoing peer connection`); return; } // Only set remote description if we're in the right state - if (peer.peerConnection.signalingState !== 'have-local-offer') { - console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.peerConnection.signalingState}`); + if (peer.outgoingPC.signalingState !== 'have-local-offer') { + console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.outgoingPC.signalingState}`); return; } @@ -888,22 +892,32 @@ 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.outgoingPC.setRemoteDescription(new RTCSessionDescription(sdp)); } function handleIceCandidate(fromPeerId, candidateJson) { const peer = state.peers.get(fromPeerId); - if (!peer || !peer.peerConnection) { - console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no peer connection`); + if (!peer) { + 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; } // Replace any mDNS .local addresses with the peer's real IP - const candidate = JSON.parse(candidateJson); - candidate.candidate = replaceMdnsWithIp(candidate.candidate, peer.ip); - console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.candidate}`); + data.candidate = replaceMdnsWithIp(data.candidate, peer.ip); + console.log(`[${fromPeerId.slice(0,8)}:${dir}] Remote candidate: ${data.candidate}`); - peer.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) + pc.addIceCandidate(new RTCIceCandidate(data)) .then(() => console.log(`[${fromPeerId.slice(0,8)}] Successfully added remote candidate`)) .catch(err => console.error(`[${fromPeerId.slice(0,8)}] Error adding ICE candidate:`, err)); } @@ -950,14 +964,15 @@ state.localStream.getTracks().forEach(track => track.stop()); state.localStream = null; } - + 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) { - if (peer.peerConnection) { - peer.peerConnection.close(); - peer.peerConnection = null; + if (peer.outgoingPC) { + peer.outgoingPC.close(); + peer.outgoingPC = null; } } From 9bfb66de86f67df2396352d89ce5daaec3254839 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 09:33:48 +0000 Subject: [PATCH 4/5] Hide share button on browsers that lack getDisplayMedia Mobile browsers typically don't support screen sharing. Hide the start-sharing button entirely when navigator.mediaDevices.getDisplayMedia is not available. https://claude.ai/code/session_01ALSwS4S8EHiP81i2KMsb9Y --- lanshare/static/index.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lanshare/static/index.html b/lanshare/static/index.html index 3221900..9eb60e3 100644 --- a/lanshare/static/index.html +++ b/lanshare/static/index.html @@ -1004,8 +1004,9 @@ // Update peer count peerCount.textContent = state.peers.size; - // Update sharing buttons - startShareBtn.classList.toggle('hidden', state.isSharing); + // Update sharing buttons (hide entirely if browser lacks getDisplayMedia) + const canShare = !!navigator.mediaDevices?.getDisplayMedia; + startShareBtn.classList.toggle('hidden', state.isSharing || !canShare); stopShareBtn.classList.toggle('hidden', !state.isSharing); // Update screen list From c617a371cfb6d665afe634e872710ba3095ed0ab Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 09:42:51 +0000 Subject: [PATCH 5/5] Fix iOS WebSocket hang with self-signed TLS certificates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS Safari silently rejects wss:// connections to servers using self-signed certificates — the trust exception accepted for the page does not extend to WebSocket handshakes. This causes the UI to hang on "Establishing WebSocket connection..." indefinitely. - Make --certificate and --key optional; server runs plain HTTP when omitted - When TLS is enabled, also start a plain HTTP listener on --http-port (default 8081) so iOS clients can connect via ws:// instead of wss:// - Add a 5-second connection timeout on the frontend that detects the hang and shows a clickable link to the HTTP fallback URL https://claude.ai/code/session_01VJ4CsBALnYcVhFJpY5cD5k --- lanshare/src/main.rs | 77 ++++++++++++++++++++++++++++++++------ lanshare/static/index.html | 32 +++++++++++++--- 2 files changed, 92 insertions(+), 17 deletions(-) diff --git a/lanshare/src/main.rs b/lanshare/src/main.rs index 04fd728..bb53c99 100644 --- a/lanshare/src/main.rs +++ b/lanshare/src/main.rs @@ -50,11 +50,26 @@ struct Args { #[arg(short, long, conflicts_with = "verbose")] 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)] - certificate: PathBuf, + certificate: Option, + /// TLS private key file (PEM format) #[arg(short, long)] - key: PathBuf, + key: Option, + + /// 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 @@ -199,6 +214,8 @@ async fn main() { .parse() .expect("Invalid host:port combination"); + let use_tls = args.certificate.is_some() && args.key.is_some(); + println!(); println!(" ╭─────────────────────────────────────────╮"); println!(" │ \x1b[1;33mLAN Share\x1b[0m │"); @@ -206,10 +223,25 @@ async fn main() { println!(" ╰─────────────────────────────────────────╯"); println!(); println!(" \x1b[1mServer running at:\x1b[0m"); - 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); + + if use_tls { + println!(" → https://{}", addr); + let http_addr: SocketAddr = format!("{}:{}", args.host, args.http_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!(); @@ -219,13 +251,36 @@ async fn main() { 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(); - let config = RustlsConfig::from_pem_file(args.certificate, args.key).await.unwrap(); + // Start a plain HTTP listener alongside HTTPS so that iOS devices + // (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::()) + .await + .unwrap(); + }); - axum_server::bind_rustls(addr, config) - .serve(app.into_make_service_with_connect_info::()) - .await - .unwrap(); + axum_server::bind_rustls(addr, config) + .serve(app.into_make_service_with_connect_info::()) + .await + .unwrap(); + } else { + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app.into_make_service_with_connect_info::()) + .await + .unwrap(); + } } async fn serve_index() -> impl IntoResponse { diff --git a/lanshare/static/index.html b/lanshare/static/index.html index 9eb60e3..22ba69b 100644 --- a/lanshare/static/index.html +++ b/lanshare/static/index.html @@ -500,25 +500,44 @@ function connect() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws`; - + connectionStatus.textContent = 'Establishing WebSocket connection...'; 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.
' + + 'Try the plain HTTP URL instead: ' + httpUrl + ''; + } + } + }, 5000); + state.ws.onopen = () => { + clearTimeout(connectTimeout); console.log('WebSocket connected'); state.reconnectAttempts = 0; connectionStatus.textContent = 'Connected, waiting for welcome...'; }; - + state.ws.onmessage = (event) => { const msg = JSON.parse(event.data); handleSignal(msg); }; - + state.ws.onclose = () => { + clearTimeout(connectTimeout); console.log('WebSocket disconnected'); updateConnectionStatus(false); - + if (state.reconnectAttempts < state.maxReconnectAttempts) { state.reconnectAttempts++; const delay = Math.min(1000 * Math.pow(2, state.reconnectAttempts), 30000); @@ -527,8 +546,9 @@ setTimeout(connect, delay); } }; - + state.ws.onerror = (err) => { + clearTimeout(connectTimeout); console.error('WebSocket error:', err); }; }