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

View File

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

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 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:
- Добавлено сквозное шифрование звонков
- Исправлена проблема с звуком в звонках на некоторых устройствах
`;

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/ipcDevice'
import './ipcs/ipcCore'
import './ipcs/ipcRuntime'
import { Tray } from 'electron/main'
import { join } from 'path'
import { Logger } from './logger'

View File

@@ -14,7 +14,7 @@ declare global {
deviceName: string;
deviceId: string;
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";
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);
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "Rosetta",
"version": "1.5.2",
"version": "1.5.3",
"description": "Rosetta Messenger",
"main": "./out/main/main.js",
"license": "MIT",

View File

@@ -5,6 +5,7 @@
"composite": true,
"jsx": "react-jsx",
"baseUrl": ".",
"lib": ["DOM"],
"esModuleInterop": true,
"types": ["electron-vite/node"],
"paths": {