Compare commits
10 commits
accbfdb1ea
...
0df86e79ff
| Author | SHA1 | Date | |
|---|---|---|---|
| 0df86e79ff | |||
|
|
c617a371cf | ||
| bd3a5e2af0 | |||
|
|
9bfb66de86 | ||
| c432384aec | |||
|
|
6ca45de838 | ||
| 8cc9ab0d46 | |||
|
|
4c223b47ed | ||
| 6193fe4cf1 | |||
|
|
cc57356a53 |
2 changed files with 250 additions and 98 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{
|
extract::{
|
||||||
|
connect_info::ConnectInfo,
|
||||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
State,
|
State,
|
||||||
},
|
},
|
||||||
|
|
@ -49,11 +50,26 @@ 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: PathBuf,
|
certificate: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// TLS private key file (PEM format)
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
key: PathBuf,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Messages sent between peers via the signaling server
|
/// Messages sent between peers via the signaling server
|
||||||
|
|
@ -63,6 +79,7 @@ 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)
|
||||||
|
|
@ -73,6 +90,7 @@ 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 {
|
||||||
|
|
@ -120,6 +138,7 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
@ -127,6 +146,7 @@ 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>,
|
||||||
|
|
@ -150,6 +170,7 @@ 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,
|
||||||
})
|
})
|
||||||
|
|
@ -193,6 +214,8 @@ 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 │");
|
||||||
|
|
@ -200,10 +223,25 @@ 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 args.host == "0.0.0.0" {
|
if use_tls {
|
||||||
if let Ok(local_ip) = local_ip_address::local_ip() {
|
println!(" → https://{}", addr);
|
||||||
println!(" → http://{}:{}", local_ip, args.port);
|
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!();
|
||||||
|
|
@ -213,22 +251,53 @@ 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();
|
||||||
|
|
||||||
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::<SocketAddr>())
|
||||||
|
.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::<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(ws: WebSocketUpgrade, State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
async fn ws_handler(
|
||||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
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 handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, addr: SocketAddr) {
|
||||||
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
|
||||||
|
|
@ -236,6 +305,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
||||||
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(),
|
||||||
|
|
@ -250,6 +320,7 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
||||||
// 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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -319,6 +390,11 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
@ -326,6 +402,7 @@ 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),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -428,8 +428,9 @@
|
||||||
// ==================== 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, isSharing, hasAudio, stream, peerConnection }
|
peers: new Map(), // peer_id -> { name, ip, isSharing, hasAudio, stream, outgoingPC, incomingPC }
|
||||||
localStream: null,
|
localStream: null,
|
||||||
isSharing: false,
|
isSharing: false,
|
||||||
includeAudio: true,
|
includeAudio: true,
|
||||||
|
|
@ -456,6 +457,14 @@
|
||||||
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 ====================
|
||||||
|
|
@ -495,7 +504,25 @@
|
||||||
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...';
|
||||||
|
|
@ -507,6 +534,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
state.ws.onclose = () => {
|
state.ws.onclose = () => {
|
||||||
|
clearTimeout(connectTimeout);
|
||||||
console.log('WebSocket disconnected');
|
console.log('WebSocket disconnected');
|
||||||
updateConnectionStatus(false);
|
updateConnectionStatus(false);
|
||||||
|
|
||||||
|
|
@ -520,6 +548,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
state.ws.onerror = (err) => {
|
state.ws.onerror = (err) => {
|
||||||
|
clearTimeout(connectTimeout);
|
||||||
console.error('WebSocket error:', err);
|
console.error('WebSocket error:', err);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -537,6 +566,8 @@
|
||||||
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);
|
||||||
|
|
||||||
|
|
@ -550,7 +581,7 @@
|
||||||
|
|
||||||
// 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.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
|
// Request stream from peers that are already sharing
|
||||||
if (peer.is_sharing) {
|
if (peer.is_sharing) {
|
||||||
requestStreamFrom(peer.peer_id);
|
requestStreamFrom(peer.peer_id);
|
||||||
|
|
@ -560,7 +591,7 @@
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'peer_joined':
|
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 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);
|
||||||
|
|
@ -590,9 +621,11 @@
|
||||||
peer.isSharing = false;
|
peer.isSharing = false;
|
||||||
peer.hasAudio = false;
|
peer.hasAudio = false;
|
||||||
peer.stream = null;
|
peer.stream = null;
|
||||||
if (peer.peerConnection) {
|
// Only close the incoming connection (their stream to us).
|
||||||
peer.peerConnection.close();
|
// Keep our outgoing connection if we're sharing to them.
|
||||||
peer.peerConnection = null;
|
if (peer.incomingPC) {
|
||||||
|
peer.incomingPC.close();
|
||||||
|
peer.incomingPC = null;
|
||||||
}
|
}
|
||||||
if (state.selectedPeerId === msg.peer_id) {
|
if (state.selectedPeerId === msg.peer_id) {
|
||||||
state.selectedPeerId = null;
|
state.selectedPeerId = null;
|
||||||
|
|
@ -607,7 +640,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)}`, false, false);
|
addPeer(msg.from, `Peer ${msg.from.slice(0, 4)}`, null, false, false);
|
||||||
}
|
}
|
||||||
await sendOfferTo(msg.from);
|
await sendOfferTo(msg.from);
|
||||||
}
|
}
|
||||||
|
|
@ -628,24 +661,25 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Peer Management ====================
|
// ==================== Peer Management ====================
|
||||||
function addPeer(peerId, name, isSharing, hasAudio) {
|
function addPeer(peerId, name, ip, 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,
|
||||||
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) {
|
function removePeer(peerId) {
|
||||||
const peer = state.peers.get(peerId);
|
const peer = state.peers.get(peerId);
|
||||||
if (peer) {
|
if (peer) {
|
||||||
if (peer.peerConnection) {
|
if (peer.outgoingPC) peer.outgoingPC.close();
|
||||||
peer.peerConnection.close();
|
if (peer.incomingPC) peer.incomingPC.close();
|
||||||
}
|
|
||||||
state.peers.delete(peerId);
|
state.peers.delete(peerId);
|
||||||
|
|
||||||
if (state.selectedPeerId === peerId) {
|
if (state.selectedPeerId === peerId) {
|
||||||
|
|
@ -656,42 +690,47 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== WebRTC ====================
|
// ==================== WebRTC ====================
|
||||||
function createPeerConnection(peerId) {
|
// direction: 'outgoing' (we send offer) or 'incoming' (we receive offer)
|
||||||
|
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)}] ICE gathering state: ${pc.iceGatheringState}`);
|
console.log(`[${peerId.slice(0,8)}:${direction}] 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)}] 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}`));
|
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)}] 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
|
// 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)}] 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}`);
|
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(event.candidate),
|
candidate: JSON.stringify({ ...candidateObj, _dir: direction }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} 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)`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -699,7 +738,15 @@
|
||||||
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) {
|
||||||
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();
|
updateUI();
|
||||||
|
|
||||||
// Auto-select if this is the first/only stream
|
// Auto-select if this is the first/only stream
|
||||||
|
|
@ -710,7 +757,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.onconnectionstatechange = () => {
|
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;
|
return pc;
|
||||||
|
|
@ -759,15 +806,15 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't create a new connection if we already have one that's not closed
|
// Don't create a new outgoing connection if we already have one
|
||||||
if (peer.peerConnection && peer.peerConnection.connectionState !== 'closed') {
|
if (peer.outgoingPC && peer.outgoingPC.connectionState !== 'closed') {
|
||||||
console.log(`[${peerId.slice(0,8)}] sendOfferTo: already have connection`);
|
console.log(`[${peerId.slice(0,8)}] sendOfferTo: already have outgoing 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);
|
const pc = createPeerConnection(peerId, 'outgoing');
|
||||||
peer.peerConnection = pc;
|
peer.outgoingPC = pc;
|
||||||
|
|
||||||
// Add local stream tracks
|
// Add local stream tracks
|
||||||
if (state.localStream) {
|
if (state.localStream) {
|
||||||
|
|
@ -784,7 +831,8 @@
|
||||||
await waitForIceGathering(pc, peerId);
|
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);
|
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`);
|
||||||
|
|
||||||
|
|
@ -799,21 +847,23 @@
|
||||||
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)}`, true, false);
|
addPeer(fromPeerId, `Peer ${fromPeerId.slice(0, 4)}`, null, true, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const peer = state.peers.get(fromPeerId);
|
const peer = state.peers.get(fromPeerId);
|
||||||
|
|
||||||
// Close existing connection if any (new offer = renegotiation)
|
// Close existing incoming connection if any (new offer = renegotiation)
|
||||||
if (peer.peerConnection) {
|
if (peer.incomingPC) {
|
||||||
peer.peerConnection.close();
|
peer.incomingPC.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);
|
const pc = createPeerConnection(fromPeerId, 'incoming');
|
||||||
peer.peerConnection = pc;
|
peer.incomingPC = 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}`));
|
||||||
|
|
@ -828,7 +878,8 @@
|
||||||
await waitForIceGathering(pc, fromPeerId);
|
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);
|
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`);
|
||||||
|
|
||||||
|
|
@ -842,37 +893,51 @@
|
||||||
|
|
||||||
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.peerConnection) {
|
if (!peer || !peer.outgoingPC) {
|
||||||
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no peer connection`);
|
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: no outgoing 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.peerConnection.signalingState !== 'have-local-offer') {
|
if (peer.outgoingPC.signalingState !== 'have-local-offer') {
|
||||||
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.peerConnection.signalingState}`);
|
console.warn(`[${fromPeerId.slice(0,8)}] handleAnswer: wrong state: ${peer.outgoingPC.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.peerConnection.setRemoteDescription(new RTCSessionDescription(sdp));
|
await peer.outgoingPC.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 || !peer.peerConnection) {
|
if (!peer) {
|
||||||
console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: no peer connection`);
|
console.warn(`[${fromPeerId.slice(0,8)}] handleIceCandidate: peer not found`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate = JSON.parse(candidateJson);
|
const data = JSON.parse(candidateJson);
|
||||||
console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.candidate}`);
|
const dir = data._dir;
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
|
|
@ -890,13 +955,21 @@
|
||||||
stopSharing();
|
stopSharing();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Notify server - other peers will send request_stream in response
|
// Notify server
|
||||||
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);
|
||||||
|
|
@ -914,11 +987,12 @@
|
||||||
|
|
||||||
state.isSharing = false;
|
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) {
|
for (const [peerId, peer] of state.peers) {
|
||||||
if (peer.peerConnection) {
|
if (peer.outgoingPC) {
|
||||||
peer.peerConnection.close();
|
peer.outgoingPC.close();
|
||||||
peer.peerConnection = null;
|
peer.outgoingPC = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -950,8 +1024,9 @@
|
||||||
// Update peer count
|
// Update peer count
|
||||||
peerCount.textContent = state.peers.size;
|
peerCount.textContent = state.peers.size;
|
||||||
|
|
||||||
// Update sharing buttons
|
// Update sharing buttons (hide entirely if browser lacks getDisplayMedia)
|
||||||
startShareBtn.classList.toggle('hidden', state.isSharing);
|
const canShare = !!navigator.mediaDevices?.getDisplayMedia;
|
||||||
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue