Merge pull request '1.1.1-1.5.3' (#17) from dev into main
All checks were successful
Windows Kernel Build / build (push) Successful in 14m52s
MacOS Kernel Build / build (push) Successful in 17m27s
SP Builds / build (push) Successful in 6m22s
Linux Kernel Build / build (push) Successful in 41m22s

Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
2026-03-20 16:49:29 +00:00
10 changed files with 163 additions and 37 deletions

View File

@@ -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();

View File

@@ -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;
} }

View 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);
});
}

View File

@@ -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
- Событийные звуки звонка (сбросить, мутинг, и прочее...)
`; `;

View 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");
});

View File

@@ -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'

View File

@@ -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>;
}; };
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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",

View File

@@ -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": {