Merge pull request '1.1.1-1.5.3' (#17) from dev into main
Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
@@ -14,7 +14,7 @@ export function useSound() {
|
|||||||
audioRef.current.load();
|
audioRef.current.load();
|
||||||
};
|
};
|
||||||
|
|
||||||
const playSound = (sound : string, loop: boolean = false) => {
|
const playSound = async (sound : string, loop: boolean = false) => {
|
||||||
try {
|
try {
|
||||||
if(loop){
|
if(loop){
|
||||||
if (!loopingAudioRef.current) {
|
if (!loopingAudioRef.current) {
|
||||||
@@ -24,7 +24,7 @@ export function useSound() {
|
|||||||
loopingAudioRef.current.loop = true;
|
loopingAudioRef.current.loop = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = window.mediaApi.getSoundUrl(sound);
|
const url = await window.mediaApi.getSoundUrl(sound);
|
||||||
const player = loopingAudioRef.current;
|
const player = loopingAudioRef.current;
|
||||||
|
|
||||||
player.src = url;
|
player.src = url;
|
||||||
@@ -43,7 +43,7 @@ export function useSound() {
|
|||||||
audioRef.current.loop = loop;
|
audioRef.current.loop = loop;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = window.mediaApi.getSoundUrl(sound);
|
const url = await window.mediaApi.getSoundUrl(sound);
|
||||||
const player = audioRef.current;
|
const player = audioRef.current;
|
||||||
|
|
||||||
stopSound();
|
stopSound();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { modals } from "@mantine/modals";
|
|||||||
import { Button, Flex, Text } from "@mantine/core";
|
import { Button, Flex, Text } from "@mantine/core";
|
||||||
import { useSound } from "@/app/hooks/useSound";
|
import { useSound } from "@/app/hooks/useSound";
|
||||||
import useWindow from "@/app/hooks/useWindow";
|
import useWindow from "@/app/hooks/useWindow";
|
||||||
|
import { attachReceiverE2EE, attachSenderE2EE } from "./audioE2EE";
|
||||||
|
|
||||||
export interface CallContextValue {
|
export interface CallContextValue {
|
||||||
call: (callable: string) => void;
|
call: (callable: string) => void;
|
||||||
@@ -73,7 +74,8 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
const roomIdRef = useRef<string>("");
|
const roomIdRef = useRef<string>("");
|
||||||
|
|
||||||
const roleRef = useRef<CallRole | null>(null);
|
const roleRef = useRef<CallRole | null>(null);
|
||||||
const [sharedSecret, setSharedSecret] = useState<string>("");
|
//const [sharedSecret, setSharedSecret] = useState<string>("");
|
||||||
|
const sharedSecretRef = useRef<string>("");
|
||||||
const iceServersRef = useRef<RTCIceServer[]>([]);
|
const iceServersRef = useRef<RTCIceServer[]>([]);
|
||||||
const remoteAudioRef = useRef<HTMLAudioElement | null>(null);
|
const remoteAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const iceCandidatesBufferRef = useRef<RTCIceCandidate[]>([]);
|
const iceCandidatesBufferRef = useRef<RTCIceCandidate[]>([]);
|
||||||
@@ -245,8 +247,8 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
}
|
}
|
||||||
const sessionKeys = generateSessionKeys();
|
const sessionKeys = generateSessionKeys();
|
||||||
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
|
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
|
||||||
setSharedSecret(Buffer.from(computedSharedSecret).toString('hex'));
|
sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex');
|
||||||
info("Generated shared secret for call session: " + Buffer.from(computedSharedSecret).toString('hex'));
|
info("Generated shared secret for call session: " + sharedSecretRef.current);
|
||||||
/**
|
/**
|
||||||
* Нам нужно отправить свой публичный ключ другой стороне, чтобы она тоже могла создать общую секретную сессию
|
* Нам нужно отправить свой публичный ключ другой стороне, чтобы она тоже могла создать общую секретную сессию
|
||||||
*/
|
*/
|
||||||
@@ -283,8 +285,8 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
|
const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey);
|
||||||
info("Generated shared secret for call session: " + Buffer.from(computedSharedSecret).toString('hex'));
|
sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex');
|
||||||
setSharedSecret(Buffer.from(computedSharedSecret).toString('hex'));
|
info("Generated shared secret for call session: " + sharedSecretRef.current);
|
||||||
setCallState(CallState.WEB_RTC_EXCHANGE);
|
setCallState(CallState.WEB_RTC_EXCHANGE);
|
||||||
}
|
}
|
||||||
if(signalType == SignalType.CREATE_ROOM) {
|
if(signalType == SignalType.CREATE_ROOM) {
|
||||||
@@ -297,7 +299,9 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
* Нужно отправить свой SDP оффер другой стороне, чтобы установить WebRTC соединение
|
* Нужно отправить свой SDP оффер другой стороне, чтобы установить WebRTC соединение
|
||||||
*/
|
*/
|
||||||
peerConnectionRef.current = new RTCPeerConnection({
|
peerConnectionRef.current = new RTCPeerConnection({
|
||||||
iceServers: iceServersRef.current
|
iceServers: iceServersRef.current,
|
||||||
|
// @ts-ignore
|
||||||
|
encodedInsertableStreams: true
|
||||||
});
|
});
|
||||||
/**
|
/**
|
||||||
* Подписываемся на ICE кандидат
|
* Подписываемся на ICE кандидат
|
||||||
@@ -321,7 +325,12 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
peerConnectionRef.current.ontrack = (event) => {
|
peerConnectionRef.current.ontrack = async (event) => {
|
||||||
|
try {
|
||||||
|
await attachReceiverE2EE(event.receiver, Buffer.from(sharedSecretRef.current, "hex"));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("attachReceiverE2EE failed:", e);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* При получении медиа-трека с другой стороны
|
* При получении медиа-трека с другой стороны
|
||||||
*/
|
*/
|
||||||
@@ -339,12 +348,17 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
* Запрашиваем Аудио поток с микрофона и добавляем его в PeerConnection, чтобы другая сторона могла его получить и воспроизвести,
|
* Запрашиваем Аудио поток с микрофона и добавляем его в PeerConnection, чтобы другая сторона могла его получить и воспроизвести,
|
||||||
* когда мы установим WebRTC соединение
|
* когда мы установим WebRTC соединение
|
||||||
*/
|
*/
|
||||||
const localStream = await navigator.mediaDevices.getUserMedia({
|
const localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
audio: true
|
const audioTrack = localStream.getAudioTracks()[0];
|
||||||
});
|
|
||||||
localStream.getTracks().forEach(track => {
|
|
||||||
peerConnectionRef.current?.addTrack(track, localStream);
|
const tx = peerConnectionRef.current.addTransceiver(audioTrack, {
|
||||||
|
direction: "sendrecv",
|
||||||
|
streams: [localStream]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await attachSenderE2EE(tx.sender, Buffer.from(sharedSecretRef.current, "hex"));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отправляем свой оффер другой стороне
|
* Отправляем свой оффер другой стороне
|
||||||
*/
|
*/
|
||||||
@@ -470,10 +484,10 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
const getKeyCast = () => {
|
const getKeyCast = () => {
|
||||||
if(!sharedSecret){
|
if(!sharedSecretRef.current){
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return sharedSecret;
|
return sharedSecretRef.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
105
app/providers/CallProvider/audioE2EE.ts
Normal file
105
app/providers/CallProvider/audioE2EE.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
function toArrayBuffer(src: Buffer | Uint8Array): ArrayBuffer {
|
||||||
|
const u8 = src instanceof Uint8Array ? src : new Uint8Array(src);
|
||||||
|
return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyInput = Buffer | Uint8Array;
|
||||||
|
|
||||||
|
|
||||||
|
async function importAesCtrKey(input: KeyInput): Promise<CryptoKey> {
|
||||||
|
const keyBytes = toArrayBuffer(input);
|
||||||
|
if (keyBytes.byteLength !== 32) {
|
||||||
|
throw new Error(`E2EE key must be 32 bytes, got ${keyBytes.byteLength}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
keyBytes,
|
||||||
|
{ name: "AES-CTR" },
|
||||||
|
false,
|
||||||
|
["encrypt", "decrypt"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBigIntTs(ts: unknown): bigint {
|
||||||
|
if (typeof ts === "bigint") return ts;
|
||||||
|
if (typeof ts === "number") return BigInt(ts);
|
||||||
|
return 0n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 16-byte counter:
|
||||||
|
* [0..3] direction marker
|
||||||
|
* [4..11] frame timestamp
|
||||||
|
* [12..15] reserved
|
||||||
|
*/
|
||||||
|
function buildCounter(direction: number, timestamp: unknown): ArrayBuffer {
|
||||||
|
const iv = new Uint8Array(16);
|
||||||
|
const dv = new DataView(iv.buffer);
|
||||||
|
dv.setUint32(0, direction >>> 0, false);
|
||||||
|
dv.setBigUint64(4, toBigIntTs(timestamp), false);
|
||||||
|
dv.setUint32(12, 0, false);
|
||||||
|
return toArrayBuffer(iv);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachSenderE2EE(sender: RTCRtpSender, keyInput: KeyInput): Promise<void> {
|
||||||
|
const key = await importAesCtrKey(keyInput);
|
||||||
|
|
||||||
|
const anySender = sender as unknown as {
|
||||||
|
createEncodedStreams?: () => { readable: ReadableStream<any>; writable: WritableStream<any> };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!anySender.createEncodedStreams) {
|
||||||
|
throw new Error("createEncodedStreams is not available on RTCRtpSender");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { readable, writable } = anySender.createEncodedStreams();
|
||||||
|
|
||||||
|
const enc = new TransformStream<any, any>({
|
||||||
|
async transform(frame, controller) {
|
||||||
|
const counter = buildCounter(1, frame.timestamp);
|
||||||
|
const encrypted = await crypto.subtle.encrypt(
|
||||||
|
{ name: "AES-CTR", counter, length: 64 },
|
||||||
|
key,
|
||||||
|
frame.data
|
||||||
|
);
|
||||||
|
frame.data = encrypted; // same length
|
||||||
|
controller.enqueue(frame);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
readable.pipeThrough(enc).pipeTo(writable).catch((e) => {
|
||||||
|
console.error("Sender E2EE pipeline failed:", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachReceiverE2EE(receiver: RTCRtpReceiver, keyInput: KeyInput): Promise<void> {
|
||||||
|
const key = await importAesCtrKey(keyInput);
|
||||||
|
|
||||||
|
const anyReceiver = receiver as unknown as {
|
||||||
|
createEncodedStreams?: () => { readable: ReadableStream<any>; writable: WritableStream<any> };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!anyReceiver.createEncodedStreams) {
|
||||||
|
throw new Error("createEncodedStreams is not available on RTCRtpReceiver");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { readable, writable } = anyReceiver.createEncodedStreams();
|
||||||
|
|
||||||
|
const dec = new TransformStream<any, any>({
|
||||||
|
async transform(frame, controller) {
|
||||||
|
const counter = buildCounter(1, frame.timestamp);
|
||||||
|
const decrypted = await crypto.subtle.decrypt(
|
||||||
|
{ name: "AES-CTR", counter, length: 64 },
|
||||||
|
key,
|
||||||
|
frame.data
|
||||||
|
);
|
||||||
|
frame.data = decrypted; // same length
|
||||||
|
controller.enqueue(frame);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
readable.pipeThrough(dec).pipeTo(writable).catch((e) => {
|
||||||
|
console.error("Receiver E2EE pipeline failed:", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
export const APP_VERSION = "1.1.0";
|
export const APP_VERSION = "1.1.1";
|
||||||
export const CORE_MIN_REQUIRED_VERSION = "1.5.2";
|
export const CORE_MIN_REQUIRED_VERSION = "1.5.3";
|
||||||
|
|
||||||
export const RELEASE_NOTICE = `
|
export const RELEASE_NOTICE = `
|
||||||
**Обновление v1.1.0** :emoji_1f631:
|
**Обновление v1.1.1** :emoji_1f631:
|
||||||
- Добавлена поддержка звонков
|
- Добавлено сквозное шифрование звонков
|
||||||
- Прозрачным аватаркам добавлена подложка
|
- Исправлена проблема с звуком в звонках на некоторых устройствах
|
||||||
- Фикс ошибки чтения
|
|
||||||
- Подложка к вложению аватарки
|
|
||||||
- Обмен ключами шифрования DH
|
|
||||||
- Поддерджка WebRTC
|
|
||||||
- Событийные звуки звонка (сбросить, мутинг, и прочее...)
|
|
||||||
`;
|
`;
|
||||||
13
lib/main/ipcs/ipcRuntime.ts
Normal file
13
lib/main/ipcs/ipcRuntime.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { app, ipcMain } from "electron";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить директорию с ресурсами приложения
|
||||||
|
*/
|
||||||
|
ipcMain.handle('runtime:get-resources', () => {
|
||||||
|
const isDev = !app.isPackaged && process.env['ELECTRON_RENDERER_URL'];
|
||||||
|
if(isDev){
|
||||||
|
return path.join(process.cwd(), "resources")
|
||||||
|
}
|
||||||
|
return path.join(process.resourcesPath, "resources");
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import './ipcs/ipcUpdate'
|
|||||||
import './ipcs/ipcNotification'
|
import './ipcs/ipcNotification'
|
||||||
import './ipcs/ipcDevice'
|
import './ipcs/ipcDevice'
|
||||||
import './ipcs/ipcCore'
|
import './ipcs/ipcCore'
|
||||||
|
import './ipcs/ipcRuntime'
|
||||||
import { Tray } from 'electron/main'
|
import { Tray } from 'electron/main'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { Logger } from './logger'
|
import { Logger } from './logger'
|
||||||
|
|||||||
2
lib/preload/index.d.ts
vendored
2
lib/preload/index.d.ts
vendored
@@ -14,7 +14,7 @@ declare global {
|
|||||||
deviceName: string;
|
deviceName: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
mediaApi: {
|
mediaApi: {
|
||||||
getSoundUrl: (fileName: string) => string;
|
getSoundUrl: (fileName: string) => Promise<string>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,9 @@ import path from 'node:path'
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
|
||||||
function resolveSound(fileName: string) {
|
async function resolveSound(fileName: string) {
|
||||||
const isDev = !process.env.APP_PACKAGED; // или свой флаг dev
|
const resourcesPath = await ipcRenderer.invoke('runtime:get-resources');
|
||||||
const fullPath = isDev
|
const fullPath = path.join(resourcesPath, "sounds", fileName);
|
||||||
? path.join(process.cwd(), "resources", "sounds", fileName)
|
|
||||||
: path.join(process.resourcesPath, "resources", "sounds", fileName);
|
|
||||||
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
throw new Error(`Sound not found: ${fullPath}`);
|
throw new Error(`Sound not found: ${fullPath}`);
|
||||||
}
|
}
|
||||||
@@ -32,7 +29,7 @@ const exposeContext = async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
contextBridge.exposeInMainWorld("mediaApi", {
|
contextBridge.exposeInMainWorld("mediaApi", {
|
||||||
getSoundUrl: (fileName: string) => {
|
getSoundUrl: async (fileName: string) => {
|
||||||
return resolveSound(fileName);
|
return resolveSound(fileName);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -44,7 +41,7 @@ const exposeContext = async () => {
|
|||||||
window.api = api;
|
window.api = api;
|
||||||
window.shell = shell;
|
window.shell = shell;
|
||||||
window.mediaApi = {
|
window.mediaApi = {
|
||||||
getSoundUrl: (fileName: string) => {
|
getSoundUrl: async (fileName: string) => {
|
||||||
return resolveSound(fileName);
|
return resolveSound(fileName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Rosetta",
|
"name": "Rosetta",
|
||||||
"version": "1.5.2",
|
"version": "1.5.3",
|
||||||
"description": "Rosetta Messenger",
|
"description": "Rosetta Messenger",
|
||||||
"main": "./out/main/main.js",
|
"main": "./out/main/main.js",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"composite": true,
|
"composite": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
|
"lib": ["DOM"],
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"types": ["electron-vite/node"],
|
"types": ["electron-vite/node"],
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
Reference in New Issue
Block a user