From d23a12e5e30ccdcc8c565bc16f4a15ea65606008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Sat, 15 Nov 2025 16:12:02 +0100 Subject: [PATCH] feat: implement WebRTC P2P file transfer with detection and fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented complete WebRTC peer-to-peer file transfer system for match chat: **Core WebRTC Implementation:** - Created useWebRTC hook with RTCPeerConnection and RTCDataChannel - P2P file transfer with 16KB chunking for large files (tested up to 700MB) - Real-time progress monitoring for sender and receiver - Automatic file download on receiver side - End-to-end encryption via DTLS (native WebRTC) - ICE candidate exchange via Socket.IO signaling - Support for host candidates (localhost testing) **WebRTC Detection & User Experience:** - Automatic WebRTC capability detection on page load - Detects if ICE candidates can be generated (fails in Opera, privacy-focused browsers, VPNs) - User-friendly warning component with fix suggestions - Graceful degradation: disables WebRTC button when blocked - Suggests alternative methods (video links via Google Drive/Dropbox) **Socket.IO Improvements:** - Fixed multiple socket instance creation issue - Implemented socket instance reuse pattern - Disabled React.StrictMode to prevent reconnection loops in development **Technical Details:** - RTCPeerConnection with configurable STUN servers (currently using localhost config) - RTCDataChannel with ordered delivery - Comprehensive logging for debugging (ICE gathering, connection states, signaling) - Match room-based signaling relay via Socket.IO - Authorization checks for all WebRTC signaling events **Files Changed:** - frontend/src/hooks/useWebRTC.js - Complete WebRTC implementation - frontend/src/utils/webrtcDetection.js - WebRTC capability detection - frontend/src/components/WebRTCWarning.jsx - User warning component - frontend/src/pages/MatchChatPage.jsx - WebRTC integration - frontend/src/services/socket.js - Socket instance reuse - frontend/src/main.jsx - Disabled StrictMode for Socket.IO stability **Testing:** - ✅ Verified working in Chrome (ICE candidates generated) - ✅ Tested with 700MB file transfer - ✅ Detection working in Opera (shows warning when WebRTC blocked) - ✅ P2P connection establishment and DataChannel opening - ✅ File chunking and progress monitoring **TODO:** - Add STUN server configuration for production (NAT traversal) - Consider server-based upload fallback for blocked users --- frontend/src/components/WebRTCWarning.jsx | 99 ++++++++++++++++ frontend/src/hooks/useWebRTC.js | 131 +++++++++++++++++----- frontend/src/main.jsx | 7 +- frontend/src/pages/MatchChatPage.jsx | 49 +++++++- frontend/src/services/socket.js | 6 +- frontend/src/utils/webrtcDetection.js | 123 ++++++++++++++++++++ 6 files changed, 374 insertions(+), 41 deletions(-) create mode 100644 frontend/src/components/WebRTCWarning.jsx create mode 100644 frontend/src/utils/webrtcDetection.js diff --git a/frontend/src/components/WebRTCWarning.jsx b/frontend/src/components/WebRTCWarning.jsx new file mode 100644 index 0000000..ffc5906 --- /dev/null +++ b/frontend/src/components/WebRTCWarning.jsx @@ -0,0 +1,99 @@ +import { AlertTriangle, X } from 'lucide-react'; +import { useState } from 'react'; + +/** + * WebRTC Warning Component + * + * Shows a dismissible warning when WebRTC is not available or blocked + */ +const WebRTCWarning = ({ detection, onDismiss }) => { + const [isDismissed, setIsDismissed] = useState(false); + + if (isDismissed) { + return null; + } + + // Don't show if WebRTC is working fine + if (detection.supported && detection.hasIceCandidates) { + return null; + } + + const handleDismiss = () => { + setIsDismissed(true); + if (onDismiss) { + onDismiss(); + } + }; + + const getMessage = () => { + if (!detection.supported) { + return { + title: 'WebRTC Not Supported', + description: 'Your browser does not support WebRTC. P2P video transfer is disabled.', + suggestions: [ + 'Update your browser to the latest version', + 'Try using Chrome, Firefox, or Edge', + ], + }; + } + + if (!detection.hasIceCandidates) { + return { + title: 'WebRTC Blocked', + description: 'WebRTC is blocked by browser settings or extensions. P2P video transfer is disabled.', + suggestions: [ + 'Check browser privacy settings (e.g., Opera: Settings → Privacy → WebRTC)', + 'Disable VPN extensions that block WebRTC', + 'Try using Chrome or Firefox', + 'Use incognito/private mode without extensions', + ], + }; + } + + return { + title: 'WebRTC Error', + description: detection.error || 'Unknown WebRTC error', + suggestions: ['Try refreshing the page', 'Try a different browser'], + }; + }; + + const { title, description, suggestions } = getMessage(); + + return ( +
+
+
+ +
+

+ {title} +

+

+ {description} +

+
+

How to fix:

+
    + {suggestions.map((suggestion, index) => ( +
  • {suggestion}
  • + ))} +
+
+

+ You can still send video links via Google Drive, Dropbox, etc. using the "Link" button. +

+
+
+ +
+
+ ); +}; + +export default WebRTCWarning; diff --git a/frontend/src/hooks/useWebRTC.js b/frontend/src/hooks/useWebRTC.js index 5dd34aa..60c4685 100644 --- a/frontend/src/hooks/useWebRTC.js +++ b/frontend/src/hooks/useWebRTC.js @@ -8,6 +8,13 @@ const rtcConfig = { { urls: 'stun:stun1.l.google.com:19302' }, { urls: 'stun:stun2.l.google.com:19302' }, ], + iceTransportPolicy: 'all', // Use all candidates (host, srflx, relay) + iceCandidatePoolSize: 10, // Pre-gather candidates +}; + +// Alternative config for localhost testing (no STUN) +const rtcConfigLocalhost = { + iceServers: [], // No STUN - use only host candidates for localhost }; // File chunk size (16KB recommended for WebRTC DataChannel) @@ -29,6 +36,14 @@ export const useWebRTC = (matchId, userId) => { const peerConnectionRef = useRef(null); const dataChannelRef = useRef(null); const socketRef = useRef(null); + const matchIdRef = useRef(matchId); + const userIdRef = useRef(userId); + + // Update refs when props change + useEffect(() => { + matchIdRef.current = matchId; + userIdRef.current = userId; + }, [matchId, userId]); // File transfer state const fileTransferRef = useRef({ @@ -53,20 +68,39 @@ export const useWebRTC = (matchId, userId) => { return peerConnectionRef.current; } - const pc = new RTCPeerConnection(rtcConfig); + // Use localhost config for testing (no STUN servers) + const pc = new RTCPeerConnection(rtcConfigLocalhost); peerConnectionRef.current = pc; + console.log('🔧 Using rtcConfigLocalhost (no STUN servers)'); // ICE candidate handler pc.onicecandidate = (event) => { - if (event.candidate && socketRef.current) { - socketRef.current.emit('webrtc_ice_candidate', { - matchId, - candidate: event.candidate, - }); - console.log('📤 Sent ICE candidate'); + if (event.candidate) { + console.log('🧊 ICE candidate generated:', event.candidate.type); + if (socketRef.current) { + socketRef.current.emit('webrtc_ice_candidate', { + matchId: matchIdRef.current, + candidate: event.candidate, + }); + console.log('📤 Sent ICE candidate'); + } else { + console.error('❌ Socket not available to send ICE candidate'); + } + } else { + console.log('🧊 ICE gathering complete (candidate is null)'); } }; + // ICE gathering state handler + pc.onicegatheringstatechange = () => { + console.log('🧊 ICE gathering state:', pc.iceGatheringState); + }; + + // Signaling state handler + pc.onsignalingstatechange = () => { + console.log('📡 Signaling state:', pc.signalingState); + }; + // Connection state change handler pc.onconnectionstatechange = () => { console.log('🔄 Connection state:', pc.connectionState); @@ -84,8 +118,9 @@ export const useWebRTC = (matchId, userId) => { }; console.log('✅ RTCPeerConnection initialized'); + console.log('🔍 Initial states - Connection:', pc.connectionState, 'ICE:', pc.iceConnectionState, 'Signaling:', pc.signalingState); return pc; - }, [matchId]); + }, []); /** * Create data channel for file transfer @@ -200,9 +235,11 @@ export const useWebRTC = (matchId, userId) => { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); + console.log('✅ Local description set (offer). ICE gathering should start now...'); + console.log('🔍 SDP has candidates:', pc.localDescription.sdp.includes('candidate:')); socketRef.current.emit('webrtc_offer', { - matchId, + matchId: matchIdRef.current, offer: pc.localDescription, }); @@ -211,7 +248,7 @@ export const useWebRTC = (matchId, userId) => { console.error('Failed to create offer:', error); setConnectionState('failed'); } - }, [matchId, initializePeerConnection, createDataChannel]); + }, [initializePeerConnection, createDataChannel]); /** * Handle incoming offer and create answer @@ -230,12 +267,15 @@ export const useWebRTC = (matchId, userId) => { }; await pc.setRemoteDescription(new RTCSessionDescription(offer)); + console.log('✅ Remote description set (offer)'); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); + console.log('✅ Local description set (answer). ICE gathering should start now...'); + console.log('🔍 SDP has candidates:', pc.localDescription.sdp.includes('candidate:')); socketRef.current.emit('webrtc_answer', { - matchId, + matchId: matchIdRef.current, answer: pc.localDescription, }); @@ -244,7 +284,7 @@ export const useWebRTC = (matchId, userId) => { console.error('Failed to handle offer:', error); setConnectionState('failed'); } - }, [matchId, initializePeerConnection, setupDataChannelHandlers]); + }, [initializePeerConnection, setupDataChannelHandlers]); /** * Handle incoming answer @@ -258,7 +298,7 @@ export const useWebRTC = (matchId, userId) => { } await pc.setRemoteDescription(new RTCSessionDescription(answer)); - console.log('✅ Remote description set'); + console.log('✅ Remote description set (answer). ICE should connect now...'); } catch (error) { console.error('Failed to handle answer:', error); setConnectionState('failed'); @@ -385,36 +425,67 @@ export const useWebRTC = (matchId, userId) => { socketRef.current = socket; - // Listen for WebRTC signaling events - socket.on('webrtc_offer', ({ from, offer }) => { - console.log('📥 Received WebRTC offer from:', from); - if (from !== userId) { + // Create stable handlers using current values from refs + const onOffer = ({ from, offer }) => { + console.log('📥 Received WebRTC offer from:', from, 'My userId:', userIdRef.current); + if (from !== userIdRef.current) { handleOffer(offer); + } else { + console.log('⏭️ Ignoring offer from self'); } - }); + }; - socket.on('webrtc_answer', ({ from, answer }) => { - console.log('📥 Received WebRTC answer from:', from); - if (from !== userId) { + const onAnswer = ({ from, answer }) => { + console.log('📥 Received WebRTC answer from:', from, 'My userId:', userIdRef.current); + if (from !== userIdRef.current) { handleAnswer(answer); + } else { + console.log('⏭️ Ignoring answer from self'); } - }); + }; - socket.on('webrtc_ice_candidate', ({ from, candidate }) => { + const onIceCandidate = ({ from, candidate }) => { console.log('📥 Received ICE candidate from:', from); - if (from !== userId) { + if (from !== userIdRef.current) { handleIceCandidate(candidate); } - }); + }; - // Cleanup on unmount + // Attach listeners + const attachListeners = () => { + socket.off('webrtc_offer', onOffer); + socket.off('webrtc_answer', onAnswer); + socket.off('webrtc_ice_candidate', onIceCandidate); + + socket.on('webrtc_offer', onOffer); + socket.on('webrtc_answer', onAnswer); + socket.on('webrtc_ice_candidate', onIceCandidate); + + console.log('✅ WebRTC Socket.IO listeners attached (socketId:', socket.id, ')'); + }; + + // Attach initially + attachListeners(); + + // Reattach on reconnect + const onReconnect = () => { + console.log('🔄 Socket reconnected - reattaching WebRTC listeners'); + attachListeners(); + }; + + socket.on('connect', onReconnect); + + // Cleanup on unmount only return () => { - socket.off('webrtc_offer'); - socket.off('webrtc_answer'); - socket.off('webrtc_ice_candidate'); + console.log('🧹 Removing WebRTC Socket.IO listeners'); + socket.off('connect', onReconnect); + socket.off('webrtc_offer', onOffer); + socket.off('webrtc_answer', onAnswer); + socket.off('webrtc_ice_candidate', onIceCandidate); cleanupConnection(); }; - }, [userId, handleOffer, handleAnswer, handleIceCandidate, cleanupConnection]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId]); // Only re-run if userId changes return { connectionState, diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index b9a1a6d..1a30beb 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,9 @@ -import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.jsx' +// StrictMode disabled for development - causes Socket.IO reconnection issues +// TODO: Re-enable in production or fix Socket.IO to handle double mounting createRoot(document.getElementById('root')).render( - - - , + ) diff --git a/frontend/src/pages/MatchChatPage.jsx b/frontend/src/pages/MatchChatPage.jsx index 2c7c650..ec130e0 100644 --- a/frontend/src/pages/MatchChatPage.jsx +++ b/frontend/src/pages/MatchChatPage.jsx @@ -6,6 +6,8 @@ import { matchesAPI } from '../services/api'; import { Send, Video, Upload, X, Check, Link as LinkIcon, Loader2 } from 'lucide-react'; import { connectSocket, getSocket } from '../services/socket'; import { useWebRTC } from '../hooks/useWebRTC'; +import { detectWebRTCSupport } from '../utils/webrtcDetection'; +import WebRTCWarning from '../components/WebRTCWarning'; const MatchChatPage = () => { const { slug } = useParams(); @@ -19,6 +21,7 @@ const MatchChatPage = () => { const [showLinkInput, setShowLinkInput] = useState(false); const [videoLink, setVideoLink] = useState(''); const [isConnected, setIsConnected] = useState(false); + const [webrtcDetection, setWebrtcDetection] = useState(null); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); @@ -33,6 +36,21 @@ const MatchChatPage = () => { cleanupConnection, } = useWebRTC(match?.id, user?.id); + // Detect WebRTC support on mount + useEffect(() => { + const runDetection = async () => { + const result = await detectWebRTCSupport(); + setWebrtcDetection(result); + + if (result.hasIceCandidates) { + console.log('✅ WebRTC detection: Working correctly'); + } else { + console.warn('⚠️ WebRTC detection:', result.error); + } + }; + runDetection(); + }, []); + // Fetch match data useEffect(() => { const loadMatch = async () => { @@ -88,13 +106,15 @@ const MatchChatPage = () => { return; } - // Socket event listeners - socket.on('connect', () => { + // Helper to join match room + const joinMatchRoom = () => { setIsConnected(true); - // Join match room using numeric match ID for socket socket.emit('join_match_room', { matchId: match.id }); console.log(`Joined match room ${match.id}`); - }); + }; + + // Socket event listeners + socket.on('connect', joinMatchRoom); socket.on('disconnect', () => { setIsConnected(false); @@ -105,9 +125,14 @@ const MatchChatPage = () => { setMessages((prev) => [...prev, message]); }); + // Join immediately if already connected + if (socket.connected) { + joinMatchRoom(); + } + // Cleanup return () => { - socket.off('connect'); + socket.off('connect', joinMatchRoom); socket.off('disconnect'); socket.off('match_message'); }; @@ -315,6 +340,13 @@ const MatchChatPage = () => {
+ {/* WebRTC Warning */} + {webrtcDetection && ( +
+ +
+ )} + {/* Messages */}
{messages.length === 0 && ( @@ -472,7 +504,12 @@ const MatchChatPage = () => { />