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::{
extract::{
connect_info::ConnectInfo,
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
@ -50,26 +49,11 @@ 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: Option<PathBuf>,
certificate: PathBuf,
/// TLS private key file (PEM format)
#[arg(short, long)]
key: Option<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,
key: PathBuf,
}
/// Messages sent between peers via the signaling server
@ -79,7 +63,6 @@ enum SignalMessage {
/// Server assigns an ID to a new peer
Welcome {
peer_id: String,
ip: String,
peers: Vec<PeerInfo>,
},
/// Peer announces themselves (with optional name)
@ -90,7 +73,6 @@ enum SignalMessage {
PeerJoined {
peer_id: String,
name: String,
ip: String,
},
/// Broadcast: a peer left
PeerLeft {
@ -138,7 +120,6 @@ enum SignalMessage {
struct PeerInfo {
peer_id: String,
name: String,
ip: String,
is_sharing: bool,
has_audio: bool,
}
@ -146,7 +127,6 @@ struct PeerInfo {
/// State for a connected peer
struct PeerState {
name: String,
ip: String,
is_sharing: bool,
has_audio: bool,
tx: broadcast::Sender<SignalMessage>,
@ -170,7 +150,6 @@ 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,
})
@ -214,8 +193,6 @@ 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 │");
@ -223,27 +200,12 @@ async fn main() {
println!(" ╰─────────────────────────────────────────╯");
println!();
println!(" \x1b[1mServer running at:\x1b[0m");
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!();
println!(" \x1b[90mOpen this URL in browsers on your local network.\x1b[0m");
println!(" \x1b[90mPress Ctrl+C to stop the server.\x1b[0m");
@ -251,53 +213,22 @@ 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();
// 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::<SocketAddr>())
.await
.unwrap();
});
let config = RustlsConfig::from_pem_file(args.certificate, args.key).await.unwrap();
axum_server::bind_rustls(addr, config)
.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();
}
axum_server::bind_rustls(addr, config).serve(app.into_make_service()).await.unwrap();
}
async fn serve_index() -> impl IntoResponse {
Html(include_str!("../static/index.html"))
}
async fn ws_handler(
ws: WebSocketUpgrade,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state, addr))
async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<AppState>>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
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_ip = addr.ip().to_string();
let (tx, _) = broadcast::channel::<SignalMessage>(64);
// 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(),
PeerState {
name: format!("Peer {}", &peer_id[..8]),
ip: peer_ip.clone(),
is_sharing: false,
has_audio: false,
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
let welcome = SignalMessage::Welcome {
peer_id: peer_id.clone(),
ip: peer_ip,
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) {
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();
}
@ -402,7 +326,6 @@ async fn handle_signal(state: &AppState, from_peer: &str, signal: SignalMessage)
SignalMessage::PeerJoined {
peer_id: from_peer.to_string(),
name,
ip,
},
Some(from_peer),
);

View file

@ -428,9 +428,8 @@
// ==================== 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, ip, isSharing, hasAudio, stream, outgoingPC, incomingPC }
peers: new Map(), // peer_id -> { name, isSharing, hasAudio, stream, peerConnection }
localStream: null,
isSharing: false,
includeAudio: true,
@ -457,14 +456,6 @@
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 ====================
@ -504,25 +495,7 @@
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.<br>' +
'Try the plain HTTP URL instead: <a href="' + httpUrl + '" style="color:#60a5fa;text-decoration:underline">' + httpUrl + '</a>';
}
}
}, 5000);
state.ws.onopen = () => {
clearTimeout(connectTimeout);
console.log('WebSocket connected');
state.reconnectAttempts = 0;
connectionStatus.textContent = 'Connected, waiting for welcome...';
@ -534,7 +507,6 @@
};
state.ws.onclose = () => {
clearTimeout(connectTimeout);
console.log('WebSocket disconnected');
updateConnectionStatus(false);
@ -548,7 +520,6 @@
};
state.ws.onerror = (err) => {
clearTimeout(connectTimeout);
console.error('WebSocket error:', err);
};
}
@ -566,8 +537,6 @@
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);
@ -581,7 +550,7 @@
// Add existing 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
if (peer.is_sharing) {
requestStreamFrom(peer.peer_id);
@ -591,7 +560,7 @@
break;
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 (state.isSharing && state.localStream) {
await sendOfferTo(msg.peer_id);
@ -621,11 +590,9 @@
peer.isSharing = false;
peer.hasAudio = false;
peer.stream = 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 (peer.peerConnection) {
peer.peerConnection.close();
peer.peerConnection = null;
}
if (state.selectedPeerId === msg.peer_id) {
state.selectedPeerId = null;
@ -640,7 +607,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)}`, null, false, false);
addPeer(msg.from, `Peer ${msg.from.slice(0, 4)}`, false, false);
}
await sendOfferTo(msg.from);
}
@ -661,25 +628,24 @@
}
// ==================== Peer Management ====================
function addPeer(peerId, name, ip, isSharing, hasAudio) {
function addPeer(peerId, name, isSharing, hasAudio) {
if (peerId === state.peerId) return;
state.peers.set(peerId, {
name,
ip,
isSharing,
hasAudio,
stream: null,
outgoingPC: null, // PC where we send our stream to this peer
incomingPC: null, // PC where we receive this peer's stream
peerConnection: null,
});
}
function removePeer(peerId) {
const peer = state.peers.get(peerId);
if (peer) {
if (peer.outgoingPC) peer.outgoingPC.close();
if (peer.incomingPC) peer.incomingPC.close();
if (peer.peerConnection) {
peer.peerConnection.close();
}
state.peers.delete(peerId);
if (state.selectedPeerId === peerId) {
@ -690,47 +656,42 @@
}
// ==================== WebRTC ====================
// direction: 'outgoing' (we send offer) or 'incoming' (we receive offer)
function createPeerConnection(peerId, direction) {
function createPeerConnection(peerId) {
const pc = new RTCPeerConnection(rtcConfig);
// Debug: Log ICE gathering state changes
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') {
const sdp = pc.localDescription?.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}`));
}
};
// Debug: Log ICE connection state
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
pc.onicecandidate = (event) => {
if (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}`);
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, _dir: direction }),
candidate: JSON.stringify(event.candidate),
});
}
} 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,15 +699,7 @@
console.log(`[${peerId.slice(0,8)}] Received track:`, event.track.kind);
const peer = state.peers.get(peerId);
if (peer) {
// 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
@ -757,7 +710,7 @@
};
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;
@ -806,15 +759,15 @@
return;
}
// 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`);
// 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`);
return;
}
console.log(`[${peerId.slice(0,8)}] sendOfferTo: creating offer (${USE_TRICKLE_ICE ? 'trickle' : 'bundled'})`);
const pc = createPeerConnection(peerId, 'outgoing');
peer.outgoingPC = pc;
const pc = createPeerConnection(peerId);
peer.peerConnection = pc;
// Add local stream tracks
if (state.localStream) {
@ -831,8 +784,7 @@
await waitForIceGathering(pc, peerId);
}
// 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 sdpToSend = pc.localDescription;
const candidates = extractCandidatesFromSDP(sdpToSend.sdp);
console.log(`[${peerId.slice(0,8)}] Sending offer with ${candidates.length} candidates in SDP`);
@ -847,23 +799,21 @@
async function handleOffer(fromPeerId, sdpJson) {
// Ensure we have this peer in state
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);
// Close existing incoming connection if any (new offer = renegotiation)
if (peer.incomingPC) {
peer.incomingPC.close();
// 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, 'incoming');
peer.incomingPC = pc;
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}`));
@ -878,8 +828,7 @@
await waitForIceGathering(pc, fromPeerId);
}
// 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 sdpToSend = pc.localDescription;
const localCandidates = extractCandidatesFromSDP(sdpToSend.sdp);
console.log(`[${fromPeerId.slice(0,8)}] Sending answer with ${localCandidates.length} candidates in SDP`);
@ -893,51 +842,37 @@
async function handleAnswer(fromPeerId, sdpJson) {
const peer = state.peers.get(fromPeerId);
if (!peer || !peer.outgoingPC) {
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no outgoing peer connection`);
if (!peer || !peer.peerConnection) {
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no peer connection`);
return;
}
// Only set remote description if we're in the right state
if (peer.outgoingPC.signalingState !== 'have-local-offer') {
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.outgoingPC.signalingState}`);
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.outgoingPC.setRemoteDescription(new RTCSessionDescription(sdp));
await peer.peerConnection.setRemoteDescription(new RTCSessionDescription(sdp));
}
function handleIceCandidate(fromPeerId, candidateJson) {
const peer = state.peers.get(fromPeerId);
if (!peer) {
console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: peer not found`);
if (!peer || !peer.peerConnection) {
console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no peer connection`);
return;
}
const data = JSON.parse(candidateJson);
const dir = data._dir;
delete data._dir;
const candidate = JSON.parse(candidateJson);
console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.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))
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));
}
@ -955,21 +890,13 @@
stopSharing();
};
// Notify server
// Notify server - other peers will send request_stream in response
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);
@ -987,12 +914,11 @@
state.isSharing = false;
// Only close outgoing connections (where we were sending our stream).
// Keep incoming connections alive so we can still view others' streams.
// Close all peer connections we initiated
for (const [peerId, peer] of state.peers) {
if (peer.outgoingPC) {
peer.outgoingPC.close();
peer.outgoingPC = null;
if (peer.peerConnection) {
peer.peerConnection.close();
peer.peerConnection = null;
}
}
@ -1024,9 +950,8 @@
// Update peer count
peerCount.textContent = state.peers.size;
// Update sharing buttons (hide entirely if browser lacks getDisplayMedia)
const canShare = !!navigator.mediaDevices?.getDisplayMedia;
startShareBtn.classList.toggle('hidden', state.isSharing || !canShare);
// Update sharing buttons
startShareBtn.classList.toggle('hidden', state.isSharing);
stopShareBtn.classList.toggle('hidden', !state.isSharing);
// Update screen list