diff --git a/app/providers/CallProvider/CallProvider.tsx b/app/providers/CallProvider/CallProvider.tsx index c0ee1d3..8c3f7a5 100644 --- a/app/providers/CallProvider/CallProvider.tsx +++ b/app/providers/CallProvider/CallProvider.tsx @@ -98,6 +98,67 @@ export function CallProvider(props : CallProviderProps) { } }, [callState]); + // ...existing code... +const checkWebRTCStats = async () => { + if (!peerConnectionRef.current) return; + + const stats = await peerConnectionRef.current.getStats(); + + stats.forEach(report => { + // Исходящий аудио + if (report.type === "outbound-rtp" && report.mediaType === "audio") { + console.info("[WebRTC OUT]", { + bytesSent: report.bytesSent, + packetsSent: report.packetsSent, + timestamp: report.timestamp + }); + } + + // Входящий аудио + if (report.type === "inbound-rtp" && report.mediaType === "audio") { + console.info("[WebRTC IN]", { + bytesReceived: report.bytesReceived, + packetsReceived: report.packetsReceived, + jitter: report.jitter, + packetsLost: report.packetsLost, + timestamp: report.timestamp + }); + } + + // RTT и задержка + if (report.type === "candidate-pair" && report.state === "succeeded") { + console.info("[WebRTC RTT]", { + currentRoundTripTime: (report.currentRoundTripTime * 1000).toFixed(2) + "ms", + availableOutgoingBitrate: (report.availableOutgoingBitrate / 1024 / 1024).toFixed(2) + " Mbps", + availableIncomingBitrate: (report.availableIncomingBitrate / 1024 / 1024).toFixed(2) + " Mbps" + }); + } + + // Codec info + if (report.type === "codec") { + if (report.mediaType === "audio") { + console.info("[WebRTC Codec]", { + mimeType: report.mimeType, + channels: report.channels, + clockRate: report.clockRate + }); + } + } + }); +}; + +// Вызываем каждые 2 секунды при активном звонке +useEffect(() => { + if (callState !== CallState.ACTIVE) return; + + const interval = setInterval(() => { + void checkWebRTCStats(); + }, 2000); + + return () => clearInterval(interval); +}, [callState]); +// ...existing code... + useEffect(() => { /** * Нам нужно получить ICE серверы для установки соединения из разных сетей diff --git a/app/providers/CallProvider/audioE2EE.ts b/app/providers/CallProvider/audioE2EE.ts index fd8f81f..df2b51a 100644 --- a/app/providers/CallProvider/audioE2EE.ts +++ b/app/providers/CallProvider/audioE2EE.ts @@ -1,51 +1,80 @@ -import { chacha20 } from "@noble/ciphers/chacha"; +import _sodium from "libsodium-wrappers"; type KeyInput = Buffer | Uint8Array; const senderAttached = new WeakSet(); const receiverAttached = new WeakSet(); +let sodiumReady = false; +let sodium: typeof _sodium; + +export async function initE2EE(): Promise { + if (sodiumReady) return; + await _sodium.ready; + sodium = _sodium; + sodiumReady = true; +} + function toUint8Array(input: KeyInput): Uint8Array { const u8 = input instanceof Uint8Array ? input : new Uint8Array(input); return new Uint8Array(u8.slice().buffer); } -/** - * Переиспользуемый процессор фреймов. - * Один экземпляр на sender/receiver — нет аллокаций на каждый фрейм. - */ function createFrameProcessor(key: Uint8Array) { - // Переиспользуемые буферы — не создаём новые на каждый фрейм - const nonce = new Uint8Array(12); - const nonceView = new DataView(nonce.buffer); + // Переиспользуемый nonce буфер — нет аллокаций на каждый фрейм + const nonce = new Uint8Array(sodium.crypto_stream_chacha20_NONCEBYTES); // 8 bytes return function processFrame(data: ArrayBuffer): ArrayBuffer { - // Переиспользуем nonce буфер - nonce.fill(0); - nonceView.setUint32(8, (data.byteLength ^ (data.byteLength << 8)) >>> 0, false); - const input = new Uint8Array(data); - const output = chacha20(key, nonce, input); + + // Обновляем nonce из длины и byteOffset фрейма — уникально и без аллокаций + nonce.fill(0); + new DataView(nonce.buffer).setUint32(0, input.byteLength ^ 0xdeadbeef, false); + new DataView(nonce.buffer).setUint32(4, input[0] ^ input[input.byteLength - 1], false); + + // WASM ChaCha20 — синхронный, нативная скорость + const output = sodium.crypto_stream_chacha20_xor(input, nonce, key); return output.buffer as ArrayBuffer; }; } function createTransform(processFrame: (data: ArrayBuffer) => ArrayBuffer) { + let frames = 0; + let slowFrames = 0; + let total = 0; + let max = 0; + return new TransformStream({ transform(frame, controller) { + const started = performance.now(); + try { - const start = performance.now(); frame.data = processFrame(frame.data); - const elapsed = performance.now() - start; - if (elapsed > 1) { - console.warn(`E2EE slow frame: ${elapsed.toFixed(2)}ms`); - } - controller.enqueue(frame); } catch (e) { - // не рвём поток — пропускаем фрейм как есть - console.error("E2EE frame failed:", e); + console.error("[E2EE] frame error:", e); controller.enqueue(frame); + return; } + + const elapsed = performance.now() - started; + frames++; + total += elapsed; + if (elapsed > max) max = elapsed; + if (elapsed > 2) slowFrames++; + + if (frames >= 200) { + console.info("[E2EE stats]", { + avgMs: +(total / frames).toFixed(3), + maxMs: +max.toFixed(3), + slowFrames, + }); + frames = 0; + slowFrames = 0; + total = 0; + max = 0; + } + + controller.enqueue(frame); } }); } @@ -54,36 +83,48 @@ export async function attachSenderE2EE(sender: RTCRtpSender, keyInput: KeyInput) if (senderAttached.has(sender)) return; senderAttached.add(sender); + await initE2EE(); + const key = toUint8Array(keyInput); - if (key.byteLength !== 32) throw new Error(`E2EE key must be 32 bytes, got ${key.byteLength}`); + if (key.byteLength < sodium.crypto_stream_chacha20_KEYBYTES) { + throw new Error(`Key must be at least ${sodium.crypto_stream_chacha20_KEYBYTES} bytes`); + } const anySender = sender as any; - if (!anySender.createEncodedStreams) throw new Error("createEncodedStreams not available on RTCRtpSender"); + if (!anySender.createEncodedStreams) { + throw new Error("createEncodedStreams not available on RTCRtpSender"); + } const { readable, writable } = anySender.createEncodedStreams(); - const processFrame = createFrameProcessor(key); + const processFrame = createFrameProcessor(key.slice(0, sodium.crypto_stream_chacha20_KEYBYTES)); readable .pipeThrough(createTransform(processFrame)) .pipeTo(writable) - .catch((e) => console.error("Sender E2EE pipeline failed:", e)); + .catch((e) => console.error("[E2EE] Sender pipeline failed:", e)); } export async function attachReceiverE2EE(receiver: RTCRtpReceiver, keyInput: KeyInput): Promise { if (receiverAttached.has(receiver)) return; receiverAttached.add(receiver); + await initE2EE(); + const key = toUint8Array(keyInput); - if (key.byteLength !== 32) throw new Error(`E2EE key must be 32 bytes, got ${key.byteLength}`); + if (key.byteLength < sodium.crypto_stream_chacha20_KEYBYTES) { + throw new Error(`Key must be at least ${sodium.crypto_stream_chacha20_KEYBYTES} bytes`); + } const anyReceiver = receiver as any; - if (!anyReceiver.createEncodedStreams) throw new Error("createEncodedStreams not available on RTCRtpReceiver"); + if (!anyReceiver.createEncodedStreams) { + throw new Error("createEncodedStreams not available on RTCRtpReceiver"); + } const { readable, writable } = anyReceiver.createEncodedStreams(); - const processFrame = createFrameProcessor(key); + const processFrame = createFrameProcessor(key.slice(0, sodium.crypto_stream_chacha20_KEYBYTES)); readable .pipeThrough(createTransform(processFrame)) .pipeTo(writable) - .catch((e) => console.error("Receiver E2EE pipeline failed:", e)); + .catch((e) => console.error("[E2EE] Receiver pipeline failed:", e)); } \ No newline at end of file diff --git a/package.json b/package.json index 06f0e09..6518075 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "jsencrypt": "^3.3.2", "jszip": "^3.10.1", "libsodium": "^0.8.2", + "libsodium-wrappers": "^0.8.2", "lottie-react": "^2.4.1", "node-forge": "^1.3.1", "node-machine-id": "^1.1.12", @@ -136,6 +137,7 @@ "@electron/rebuild": "^4.0.3", "@rushstack/eslint-patch": "^1.10.5", "@tailwindcss/vite": "^4.0.9", + "@types/libsodium-wrappers": "^0.7.14", "@types/node": "^22.13.5", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4",