diff --git a/lanshare/src/main.rs b/lanshare/src/main.rs index 270521b..bb53c99 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, }, @@ -49,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 @@ -63,6 +79,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 +90,7 @@ enum SignalMessage { PeerJoined { peer_id: String, name: String, + ip: String, }, /// Broadcast: a peer left PeerLeft { @@ -120,6 +138,7 @@ enum SignalMessage { struct PeerInfo { peer_id: String, name: String, + ip: String, is_sharing: bool, has_audio: bool, } @@ -127,6 +146,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 +170,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, }) @@ -193,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 │"); @@ -200,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!(); @@ -213,22 +251,53 @@ 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()).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 { 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 +305,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 +320,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 +390,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 +402,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..22ba69b 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, outgoingPC, incomingPC } 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 ==================== @@ -491,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); @@ -518,8 +546,9 @@ setTimeout(connect, delay); } }; - + state.ws.onerror = (err) => { + clearTimeout(connectTimeout); console.error('WebSocket error:', err); }; } @@ -537,20 +566,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 +591,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); @@ -590,9 +621,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; @@ -607,7 +640,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,26 +661,27 @@ } // ==================== 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, - 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; @@ -656,42 +690,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(event.candidate), + 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)`); } }; @@ -699,9 +738,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); @@ -710,7 +757,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; @@ -758,16 +805,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) { @@ -784,10 +831,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 +847,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(); + + // 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); + 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, @@ -842,37 +893,51 @@ 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; } - + + // 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)); + 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 candidate = JSON.parse(candidateJson); - console.log(`[${fromPeerId.slice(0,8)}] Remote candidate: ${candidate.candidate}`); - - peer.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) + + 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)) .then(() => console.log(`[${fromPeerId.slice(0,8)}] Successfully added remote candidate`)) .catch(err => console.error(`[${fromPeerId.slice(0,8)}] Error adding ICE candidate:`, err)); } @@ -890,13 +955,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); @@ -911,14 +984,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; } } @@ -950,8 +1024,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