import { Call } from "@/app/components/Call/Call"; import { useConsoleLogger } from "@/app/hooks/useConsoleLogger"; import { createContext, useEffect, useRef, useState } from "react"; import nacl from 'tweetnacl'; import { useSender } from "../ProtocolProvider/useSender"; import { PacketSignalPeer, SignalType } from "../ProtocolProvider/protocol/packets/packet.signal.peer"; import { usePacket } from "../ProtocolProvider/usePacket"; import { usePublicKey } from "../AccountProvider/usePublicKey"; import { PacketWebRTC, WebRTCSignalType } from "../ProtocolProvider/protocol/packets/packet.webrtc"; import { PacketIceServers } from "../ProtocolProvider/protocol/packets/packet.ice.servers"; export interface CallContextValue { call: (callable: string) => void; close: () => void; activeCall: string; callState: CallState; muted: boolean; sound: boolean; setMuted: (muted: boolean) => void; setSound: (sound: boolean) => void; duration: number; setShowCallView: (show: boolean) => void; getKeyCast: () => string; accept: () => void; } export enum CallState { CONNECTING, KEY_EXCHANGE, /** * Финальная стадия сигналинга, на которой обе стороны обменялись ключами и теперь устанавливают защищенный канал связи для звонка, * через WebRTC, и готовятся к активному звонку. */ WEB_RTC_EXCHANGE, ACTIVE, ENDED, INCOMING } export enum CallRole { /** * Вызывающая сторона, которая инициирует звонок */ CALLER, /** * Принимающая сторона, которая отвечает на звонок и принимает его */ CALLEE } export const CallContext = createContext(null); export interface CallProviderProps { children: React.ReactNode; } export function CallProvider(props : CallProviderProps) { const [activeCall, setActiveCall] = useState(""); const [callState, setCallState] = useState(CallState.ENDED); const [muted, setMuted] = useState(false); const [sound, setSound] = useState(true); const [duration, setDuration] = useState(0); const [showCallView, setShowCallView] = useState(callState == CallState.INCOMING); const {info} = useConsoleLogger("CallProvider"); const [sessionKeys, setSessionKeys] = useState(null); const send = useSender(); const publicKey = usePublicKey(); const peerConnectionRef = useRef(null); const roomIdRef = useRef(""); const roleRef = useRef(null); const [sharedSecret, setSharedSecret] = useState(""); const iceServersRef = useRef([]); const remoteAudioRef = useRef(null); const iceCandidatesBufferRef = useRef([]); useEffect(() => { /** * Нам нужно получить ICE серверы для установки соединения из разных сетей * Получаем их от сервера */ let packet = new PacketIceServers(); send(packet); }, []); usePacket(28, async (packet: PacketIceServers) => { let iceServers = packet.getIceServers(); /** * ICE серверы получены, теперь нужно привести их к форматку клиента и добавить udp и tcp варианты */ let formattedIceServers: RTCIceServer[] = []; for(let i = 0; i < iceServers.length; i++){ let server = iceServers[i]; formattedIceServers.push({ urls: "turn:" + server.url + "?transport=" + server.transport, username: server.username, credential: server.credential }); } iceServersRef.current = formattedIceServers; info("Received ICE servers from server, count: " + formattedIceServers.length); }, []); usePacket(27, async (packet: PacketWebRTC) => { if(callState != CallState.WEB_RTC_EXCHANGE && callState != CallState.ACTIVE){ /** * Нет активного звонка или мы не на стадии обмена WebRTC сигналами, игнорируем */ return; } const signalType = packet.getSignalType(); if(signalType == WebRTCSignalType.ANSWER){ /** * Другая сторона (сервер SFU) отправил нам SDP ответ на наш оффер */ const sdp = JSON.parse(packet.getSdpOrCandidate()); await peerConnectionRef.current?.setRemoteDescription(new RTCSessionDescription(sdp)); if(iceCandidatesBufferRef.current.length > 0){ /** * У нас есть буферизированные ICE кандидаты, которые мы получили до установки удаленного описания, теперь мы можем их добавить в PeerConnection */ for(let i = 0; i < iceCandidatesBufferRef.current.length; i++){ await peerConnectionRef.current?.addIceCandidate(iceCandidatesBufferRef.current[i]); } iceCandidatesBufferRef.current = []; } info("Received WebRTC answer and set remote description"); return; } if(signalType == WebRTCSignalType.ICE_CANDIDATE){ /** * Другая сторона отправила нам ICE кандидата для установления WebRTC соединения */ const candidate = JSON.parse(packet.getSdpOrCandidate()); console.info(candidate); if(peerConnectionRef.current?.remoteDescription == null){ /** * Удаленное описание еще не установлено, буферизуем кандидата, чтобы добавить его после установки удаленного описания */ iceCandidatesBufferRef.current.push(new RTCIceCandidate(candidate)); info("Received WebRTC ICE candidate but remote description is not set yet, buffering candidate"); return; } await peerConnectionRef.current?.addIceCandidate(new RTCIceCandidate(candidate)); info("Received WebRTC ICE candidate and added to peer connection"); return; } if(signalType == WebRTCSignalType.OFFER){ /** * SFU сервер отправил нам оффер, например при renegotiation, нам нужно его принять и * отправить ответ (ANSWER) */ const sdp = JSON.parse(packet.getSdpOrCandidate()); await peerConnectionRef.current?.setRemoteDescription(new RTCSessionDescription(sdp)); let answer = await peerConnectionRef.current?.createAnswer(); await peerConnectionRef.current?.setLocalDescription(answer); let answerSignal = new PacketWebRTC(); answerSignal.setSignalType(WebRTCSignalType.ANSWER); answerSignal.setSdpOrCandidate(JSON.stringify(answer)); send(answerSignal); info("Received WebRTC offer, set remote description and sent answer"); return; } }, [activeCall, sessionKeys, callState, roomIdRef]); usePacket(26, async (packet: PacketSignalPeer) => { const signalType = packet.getSignalType(); if(activeCall){ /** * У нас уже есть активный звонок, игнорируем все сигналы, кроме сигналов от текущего звонка */ if(packet.getSrc() != activeCall && packet.getSrc() != publicKey){ console.info("Received signal from " + packet.getSrc() + " but active call is with " + activeCall + ", ignoring"); info("Received signal for another call, ignoring"); return; } } if(signalType == SignalType.END_CALL){ /** * Сбросили звонок */ end(); return; } if(signalType == SignalType.CALL){ /** * Нам поступает звонок */ setActiveCall(packet.getSrc()); setCallState(CallState.INCOMING); setShowCallView(true); } if(signalType == SignalType.KEY_EXCHANGE && roleRef.current == CallRole.CALLER){ console.info("EXCHANGE SIGNAL RECEIVED, CALLER ROLE"); /** * Другая сторона сгенерировала ключи для сессии и отправила нам публичную часть, * теперь мы можем создать общую секретную сессию для шифрования звонка */ const sharedPublic = packet.getSharedPublic(); if(!sharedPublic){ info("Received key exchange signal without shared public key"); return; } const sessionKeys = generateSessionKeys(); const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey); setSharedSecret(Buffer.from(computedSharedSecret).toString('hex')); info("Generated shared secret for call session: " + Buffer.from(computedSharedSecret).toString('hex')); /** * Нам нужно отправить свой публичный ключ другой стороне, чтобы она тоже могла создать общую секретную сессию */ const signalPacket = new PacketSignalPeer(); signalPacket.setSrc(publicKey); signalPacket.setDst(packet.getSrc()); signalPacket.setSignalType(SignalType.KEY_EXCHANGE); signalPacket.setSharedPublic(Buffer.from(sessionKeys.publicKey).toString('hex')); send(signalPacket); setCallState(CallState.WEB_RTC_EXCHANGE); /** * Создаем комнату на сервере SFU, комнату создает звонящий */ let webRtcSignal = new PacketSignalPeer(); webRtcSignal.setSignalType(SignalType.CREATE_ROOM); webRtcSignal.setSrc(publicKey); webRtcSignal.setDst(packet.getSrc()); send(webRtcSignal); } if(signalType == SignalType.KEY_EXCHANGE && roleRef.current == CallRole.CALLEE){ console.info("EXCHANGE SIGNAL RECEIVED, CALLEE ROLE"); /** * Мы отправили свою публичную часть ключа другой стороне, * теперь мы получили ее публичную часть и можем создать общую * секретную сессию для шифрования звонка */ const sharedPublic = packet.getSharedPublic(); if(!sharedPublic){ info("Received key exchange signal without shared public key"); return; } if(!sessionKeys){ info("Received key exchange signal but session keys are not generated"); return; } const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey); info("Generated shared secret for call session: " + Buffer.from(computedSharedSecret).toString('hex')); setSharedSecret(Buffer.from(computedSharedSecret).toString('hex')); setCallState(CallState.WEB_RTC_EXCHANGE); } if(signalType == SignalType.CREATE_ROOM) { /** * Создана комната для обмена WebRTC потоками */ roomIdRef.current = packet.getRoomId(); info("WebRTC room created with id: " + packet.getRoomId()); /** * Нужно отправить свой SDP оффер другой стороне, чтобы установить WebRTC соединение */ peerConnectionRef.current = new RTCPeerConnection({ iceServers: iceServersRef.current }); /** * Подписываемся на ICE кандидат */ peerConnectionRef.current.onicecandidate = (event) => { if(event.candidate){ let candidateSignal = new PacketWebRTC(); candidateSignal.setSignalType(WebRTCSignalType.ICE_CANDIDATE); candidateSignal.setSdpOrCandidate(JSON.stringify(event.candidate)); send(candidateSignal); } } /** * Соединение установлено, можно начинать звонок, переходим в активное состояние звонка */ peerConnectionRef.current.onconnectionstatechange = () => { console.info("Peer connection state changed: " + peerConnectionRef.current?.connectionState); if(peerConnectionRef.current?.connectionState == "connected"){ setCallState(CallState.ACTIVE); info("WebRTC connection established, call is active"); } } peerConnectionRef.current.ontrack = (event) => { /** * При получении медиа-трека с другой стороны */ console.info("TRACK RECV!!!!!"); if(remoteAudioRef.current){ console.info(event.streams); remoteAudioRef.current.srcObject = event.streams[0]; } } /** * Запрашиваем Аудио поток с микрофона и добавляем его в PeerConnection, чтобы другая сторона могла его получить и воспроизвести, * когда мы установим WebRTC соединение */ const localStream = await navigator.mediaDevices.getUserMedia({ audio: true }); localStream.getTracks().forEach(track => { peerConnectionRef.current?.addTrack(track, localStream); }); /** * Отправляем свой оффер другой стороне */ let offer = await peerConnectionRef.current.createOffer(); await peerConnectionRef.current.setLocalDescription(offer); let offerSignal = new PacketWebRTC(); offerSignal.setSignalType(WebRTCSignalType.OFFER); offerSignal.setSdpOrCandidate(JSON.stringify(offer)); send(offerSignal); return; } }, [activeCall, sessionKeys]); const generateSessionKeys = () => { const sessionKeys = nacl.box.keyPair(); info("Generated keys for call session, len: " + sessionKeys.publicKey.length); setSessionKeys(sessionKeys); return sessionKeys; } const call = (dialog: string) => { setActiveCall(dialog); setCallState(CallState.CONNECTING); setShowCallView(true); const signalPacket = new PacketSignalPeer(); signalPacket.setSrc(publicKey); signalPacket.setDst(dialog); signalPacket.setSignalType(SignalType.CALL); send(signalPacket); roleRef.current = CallRole.CALLER; } const close = () => { const packetSignal = new PacketSignalPeer(); packetSignal.setSrc(publicKey); packetSignal.setDst(activeCall); packetSignal.setSignalType(SignalType.END_CALL); send(packetSignal); end(); } const end = () => { peerConnectionRef.current?.close(); peerConnectionRef.current = null; roomIdRef.current = ""; setActiveCall(""); setCallState(CallState.ENDED); setShowCallView(false); setSessionKeys(null); setDuration(0); roleRef.current = null; } const accept = () => { if(callState != CallState.INCOMING){ /** * Нечего принимать */ return; } /** * Звонок принят, генерируем ключи для сессии и отправляем их другой стороне для установления защищенного канала связи */ const keys = generateSessionKeys(); const signalPacket = new PacketSignalPeer(); signalPacket.setSrc(publicKey); signalPacket.setDst(activeCall); signalPacket.setSignalType(SignalType.KEY_EXCHANGE); signalPacket.setSharedPublic(Buffer.from(keys.publicKey).toString('hex')); send(signalPacket); setCallState(CallState.KEY_EXCHANGE); roleRef.current = CallRole.CALLEE; } /** * Получает слепок ключа для отображения в UI * чтобы не показывать настоящий ключ * @returns */ const getKeyCast = () => { if(!sharedSecret){ return ""; } return sharedSecret; } const context = { call, close, activeCall, callState, muted, sound, setMuted, setSound, duration, setShowCallView, getKeyCast, accept }; return ( {props.children} ) }