Files
desktop/app/providers/CallProvider/CallProvider.tsx
2026-03-14 15:43:49 +02:00

306 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Call } from "@/app/components/Call/Call";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { createContext, 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";
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>("");
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());
await peerConnectionRef.current?.addIceCandidate(new RTCIceCandidate(candidate));
info("Received WebRTC ICE candidate and added to peer connection");
return;
}
}, [activeCall, sessionKeys, callState]);
usePacket(26, async (packet: PacketSignalPeer) => {
const signalType = packet.getSignalType();
if(activeCall){
/**
* У нас уже есть активный звонок, игнорируем все сигналы, кроме сигналов от текущего звонка
*/
if(packet.getSrc() != activeCall){
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({
//Experemental
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
});
/**
* Отправляем свой оффер другой стороне
*/
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>
)
}