393 lines
17 KiB
TypeScript
393 lines
17 KiB
TypeScript
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<CallContextValue | null>(null);
|
||
export interface CallProviderProps {
|
||
children: React.ReactNode;
|
||
}
|
||
|
||
export function CallProvider(props : CallProviderProps) {
|
||
const [activeCall, setActiveCall] = useState<string>("");
|
||
const [callState, setCallState] = useState<CallState>(CallState.ENDED);
|
||
const [muted, setMuted] = useState<boolean>(false);
|
||
const [sound, setSound] = useState<boolean>(true);
|
||
const [duration, setDuration] = useState<number>(0);
|
||
const [showCallView, setShowCallView] = useState<boolean>(callState == CallState.INCOMING);
|
||
const {info} = useConsoleLogger("CallProvider");
|
||
const [sessionKeys, setSessionKeys] = useState<nacl.BoxKeyPair | null>(null);
|
||
const send = useSender();
|
||
const publicKey = usePublicKey();
|
||
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
|
||
const roomIdRef = useRef<string>("");
|
||
|
||
const roleRef = useRef<CallRole | null>(null);
|
||
const [sharedSecret, setSharedSecret] = useState<string>("");
|
||
const iceServersRef = useRef<RTCIceServer[]>([]);
|
||
|
||
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: "trun:" + server.urls + "?transport=udp",
|
||
username: server.username,
|
||
credential: server.credential
|
||
});
|
||
formattedIceServers.push({
|
||
urls: "trun:" + server.urls + "?transport=tcp",
|
||
username: server.username,
|
||
credential: server.credential
|
||
});
|
||
}
|
||
console.info("ICE SERVERS RECEIVED: ", formattedIceServers);
|
||
iceServersRef.current = formattedIceServers;
|
||
info("Received ICE servers from server, count: " + formattedIceServers.length);
|
||
}, []);
|
||
|
||
usePacket(27, async (packet: PacketWebRTC) => {
|
||
if(!activeCall || callState != CallState.WEB_RTC_EXCHANGE){
|
||
/**
|
||
* Нет активного звонка или мы не на стадии обмена 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));
|
||
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);
|
||
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]);
|
||
|
||
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){
|
||
/**
|
||
* Сбросили звонок
|
||
*/
|
||
setActiveCall("");
|
||
setCallState(CallState.ENDED);
|
||
setShowCallView(false);
|
||
setSessionKeys(null);
|
||
setSharedSecret("");
|
||
setDuration(0);
|
||
roleRef.current = null;
|
||
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!!!!!");
|
||
}
|
||
/**
|
||
* Запрашиваем Аудио поток с микрофона и добавляем его в 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();
|
||
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);
|
||
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 (
|
||
<CallContext.Provider value={context}>
|
||
{props.children}
|
||
{showCallView && <Call context={context}></Call>}
|
||
</CallContext.Provider>
|
||
)
|
||
} |