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();
|
||||
};
|
||||
|
||||
const playSound = (sound : string, loop: boolean = false) => {
|
||||
const playSound = async (sound : string, loop: boolean = false) => {
|
||||
try {
|
||||
if(loop){
|
||||
if (!loopingAudioRef.current) {
|
||||
@@ -24,7 +24,7 @@ export function useSound() {
|
||||
loopingAudioRef.current.loop = true;
|
||||
}
|
||||
|
||||
const url = window.mediaApi.getSoundUrl(sound);
|
||||
const url = await window.mediaApi.getSoundUrl(sound);
|
||||
const player = loopingAudioRef.current;
|
||||
|
||||
player.src = url;
|
||||
@@ -43,7 +43,7 @@ export function useSound() {
|
||||
audioRef.current.loop = loop;
|
||||
}
|
||||
|
||||
const url = window.mediaApi.getSoundUrl(sound);
|
||||
const url = await window.mediaApi.getSoundUrl(sound);
|
||||
const player = audioRef.current;
|
||||
|
||||
stopSound();
|
||||
|
||||
@@ -12,6 +12,7 @@ import { modals } from "@mantine/modals";
|
||||
import { Button, Flex, Text } from "@mantine/core";
|
||||
import { useSound } from "@/app/hooks/useSound";
|
||||
import useWindow from "@/app/hooks/useWindow";
|
||||
import { attachReceiverE2EE, attachSenderE2EE } from "./audioE2EE";
|
||||
|
||||
export interface CallContextValue {
|
||||
call: (callable: string) => void;
|
||||
@@ -73,7 +74,8 @@ export function CallProvider(props : CallProviderProps) {
|
||||
const roomIdRef = useRef<string>("");
|
||||
|
||||
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 remoteAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const iceCandidatesBufferRef = useRef<RTCIceCandidate[]>([]);
|
||||
@@ -245,8 +247,8 @@ export function CallProvider(props : CallProviderProps) {
|
||||
}
|
||||
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'));
|
||||
sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex');
|
||||
info("Generated shared secret for call session: " + sharedSecretRef.current);
|
||||
/**
|
||||
* Нам нужно отправить свой публичный ключ другой стороне, чтобы она тоже могла создать общую секретную сессию
|
||||
*/
|
||||
@@ -283,8 +285,8 @@ export function CallProvider(props : CallProviderProps) {
|
||||
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'));
|
||||
sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex');
|
||||
info("Generated shared secret for call session: " + sharedSecretRef.current);
|
||||
setCallState(CallState.WEB_RTC_EXCHANGE);
|
||||
}
|
||||
if(signalType == SignalType.CREATE_ROOM) {
|
||||
@@ -297,7 +299,9 @@ export function CallProvider(props : CallProviderProps) {
|
||||
* Нужно отправить свой SDP оффер другой стороне, чтобы установить WebRTC соединение
|
||||
*/
|
||||
peerConnectionRef.current = new RTCPeerConnection({
|
||||
iceServers: iceServersRef.current
|
||||
iceServers: iceServersRef.current,
|
||||
// @ts-ignore
|
||||
encodedInsertableStreams: true
|
||||
});
|
||||
/**
|
||||
* Подписываемся на 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, чтобы другая сторона могла его получить и воспроизвести,
|
||||
* когда мы установим WebRTC соединение
|
||||
*/
|
||||
const localStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true
|
||||
});
|
||||
localStream.getTracks().forEach(track => {
|
||||
peerConnectionRef.current?.addTrack(track, localStream);
|
||||
const localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const audioTrack = localStream.getAudioTracks()[0];
|
||||
|
||||
|
||||
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
|
||||
*/
|
||||
const getKeyCast = () => {
|
||||
if(!sharedSecret){
|
||||
if(!sharedSecretRef.current){
|
||||
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 CORE_MIN_REQUIRED_VERSION = "1.5.2";
|
||||
export const APP_VERSION = "1.1.1";
|
||||
export const CORE_MIN_REQUIRED_VERSION = "1.5.3";
|
||||
|
||||
export const RELEASE_NOTICE = `
|
||||
**Обновление v1.1.0** :emoji_1f631:
|
||||
- Добавлена поддержка звонков
|
||||
- Прозрачным аватаркам добавлена подложка
|
||||
- Фикс ошибки чтения
|
||||
- Подложка к вложению аватарки
|
||||
- Обмен ключами шифрования DH
|
||||
- Поддерджка WebRTC
|
||||
- Событийные звуки звонка (сбросить, мутинг, и прочее...)
|
||||
**Обновление v1.1.1** :emoji_1f631:
|
||||
- Добавлено сквозное шифрование звонков
|
||||
- Исправлена проблема с звуком в звонках на некоторых устройствах
|
||||
`;
|
||||
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/ipcDevice'
|
||||
import './ipcs/ipcCore'
|
||||
import './ipcs/ipcRuntime'
|
||||
import { Tray } from 'electron/main'
|
||||
import { join } from 'path'
|
||||
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;
|
||||
deviceId: string;
|
||||
mediaApi: {
|
||||
getSoundUrl: (fileName: string) => string;
|
||||
getSoundUrl: (fileName: string) => Promise<string>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,9 @@ import path from 'node:path'
|
||||
import fs from "node:fs";
|
||||
|
||||
|
||||
function resolveSound(fileName: string) {
|
||||
const isDev = !process.env.APP_PACKAGED; // или свой флаг dev
|
||||
const fullPath = isDev
|
||||
? path.join(process.cwd(), "resources", "sounds", fileName)
|
||||
: path.join(process.resourcesPath, "resources", "sounds", fileName);
|
||||
|
||||
async function resolveSound(fileName: string) {
|
||||
const resourcesPath = await ipcRenderer.invoke('runtime:get-resources');
|
||||
const fullPath = path.join(resourcesPath, "sounds", fileName);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error(`Sound not found: ${fullPath}`);
|
||||
}
|
||||
@@ -32,7 +29,7 @@ const exposeContext = async () => {
|
||||
}
|
||||
});
|
||||
contextBridge.exposeInMainWorld("mediaApi", {
|
||||
getSoundUrl: (fileName: string) => {
|
||||
getSoundUrl: async (fileName: string) => {
|
||||
return resolveSound(fileName);
|
||||
}
|
||||
});
|
||||
@@ -44,7 +41,7 @@ const exposeContext = async () => {
|
||||
window.api = api;
|
||||
window.shell = shell;
|
||||
window.mediaApi = {
|
||||
getSoundUrl: (fileName: string) => {
|
||||
getSoundUrl: async (fileName: string) => {
|
||||
return resolveSound(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Rosetta",
|
||||
"version": "1.5.2",
|
||||
"version": "1.5.3",
|
||||
"description": "Rosetta Messenger",
|
||||
"main": "./out/main/main.js",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"composite": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"lib": ["DOM"],
|
||||
"esModuleInterop": true,
|
||||
"types": ["electron-vite/node"],
|
||||
"paths": {
|
||||
|
||||
Reference in New Issue
Block a user