diff --git a/lanshare/src/main.rs b/lanshare/src/main.rs index bb53c99..270521b 100644 --- a/lanshare/src/main.rs +++ b/lanshare/src/main.rs @@ -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, + certificate: PathBuf, - /// TLS private key file (PEM format) #[arg(short, long)] - 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, + 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, }, /// 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, @@ -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,25 +200,10 @@ 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!(" → 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!(); @@ -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::()) - .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::()) - .await - .unwrap(); - } else { - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, app.into_make_service_with_connect_info::()) - .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, - State(state): State>, -) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_socket(socket, state, addr)) +async fn ws_handler(ws: WebSocketUpgrade, State(state): State>) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, state)) } -async fn handle_socket(socket: WebSocket, state: Arc, addr: SocketAddr) { +async fn handle_socket(socket: WebSocket, state: Arc) { 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 @@ -305,7 +236,6 @@ async fn handle_socket(socket: WebSocket, state: Arc, 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, 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, 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), ); diff --git a/lanshare/static/index.html b/lanshare/static/index.html index 22ba69b..20efc15 100644 --- a/lanshare/static/index.html +++ b/lanshare/static/index.html @@ -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 ==================== @@ -500,44 +491,25 @@ 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); @@ -546,9 +518,8 @@ setTimeout(connect, delay); } }; - + state.ws.onerror = (err) => { - clearTimeout(connectTimeout); console.error('WebSocket error:', err); }; } @@ -566,22 +537,20 @@ 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.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,27 +628,26 @@ } // ==================== 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) { state.selectedPeerId = null; mainVideo.srcObject = null; @@ -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,17 +699,9 @@ 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); - } + peer.stream = event.streams[0]; updateUI(); - + // Auto-select if this is the first/only stream if (!state.selectedPeerId) { selectPeer(peerId); @@ -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; @@ -805,16 +758,16 @@ console.warn('sendOfferTo: peer not found:', peerId); 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,11 +784,10 @@ 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`); - + send({ type: 'offer', from: state.peerId, @@ -847,42 +799,39 @@ 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; - - // Replace any mDNS .local addresses in the remote SDP with the peer's real IP + const pc = createPeerConnection(fromPeerId); + peer.peerConnection = pc; + 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); } - - // 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`); - + send({ type: 'answer', from: state.peerId, @@ -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; - - // 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)) + + const candidate = JSON.parse(candidateJson); + console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.candidate}`); + + peer.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) .then(() => console.log(`[${fromPeerId.slice(0,8)}] Successfully added remote candidate`)) .catch(err => console.error(`[${fromPeerId.slice(0,8)}] Error adding ICE candidate:`, err)); } @@ -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); @@ -984,15 +911,14 @@ state.localStream.getTracks().forEach(track => track.stop()); state.localStream = null; } - + 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