Merge pull request '1.1.2-1.5.3' (#20) from dev into main
Some checks failed
SP Builds / build (push) Has been cancelled
Some checks failed
SP Builds / build (push) Has been cancelled
Reviewed-on: #20
This commit was merged in pull request #20.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
name: MacOS Kernel Build
|
||||
run-name: Build and Upload MacOS Kernel
|
||||
|
||||
#Запускаем только кнопкой "Run workflow" в Actions -> Build MacOS
|
||||
on:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Linux Kernel Build
|
||||
run-name: Build and Upload Linux Kernel
|
||||
|
||||
#Запускаем только кнопкой "Run workflow" в Actions
|
||||
on:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: SP Builds
|
||||
run-name: Build and Upload SP Packages
|
||||
|
||||
#Запускаем только кнопкой "Run workflow" в Actions -> Build MacOS
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Windows Kernel Build
|
||||
run-name: Build and Upload Windows Kernel
|
||||
|
||||
#Запускаем только кнопкой "Run workflow" в Actions -> Build Windows
|
||||
#Или если есть коммпит в папку lib в ветке main
|
||||
|
||||
@@ -135,11 +135,13 @@ export function ChatHeader() {
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex h={'100%'} align={'center'} gap={'sm'}>
|
||||
<IconPhone
|
||||
{publicKey != opponent.publicKey && (
|
||||
<IconPhone
|
||||
onClick={() => call(dialog)}
|
||||
style={{
|
||||
cursor: 'pointer'
|
||||
}} stroke={1.5} color={theme.colors.blue[7]} size={24}></IconPhone>
|
||||
)}
|
||||
<IconTrashX
|
||||
onClick={onClickClearMessages}
|
||||
style={{
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ErrorBoundaryProvider } from "@/app/providers/ErrorBoundaryProvider/Err
|
||||
import { AttachmentError } from "../AttachmentError/AttachmentError";
|
||||
import { MessageAvatar } from "./MessageAvatar";
|
||||
import { MessageProps } from "../Messages/Message";
|
||||
import { MessageCall } from "./MessageCall";
|
||||
|
||||
export interface MessageAttachmentsProps {
|
||||
attachments: Attachment[];
|
||||
@@ -51,6 +52,8 @@ export function MessageAttachments(props: MessageAttachmentsProps) {
|
||||
return <MessageFile {...attachProps} key={index}></MessageFile>
|
||||
case AttachmentType.AVATAR:
|
||||
return <MessageAvatar {...attachProps} key={index}></MessageAvatar>
|
||||
case AttachmentType.CALL:
|
||||
return <MessageCall {...attachProps} key={index}></MessageCall>
|
||||
default:
|
||||
return <AttachmentError key={index}></AttachmentError>;
|
||||
}
|
||||
|
||||
62
app/components/MessageAttachments/MessageCall.tsx
Normal file
62
app/components/MessageAttachments/MessageCall.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
|
||||
import { AttachmentProps } from "./MessageAttachments";
|
||||
import { Avatar, Box, Flex, Text } from "@mantine/core";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { IconPhoneOutgoing, IconX } from "@tabler/icons-react";
|
||||
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
|
||||
|
||||
export function MessageCall(props: AttachmentProps) {
|
||||
const {
|
||||
getPreview,
|
||||
} =
|
||||
useAttachment(
|
||||
props.attachment,
|
||||
props.parent,
|
||||
);
|
||||
const preview = getPreview();
|
||||
const callerRole = preview.split("::")[0];
|
||||
const duration = parseInt(preview.split("::")[1]);
|
||||
const colors = useRosettaColors();
|
||||
const error = duration == 0;
|
||||
|
||||
return (
|
||||
<Box p={'sm'} style={{
|
||||
background: colors.mainColor,
|
||||
border: '1px solid ' + colors.borderColor,
|
||||
borderRadius: 8,
|
||||
minWidth: 200,
|
||||
minHeight: 60
|
||||
}}>
|
||||
<Flex gap={'sm'} direction={'row'}>
|
||||
<Avatar bg={error ? colors.error : colors.brandColor} size={40}>
|
||||
{!error && <>
|
||||
{callerRole == "0" && (
|
||||
<IconPhoneOutgoing color={'white'} size={22}></IconPhoneOutgoing>
|
||||
)}
|
||||
{callerRole == "1" && (
|
||||
<IconPhoneOutgoing color={'white'} size={22} style={{
|
||||
transform: 'rotate(180deg)'
|
||||
}}></IconPhoneOutgoing>
|
||||
)}
|
||||
</>}
|
||||
{error && <>
|
||||
<IconX color={'white'} size={22}></IconX>
|
||||
</>}
|
||||
</Avatar>
|
||||
<Flex direction={'column'} gap={5}>
|
||||
<Text size={'sm'}>{
|
||||
error ? (callerRole == "0" ? "Missed call" : "Rejected call") : (callerRole == "0" ? "Incoming call" : "Outgoing call")
|
||||
}</Text>
|
||||
{!error &&
|
||||
<Text size={'xs'} c={colors.chevrons.active}>
|
||||
{translateDurationToTime(duration)}
|
||||
</Text>
|
||||
}
|
||||
{error && <Text size={'xs'} c={colors.error}>
|
||||
Call was not answered or was rejected
|
||||
</Text>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
154
app/providers/AttachmentProvider/usePrepareAttachment.ts
Normal file
154
app/providers/AttachmentProvider/usePrepareAttachment.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { encodeWithPassword } from "@/app/workers/crypto/crypto";
|
||||
import { MessageReply } from "../DialogProvider/useReplyMessages";
|
||||
import { Attachment, AttachmentType } from "../ProtocolProvider/protocol/packets/packet.message";
|
||||
import { base64ImageToBlurhash } from "@/app/workers/image/image";
|
||||
import { MESSAGE_MAX_TIME_TO_DELEVERED_S } from "@/app/constants";
|
||||
import { useContext, useRef } from "react";
|
||||
import { useTransport } from "../TransportProvider/useTransport";
|
||||
import { useDialogsList } from "../DialogListProvider/useDialogsList";
|
||||
import { useDatabase } from "../DatabaseProvider/useDatabase";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { useDialogsCache } from "../DialogProvider/useDialogsCache";
|
||||
import { DialogContext } from "../DialogProvider/DialogProvider";
|
||||
|
||||
export function usePrepareAttachment() {
|
||||
const intervalsRef = useRef<NodeJS.Timeout>(null);
|
||||
const {uploadFile} = useTransport();
|
||||
const {updateDialog} = useDialogsList();
|
||||
const {runQuery} = useDatabase();
|
||||
const {info} = useConsoleLogger('usePrepareAttachment');
|
||||
const {getDialogCache} = useDialogsCache();
|
||||
const context = useContext(DialogContext);
|
||||
|
||||
const updateTimestampInDialogCache = (dialog : string, message_id: string) => {
|
||||
const dialogCache = getDialogCache(dialog);
|
||||
if(dialogCache == null){
|
||||
return;
|
||||
}
|
||||
for(let i = 0; i < dialogCache.length; i++){
|
||||
if(dialogCache[i].message_id == message_id){
|
||||
dialogCache[i].timestamp = Date.now();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет временную метку в сообщении, пока вложения отправляются,
|
||||
* потому что если этого не делать, то сообщение может быть помечено как
|
||||
* не доставленное из-за таймаута доставки
|
||||
* @param attachments Вложения
|
||||
*/
|
||||
const doTimestampUpdateImMessageWhileAttachmentsSend = (message_id: string, dialog: string) => {
|
||||
if(intervalsRef.current){
|
||||
clearInterval(intervalsRef.current);
|
||||
}
|
||||
intervalsRef.current = setInterval(async () => {
|
||||
/**
|
||||
* Обновляем время в левом меню
|
||||
*/
|
||||
await runQuery("UPDATE messages SET timestamp = ? WHERE message_id = ?", [Date.now(), message_id]);
|
||||
updateDialog(dialog);
|
||||
/**
|
||||
* Обновляем состояние в кэше диалогов
|
||||
*/
|
||||
updateTimestampInDialogCache(dialog, message_id);
|
||||
|
||||
if(context == null || !context){
|
||||
/**
|
||||
* Если этот диалог сейчас не открыт
|
||||
*/
|
||||
return;
|
||||
}
|
||||
context.setMessages((prev) => {
|
||||
return prev.map((value) => {
|
||||
if(value.message_id != message_id){
|
||||
return value;
|
||||
}
|
||||
return {
|
||||
...value,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
})
|
||||
});
|
||||
}, (MESSAGE_MAX_TIME_TO_DELEVERED_S / 2) * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет старый тег если вложения были подготовлены заново
|
||||
* например при пересылке сообщений
|
||||
*/
|
||||
const removeOldTagIfAttachemtnsRePreapred = (preview : string) => {
|
||||
if(preview.indexOf("::") == -1){
|
||||
return preview;
|
||||
}
|
||||
let parts = preview.split("::");
|
||||
return parts.slice(1).join("::");
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготавливает вложения для отправки. Подготовка
|
||||
* состоит в загрузке файлов на транспортный сервер, мы не делаем
|
||||
* это через WebSocket из-за ограничений по размеру сообщений,
|
||||
* а так же из-за надежности доставки файлов через HTTP
|
||||
* @param attachments Attachments to prepare for sending
|
||||
*/
|
||||
const prepareAttachmentsToSend = async (message_id: string, dialog: string, password: string, attachments : Attachment[], rePrepared : boolean = false) : Promise<Attachment[]> => {
|
||||
if(attachments.length <= 0){
|
||||
return [];
|
||||
}
|
||||
let prepared : Attachment[] = [];
|
||||
try{
|
||||
for(let i = 0; i < attachments.length; i++){
|
||||
const attachment : Attachment = attachments[i];
|
||||
if(attachment.type == AttachmentType.CALL){
|
||||
/**
|
||||
* Звонок загружать не надо
|
||||
*/
|
||||
continue;
|
||||
}
|
||||
if(attachment.type == AttachmentType.MESSAGES){
|
||||
let reply : MessageReply[] = JSON.parse(attachment.blob)
|
||||
for(let j = 0; j < reply.length; j++){
|
||||
reply[j].attachments = await prepareAttachmentsToSend(message_id, dialog, password, reply[j].attachments, true);
|
||||
}
|
||||
prepared.push({
|
||||
...attachment,
|
||||
blob: await encodeWithPassword(password, JSON.stringify(reply))
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if((attachment.type == AttachmentType.IMAGE
|
||||
|| attachment.type == AttachmentType.AVATAR) && attachment.preview == ""){
|
||||
/**
|
||||
* Загружаем превью blurhash для изображения
|
||||
*/
|
||||
const blurhash = await base64ImageToBlurhash(attachment.blob);
|
||||
attachment.preview = blurhash;
|
||||
}
|
||||
doTimestampUpdateImMessageWhileAttachmentsSend(message_id, dialog);
|
||||
const content = await encodeWithPassword(password, attachment.blob);
|
||||
const upid = attachment.id;
|
||||
info(`Uploading attachment with upid: ${upid}`);
|
||||
info(`Attachment content length: ${content.length}`);
|
||||
let tag = await uploadFile(upid, content);
|
||||
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
|
||||
if(intervalsRef.current != null){
|
||||
clearInterval(intervalsRef.current);
|
||||
}
|
||||
prepared.push({
|
||||
...attachment,
|
||||
preview: tag + "::" + (rePrepared ? removeOldTagIfAttachemtnsRePreapred(attachment.preview) : attachment.preview),
|
||||
blob: ""
|
||||
});
|
||||
}
|
||||
return prepared;
|
||||
}catch(e){
|
||||
return prepared;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prepareAttachmentsToSend
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import { Button, Flex, Text } from "@mantine/core";
|
||||
import { useSound } from "@/app/hooks/useSound";
|
||||
import useWindow from "@/app/hooks/useWindow";
|
||||
import { attachReceiverE2EE, attachSenderE2EE } from "./audioE2EE";
|
||||
import { useDeattachedSender } from "../DialogProvider/useDeattachedSender";
|
||||
import { AttachmentType } from "../ProtocolProvider/protocol/packets/packet.message";
|
||||
import { generateRandomKey } from "@/app/utils/utils";
|
||||
|
||||
export interface CallContextValue {
|
||||
call: (callable: string) => void;
|
||||
@@ -74,13 +77,13 @@ export function CallProvider(props : CallProviderProps) {
|
||||
const roomIdRef = useRef<string>("");
|
||||
|
||||
const roleRef = useRef<CallRole | null>(null);
|
||||
//const [sharedSecret, setSharedSecret] = useState<string>("");
|
||||
const sharedSecretRef = useRef<string>("");
|
||||
const sharedSecretRef = useRef<string>("");
|
||||
const iceServersRef = useRef<RTCIceServer[]>([]);
|
||||
const remoteAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const iceCandidatesBufferRef = useRef<RTCIceCandidate[]>([]);
|
||||
const mutedRef = useRef<boolean>(false);
|
||||
const soundRef = useRef<boolean>(true);
|
||||
const {sendMessage} = useDeattachedSender();
|
||||
|
||||
const {playSound, stopSound, stopLoopSound} = useSound();
|
||||
const {setWindowPriority} = useWindow();
|
||||
@@ -370,7 +373,7 @@ export function CallProvider(props : CallProviderProps) {
|
||||
send(offerSignal);
|
||||
return;
|
||||
}
|
||||
}, [activeCall, sessionKeys]);
|
||||
}, [activeCall, sessionKeys, duration]);
|
||||
|
||||
const openCallsModal = (text : string) => {
|
||||
modals.open({
|
||||
@@ -435,6 +438,7 @@ export function CallProvider(props : CallProviderProps) {
|
||||
remoteAudioRef.current.pause();
|
||||
remoteAudioRef.current.srcObject = null;
|
||||
}
|
||||
generateCallAttachment();
|
||||
setDuration(0);
|
||||
durationIntervalRef.current && clearInterval(durationIntervalRef.current);
|
||||
setWindowPriority(false);
|
||||
@@ -454,6 +458,27 @@ export function CallProvider(props : CallProviderProps) {
|
||||
roleRef.current = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет сообщение в диалог с звонящим с информацией о звонке
|
||||
*/
|
||||
const generateCallAttachment = () => {
|
||||
let preview = "";
|
||||
if(roleRef.current == CallRole.CALLER){
|
||||
preview += "1::";
|
||||
}
|
||||
if(roleRef.current == CallRole.CALLEE){
|
||||
preview += "0::";
|
||||
}
|
||||
preview += duration.toString();
|
||||
|
||||
sendMessage(activeCall, "", [{
|
||||
id: generateRandomKey(16),
|
||||
preview: preview,
|
||||
type: AttachmentType.CALL,
|
||||
blob: ""
|
||||
}], false);
|
||||
}
|
||||
|
||||
const accept = () => {
|
||||
if(callState != CallState.INCOMING){
|
||||
/**
|
||||
|
||||
@@ -1,105 +1,114 @@
|
||||
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;
|
||||
}
|
||||
import _sodium from "libsodium-wrappers-sumo";
|
||||
|
||||
type KeyInput = Buffer | Uint8Array;
|
||||
|
||||
const senderAttached = new WeakSet<RTCRtpSender>();
|
||||
const receiverAttached = new WeakSet<RTCRtpReceiver>();
|
||||
|
||||
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}`);
|
||||
}
|
||||
let sodiumReady = false;
|
||||
let sodium: typeof _sodium;
|
||||
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyBytes,
|
||||
{ name: "AES-CTR" },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
export async function initE2EE(): Promise<void> {
|
||||
if (sodiumReady) return;
|
||||
await _sodium.ready;
|
||||
sodium = _sodium;
|
||||
sodiumReady = true;
|
||||
}
|
||||
|
||||
function toBigIntTs(ts: unknown): bigint {
|
||||
if (typeof ts === "bigint") return ts;
|
||||
if (typeof ts === "number") return BigInt(ts);
|
||||
return 0n;
|
||||
function toUint8Array(input: KeyInput): Uint8Array {
|
||||
const u8 = input instanceof Uint8Array ? input : new Uint8Array(input);
|
||||
return new Uint8Array(u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
function fillNonceFromTimestamp(nonce: Uint8Array, tsRaw: unknown): void {
|
||||
nonce.fill(0);
|
||||
let ts = 0n;
|
||||
if (typeof tsRaw === "bigint") ts = tsRaw;
|
||||
else if (typeof tsRaw === "number" && Number.isFinite(tsRaw)) ts = BigInt(Math.floor(tsRaw));
|
||||
nonce[0] = Number((ts >> 56n) & 0xffn);
|
||||
nonce[1] = Number((ts >> 48n) & 0xffn);
|
||||
nonce[2] = Number((ts >> 40n) & 0xffn);
|
||||
nonce[3] = Number((ts >> 32n) & 0xffn);
|
||||
nonce[4] = Number((ts >> 24n) & 0xffn);
|
||||
nonce[5] = Number((ts >> 16n) & 0xffn);
|
||||
nonce[6] = Number((ts >> 8n) & 0xffn);
|
||||
nonce[7] = Number(ts & 0xffn);
|
||||
}
|
||||
|
||||
function createFrameProcessor(key: Uint8Array) {
|
||||
const nonceLen = sodium.crypto_stream_xchacha20_NONCEBYTES; // 24
|
||||
const nonce = new Uint8Array(nonceLen);
|
||||
|
||||
return function processFrame(data: ArrayBuffer, timestamp: unknown): ArrayBuffer {
|
||||
const input = new Uint8Array(data);
|
||||
fillNonceFromTimestamp(nonce, timestamp);
|
||||
|
||||
const output = sodium.crypto_stream_xchacha20_xor(input, nonce, key);
|
||||
return output.buffer.slice(output.byteOffset, output.byteOffset + output.byteLength) as ArrayBuffer;
|
||||
};
|
||||
}
|
||||
|
||||
function createTransform(processFrame: (data: ArrayBuffer, timestamp: unknown) => ArrayBuffer) {
|
||||
return new TransformStream<any, any>({
|
||||
transform(frame, controller) {
|
||||
try {
|
||||
frame.data = processFrame(frame.data, frame.timestamp);
|
||||
} catch (e) {
|
||||
console.error("[E2EE] frame error:", e);
|
||||
}
|
||||
controller.enqueue(frame);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function attachSenderE2EE(sender: RTCRtpSender, keyInput: KeyInput): Promise<void> {
|
||||
const key = await importAesCtrKey(keyInput);
|
||||
if (senderAttached.has(sender)) return;
|
||||
senderAttached.add(sender);
|
||||
|
||||
const anySender = sender as unknown as {
|
||||
createEncodedStreams?: () => { readable: ReadableStream<any>; writable: WritableStream<any> };
|
||||
};
|
||||
await initE2EE();
|
||||
|
||||
const key = toUint8Array(keyInput);
|
||||
const keyLen = sodium.crypto_stream_xchacha20_KEYBYTES; // 32
|
||||
if (key.byteLength < keyLen) {
|
||||
throw new Error(`Key must be at least ${keyLen} bytes`);
|
||||
}
|
||||
|
||||
const anySender = sender as any;
|
||||
if (!anySender.createEncodedStreams) {
|
||||
throw new Error("createEncodedStreams is not available on RTCRtpSender");
|
||||
throw new Error("createEncodedStreams not available on RTCRtpSender");
|
||||
}
|
||||
|
||||
const { readable, writable } = anySender.createEncodedStreams();
|
||||
const processFrame = createFrameProcessor(key.slice(0, keyLen));
|
||||
|
||||
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);
|
||||
});
|
||||
readable
|
||||
.pipeThrough(createTransform(processFrame))
|
||||
.pipeTo(writable)
|
||||
.catch((e) => console.error("[E2EE] Sender pipeline failed:", e));
|
||||
}
|
||||
|
||||
export async function attachReceiverE2EE(receiver: RTCRtpReceiver, keyInput: KeyInput): Promise<void> {
|
||||
const key = await importAesCtrKey(keyInput);
|
||||
if (receiverAttached.has(receiver)) return;
|
||||
receiverAttached.add(receiver);
|
||||
|
||||
const anyReceiver = receiver as unknown as {
|
||||
createEncodedStreams?: () => { readable: ReadableStream<any>; writable: WritableStream<any> };
|
||||
};
|
||||
await initE2EE();
|
||||
|
||||
const key = toUint8Array(keyInput);
|
||||
const keyLen = sodium.crypto_stream_xchacha20_KEYBYTES; // 32
|
||||
if (key.byteLength < keyLen) {
|
||||
throw new Error(`Key must be at least ${keyLen} bytes`);
|
||||
}
|
||||
|
||||
const anyReceiver = receiver as any;
|
||||
if (!anyReceiver.createEncodedStreams) {
|
||||
throw new Error("createEncodedStreams is not available on RTCRtpReceiver");
|
||||
throw new Error("createEncodedStreams not available on RTCRtpReceiver");
|
||||
}
|
||||
|
||||
const { readable, writable } = anyReceiver.createEncodedStreams();
|
||||
const processFrame = createFrameProcessor(key.slice(0, keyLen));
|
||||
|
||||
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);
|
||||
});
|
||||
readable
|
||||
.pipeThrough(createTransform(processFrame))
|
||||
.pipeTo(writable)
|
||||
.catch((e) => console.error("[E2EE] Receiver pipeline failed:", e));
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from '@/app/workers/crypto/crypto';
|
||||
import { chacha20Decrypt, decodeWithPassword, decrypt, generateMd5 } from '@/app/workers/crypto/crypto';
|
||||
import { useDatabase } from '@/app/providers/DatabaseProvider/useDatabase';
|
||||
import { createContext, useEffect, useRef, useState } from 'react';
|
||||
import { Attachment, AttachmentType, PacketMessage } from '@/app/providers/ProtocolProvider/protocol/packets/packet.message';
|
||||
@@ -11,21 +11,18 @@ import { useBlacklist } from '../BlacklistProvider/useBlacklist';
|
||||
import { useLogger } from '@/app/hooks/useLogger';
|
||||
import { useSender } from '../ProtocolProvider/useSender';
|
||||
import { usePacket } from '../ProtocolProvider/usePacket';
|
||||
import { MAX_MESSAGES_LOAD, MESSAGE_MAX_LOADED, MESSAGE_MAX_TIME_TO_DELEVERED_S, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from '@/app/constants';
|
||||
import { MAX_MESSAGES_LOAD, MESSAGE_MAX_LOADED, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from '@/app/constants';
|
||||
import { PacketDelivery } from '@/app/providers/ProtocolProvider/protocol/packets/packet.delivery';
|
||||
import { useIdle } from '@mantine/hooks';
|
||||
import { useWindowFocus } from '@/app/hooks/useWindowFocus';
|
||||
import { useDialogsCache } from './useDialogsCache';
|
||||
import { useConsoleLogger } from '@/app/hooks/useConsoleLogger';
|
||||
import { useViewPanelsState, ViewPanelsState } from '@/app/hooks/useViewPanelsState';
|
||||
import { MessageReply } from './useReplyMessages';
|
||||
import { useTransport } from '../TransportProvider/useTransport';
|
||||
import { useFileStorage } from '@/app/hooks/useFileStorage';
|
||||
import { useSystemAccounts } from '../SystemAccountsProvider/useSystemAccounts';
|
||||
import { useDialogsList } from '../DialogListProvider/useDialogsList';
|
||||
import { useGroups } from './useGroups';
|
||||
import { useMentions } from '../DialogStateProvider.tsx/useMentions';
|
||||
import { base64ImageToBlurhash } from '@/app/workers/image/image';
|
||||
|
||||
export interface DialogContextValue {
|
||||
loading: boolean;
|
||||
@@ -33,7 +30,6 @@ export interface DialogContextValue {
|
||||
setMessages: (messages: React.SetStateAction<Message[]>) => void;
|
||||
dialog: string;
|
||||
clearDialogCache: () => void;
|
||||
prepareAttachmentsToSend: (password: string, attachments: Attachment[]) => Promise<Attachment[]>;
|
||||
loadMessagesToTop: () => Promise<void>;
|
||||
loadMessagesToMessageId: (messageId: string) => Promise<void>;
|
||||
}
|
||||
@@ -71,6 +67,23 @@ interface DialogProviderProps {
|
||||
dialog: string;
|
||||
}
|
||||
|
||||
type DialogMessageEvent = {
|
||||
dialogId: string;
|
||||
message: Message;
|
||||
};
|
||||
|
||||
const bus = new EventTarget();
|
||||
|
||||
export const emitDialogMessage = (payload: DialogMessageEvent) => {
|
||||
bus.dispatchEvent(new CustomEvent<DialogMessageEvent>("dialog:message", { detail: payload }));
|
||||
};
|
||||
|
||||
export const onDialogMessage = (handler: (payload: DialogMessageEvent) => void) => {
|
||||
const listener = (e: Event) => handler((e as CustomEvent<DialogMessageEvent>).detail);
|
||||
bus.addEventListener("dialog:message", listener);
|
||||
return () => bus.removeEventListener("dialog:message", listener);
|
||||
};
|
||||
|
||||
export function DialogProvider(props: DialogProviderProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const {allQuery, runQuery} = useDatabase();
|
||||
@@ -88,15 +101,21 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
const {getDialogCache, addOrUpdateDialogCache, dialogsCache, setDialogsCache} = useDialogsCache();
|
||||
const {info, warn, error} = useConsoleLogger('DialogProvider');
|
||||
const [viewState] = useViewPanelsState();
|
||||
const {uploadFile} = useTransport();
|
||||
const {readFile} = useFileStorage();
|
||||
const intervalsRef = useRef<NodeJS.Timeout>(null);
|
||||
const systemAccounts = useSystemAccounts();
|
||||
const {updateDialog} = useDialogsList();
|
||||
const {hasGroup, getGroupKey} = useGroups();
|
||||
const {popMention, isMentioned} = useMentions();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onDialogMessage(({ dialogId, message }) => {
|
||||
if (dialogId !== props.dialog) return;
|
||||
setMessages((prev) => [...prev, message]);
|
||||
});
|
||||
return unsub;
|
||||
}, [props.dialog]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentDialogPublicKeyView(props.dialog);
|
||||
return () => {
|
||||
@@ -919,6 +938,16 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if(meta.type == AttachmentType.CALL){
|
||||
/**
|
||||
* Если это звонок
|
||||
*/
|
||||
attachments.push({
|
||||
...meta,
|
||||
blob: ""
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const fileData = await readFile(`m/${await generateMd5(meta.id + publicKey)}`);
|
||||
if(!fileData) {
|
||||
attachments.push({
|
||||
@@ -940,110 +969,12 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
}
|
||||
return attachments;
|
||||
}catch(e) {
|
||||
console.info(e);
|
||||
error("Failed to parse attachments");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет временную метку в сообщении, пока вложения отправляются,
|
||||
* потому что если этого не делать, то сообщение может быть помечено как
|
||||
* не доставленное из-за таймаута доставки
|
||||
* @param attachments Вложения
|
||||
*/
|
||||
const doTimestampUpdateImMessageWhileAttachmentsSend = (attachments : Attachment[]) => {
|
||||
if(intervalsRef.current){
|
||||
clearInterval(intervalsRef.current);
|
||||
}
|
||||
intervalsRef.current = setInterval(() => {
|
||||
//update timestamp in message to keep message marked as error
|
||||
updateDialog(props.dialog);
|
||||
setMessages((prev) => {
|
||||
return prev.map((value) => {
|
||||
if(value.attachments.length <= 0){
|
||||
return value;
|
||||
}
|
||||
if(value.attachments[0].id != attachments[0].id){
|
||||
return value;
|
||||
}
|
||||
runQuery("UPDATE messages SET timestamp = ? WHERE message_id = ?", [Date.now(), value.message_id]);
|
||||
return {
|
||||
...value,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
})
|
||||
});
|
||||
}, (MESSAGE_MAX_TIME_TO_DELEVERED_S / 2) * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет старый тег если вложения были подготовлены заново
|
||||
* например при пересылке сообщений
|
||||
*/
|
||||
const removeOldTagIfAttachemtnsRePreapred = (preview : string) => {
|
||||
if(preview.indexOf("::") == -1){
|
||||
return preview;
|
||||
}
|
||||
let parts = preview.split("::");
|
||||
return parts.slice(1).join("::");
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготавливает вложения для отправки. Подготовка
|
||||
* состоит в загрузке файлов на транспортный сервер, мы не делаем
|
||||
* это через WebSocket из-за ограничений по размеру сообщений,
|
||||
* а так же из-за надежности доставки файлов через HTTP
|
||||
* @param attachments Attachments to prepare for sending
|
||||
*/
|
||||
const prepareAttachmentsToSend = async (password: string, attachments : Attachment[], rePrepared : boolean = false) : Promise<Attachment[]> => {
|
||||
if(attachments.length <= 0){
|
||||
return [];
|
||||
}
|
||||
let prepared : Attachment[] = [];
|
||||
try{
|
||||
for(let i = 0; i < attachments.length; i++){
|
||||
const attachment : Attachment = attachments[i];
|
||||
if(attachment.type == AttachmentType.MESSAGES){
|
||||
let reply : MessageReply[] = JSON.parse(attachment.blob)
|
||||
for(let j = 0; j < reply.length; j++){
|
||||
reply[j].attachments = await prepareAttachmentsToSend(password, reply[j].attachments, true);
|
||||
}
|
||||
prepared.push({
|
||||
...attachment,
|
||||
blob: await encodeWithPassword(password, JSON.stringify(reply))
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if((attachment.type == AttachmentType.IMAGE
|
||||
|| attachment.type == AttachmentType.AVATAR) && attachment.preview == ""){
|
||||
/**
|
||||
* Загружаем превью blurhash для изображения
|
||||
*/
|
||||
const blurhash = await base64ImageToBlurhash(attachment.blob);
|
||||
attachment.preview = blurhash;
|
||||
}
|
||||
doTimestampUpdateImMessageWhileAttachmentsSend(attachments);
|
||||
const content = await encodeWithPassword(password, attachment.blob);
|
||||
const upid = attachment.id;
|
||||
info(`Uploading attachment with upid: ${upid}`);
|
||||
info(`Attachment content length: ${content.length}`);
|
||||
let tag = await uploadFile(upid, content);
|
||||
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
|
||||
if(intervalsRef.current != null){
|
||||
clearInterval(intervalsRef.current);
|
||||
}
|
||||
prepared.push({
|
||||
...attachment,
|
||||
preview: tag + "::" + (rePrepared ? removeOldTagIfAttachemtnsRePreapred(attachment.preview) : attachment.preview),
|
||||
blob: ""
|
||||
});
|
||||
}
|
||||
return prepared;
|
||||
}catch(e){
|
||||
return prepared;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Дедубликация сообщений по message_id, так как может возникать ситуация, что одно и то же сообщение
|
||||
* может загрузиться несколько раз при накладках сети, отставании часов, при синхронизации
|
||||
@@ -1071,7 +1002,6 @@ export function DialogProvider(props: DialogProviderProps) {
|
||||
setDialogsCache(dialogsCache.filter((cache) => cache.publicKey != props.dialog));
|
||||
},
|
||||
dialog: props.dialog,
|
||||
prepareAttachmentsToSend,
|
||||
loadMessagesToTop,
|
||||
loadMessagesToMessageId
|
||||
}}>
|
||||
|
||||
160
app/providers/DialogProvider/useDeattachedSender.ts
Normal file
160
app/providers/DialogProvider/useDeattachedSender.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { generateRandomKey } from "@/app/utils/utils";
|
||||
import { Attachment, AttachmentType, PacketMessage } from "../ProtocolProvider/protocol/packets/packet.message";
|
||||
import { useGroups } from "./useGroups";
|
||||
import { chacha20Encrypt, encodeWithPassword, encrypt, generateMd5 } from "@/app/workers/crypto/crypto";
|
||||
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { AttachmentMeta, DeliveredMessageState, emitDialogMessage, Message } from "./DialogProvider";
|
||||
import { useDatabase } from "../DatabaseProvider/useDatabase";
|
||||
import { useFileStorage } from "@/app/hooks/useFileStorage";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
|
||||
import { useDialogsList } from "../DialogListProvider/useDialogsList";
|
||||
import { useProtocolState } from "../ProtocolProvider/useProtocolState";
|
||||
import { usePrivateKeyHash } from "../AccountProvider/usePrivateKeyHash";
|
||||
import { useSender } from "../ProtocolProvider/useSender";
|
||||
import { usePrepareAttachment } from "../AttachmentProvider/usePrepareAttachment";
|
||||
|
||||
/**
|
||||
* Используется для отправки сообщений не внутри DialogProvider, а например в CallProvider,
|
||||
* когда нам нужно отправить сообщение от своего имени что мы совершли звонок (Attachment.CALL)
|
||||
*/
|
||||
export function useDeattachedSender() {
|
||||
const {hasGroup, getGroupKey} = useGroups();
|
||||
const privatePlain = usePrivatePlain();
|
||||
const {warn} = useConsoleLogger('useDeattachedSender');
|
||||
const {runQuery} = useDatabase();
|
||||
const {writeFile} = useFileStorage();
|
||||
const publicKey = usePublicKey();
|
||||
const {updateDialog} = useDialogsList();
|
||||
const [protocolState] = useProtocolState();
|
||||
const privateKey = usePrivateKeyHash();
|
||||
const send = useSender();
|
||||
const {prepareAttachmentsToSend} = usePrepareAttachment();
|
||||
|
||||
/**
|
||||
* Отправка сообщения в диалог
|
||||
* @param dialog ID диалога, может быть как публичным ключом собеседника, так и ID группового диалога
|
||||
* @param message Сообщение
|
||||
* @param attachemnts Вложения
|
||||
*/
|
||||
const sendMessage = async (dialog: string, message: string, attachemnts : Attachment[], serverSent: boolean = false) => {
|
||||
const messageId = generateRandomKey(16);
|
||||
|
||||
|
||||
let cahchaEncrypted = {ciphertext: "", key: "", nonce: ""} as any;
|
||||
let key = Buffer.from("");
|
||||
let encryptedKey = "";
|
||||
let plainMessage = "";
|
||||
let content = "";
|
||||
|
||||
if(!hasGroup(dialog)){
|
||||
cahchaEncrypted = (await chacha20Encrypt(message.trim()) as any);
|
||||
key = Buffer.concat([
|
||||
Buffer.from(cahchaEncrypted.key, "hex"),
|
||||
Buffer.from(cahchaEncrypted.nonce, "hex")]);
|
||||
encryptedKey = await encrypt(key.toString('binary'), dialog);
|
||||
plainMessage = await encodeWithPassword(privatePlain, message.trim());
|
||||
content = cahchaEncrypted.ciphertext;
|
||||
}else{
|
||||
/**
|
||||
* Это группа, там шифрование устроено иначе
|
||||
* для групп используется один общий ключ, который
|
||||
* есть только у участников группы, сам ключ при этом никак
|
||||
* не отправляется по сети (ведь ID у группы общий и у каждого
|
||||
* и так есть этот ключ)
|
||||
*/
|
||||
const groupKey = await getGroupKey(dialog);
|
||||
if(!groupKey){
|
||||
warn("Group key not found for dialog " + dialog);
|
||||
return;
|
||||
}
|
||||
content = await encodeWithPassword(groupKey, message.trim());
|
||||
plainMessage = await encodeWithPassword(privatePlain, message.trim());
|
||||
encryptedKey = ""; // В группах не нужен зашифрованный ключ
|
||||
key = Buffer.from(groupKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Нужно зашифровать ключ еще и нашим ключом,
|
||||
* чтобы в последствии мы могли расшифровать этот ключ у своих
|
||||
* же сообщений (смотреть problem_sync.md)
|
||||
*/
|
||||
const aesChachaKey = await encodeWithPassword(privatePlain, key.toString('binary'));
|
||||
|
||||
emitDialogMessage({
|
||||
dialogId: dialog,
|
||||
message: {
|
||||
from_public_key: publicKey,
|
||||
to_public_key: dialog,
|
||||
content: content,
|
||||
timestamp: Date.now(),
|
||||
readed: publicKey == dialog ? 1 : 0,
|
||||
chacha_key: "",
|
||||
from_me: 1,
|
||||
plain_message: message,
|
||||
delivered: serverSent ? (publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING) : DeliveredMessageState.DELIVERED,
|
||||
message_id: messageId,
|
||||
attachments: attachemnts
|
||||
} as Message
|
||||
})
|
||||
|
||||
|
||||
let attachmentsMeta : AttachmentMeta[] = [];
|
||||
for(let i = 0; i < attachemnts.length; i++) {
|
||||
const attachment = attachemnts[i];
|
||||
attachmentsMeta.push({
|
||||
id: attachment.id,
|
||||
type: attachment.type,
|
||||
preview: attachment.preview
|
||||
});
|
||||
if(attachment.type == AttachmentType.FILE){
|
||||
/**
|
||||
* Обычно вложения дублируются на диск. Так происходит со всем.
|
||||
* Кроме файлов. Если дублировать файл весом в 2гб на диск отправка будет
|
||||
* занимать очень много времени.
|
||||
* К тому же, это приведет к созданию ненужной копии у отправителя
|
||||
*/
|
||||
continue;
|
||||
}
|
||||
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`, Buffer.from(await encodeWithPassword(privatePlain, attachment.blob)).toString('binary'));
|
||||
}
|
||||
|
||||
await runQuery(`
|
||||
INSERT INTO messages
|
||||
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [publicKey, dialog, content, Date.now(), publicKey == dialog ? 1 : 0, encryptedKey, 1, plainMessage, publicKey, messageId, publicKey == dialog ? DeliveredMessageState.DELIVERED : (
|
||||
(serverSent ? (protocolState != ProtocolState.CONNECTED ? DeliveredMessageState.ERROR : DeliveredMessageState.WAITING) : DeliveredMessageState.DELIVERED)
|
||||
), JSON.stringify(attachmentsMeta)]);
|
||||
updateDialog(dialog);
|
||||
if(publicKey == ""
|
||||
|| dialog == ""
|
||||
|| publicKey == dialog) {
|
||||
return;
|
||||
}
|
||||
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('utf-8'), attachemnts);
|
||||
if(attachemnts.length <= 0 && message.trim() == ""){
|
||||
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
|
||||
updateDialog(dialog);
|
||||
return;
|
||||
}
|
||||
|
||||
if(!serverSent){
|
||||
return;
|
||||
}
|
||||
|
||||
const packet = new PacketMessage();
|
||||
packet.setFromPublicKey(publicKey);
|
||||
packet.setToPublicKey(dialog);
|
||||
packet.setContent(content);
|
||||
packet.setChachaKey(encryptedKey);
|
||||
packet.setPrivateKey(privateKey);
|
||||
packet.setMessageId(messageId);
|
||||
packet.setTimestamp(Date.now());
|
||||
packet.setAttachments(preparedToNetworkSendAttachements);
|
||||
packet.setAesChachaKey(aesChachaKey);
|
||||
send(packet);
|
||||
}
|
||||
|
||||
return {sendMessage};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { useProtocolState } from "../ProtocolProvider/useProtocolState";
|
||||
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
|
||||
import { useGroups } from "./useGroups";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { usePrepareAttachment } from "../AttachmentProvider/usePrepareAttachment";
|
||||
|
||||
export function useDialog() : {
|
||||
messages: Message[];
|
||||
@@ -34,8 +35,7 @@ export function useDialog() : {
|
||||
throw new Error("useDialog must be used within a DialogProvider");
|
||||
}
|
||||
const {loading,
|
||||
messages,
|
||||
prepareAttachmentsToSend,
|
||||
messages,
|
||||
clearDialogCache,
|
||||
setMessages,
|
||||
dialog, loadMessagesToTop, loadMessagesToMessageId} = context;
|
||||
@@ -47,6 +47,7 @@ export function useDialog() : {
|
||||
const [protocolState] = useProtocolState();
|
||||
const {hasGroup, getGroupKey} = useGroups();
|
||||
const {warn} = useConsoleLogger('useDialog');
|
||||
const {prepareAttachmentsToSend} = usePrepareAttachment();
|
||||
|
||||
/**
|
||||
* Отправка сообщения в диалог
|
||||
@@ -146,7 +147,7 @@ export function useDialog() : {
|
||||
|
||||
//98acbbc68f4b2449daf0a39d1b3eab9a3056da5d45b811bbc903e214c21d39643394980231e1a89c811830d870f3354184319665327ca8bd
|
||||
console.info("Sending key for message ", key.toString('hex'));
|
||||
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(key.toString('utf-8'), attachemnts);
|
||||
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('utf-8'), attachemnts);
|
||||
if(attachemnts.length <= 0 && message.trim() == ""){
|
||||
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
|
||||
updateDialog(dialog);
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useDialogsCache() {
|
||||
|
||||
const updateAttachmentInDialogCache = (attachment_id: string, blob: string) => {
|
||||
/**
|
||||
* TODO: Optimize this function to avoid full map if possible
|
||||
* TODO: Оптимизировать чтобы проходил снизу вверх
|
||||
*/
|
||||
let newCache = dialogsCache.map((cache) => {
|
||||
let newMessages = cache.messages.map((message) => {
|
||||
|
||||
@@ -5,7 +5,8 @@ export enum AttachmentType {
|
||||
IMAGE = 0,
|
||||
MESSAGES = 1,
|
||||
FILE = 2,
|
||||
AVATAR = 3
|
||||
AVATAR = 3,
|
||||
CALL
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
|
||||
@@ -15,6 +15,8 @@ export const constructLastMessageTextByAttachments = (attachment: string) => {
|
||||
return "$a=File";
|
||||
case AttachmentType.AVATAR:
|
||||
return "$a=Avatar";
|
||||
case AttachmentType.CALL:
|
||||
return "$a=Call";
|
||||
default:
|
||||
return "[Unsupported attachment]";
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export const APP_VERSION = "1.1.1";
|
||||
export const APP_VERSION = "1.1.2";
|
||||
export const CORE_MIN_REQUIRED_VERSION = "1.5.3";
|
||||
|
||||
export const RELEASE_NOTICE = `
|
||||
**Обновление v1.1.1** :emoji_1f631:
|
||||
- Добавлено сквозное шифрование звонков
|
||||
- Исправлена проблема с звуком в звонках на некоторых устройствах
|
||||
**Обновление v1.1.2** :emoji_1f631:
|
||||
- Улучшено шифрование звонков, теперь они более производительне и стабильные.
|
||||
- Добавлены события звонков (начало, окончание, пропущенные).
|
||||
- Улучшена организация кода.
|
||||
- Исправлены мелкие баги и улучшена стабильность приложения.
|
||||
`;
|
||||
10
package.json
10
package.json
@@ -7,7 +7,10 @@
|
||||
"build": {
|
||||
"electronUpdaterCompatibility": false,
|
||||
"extraResources": [
|
||||
{ "from": "resources/", "to": "resources/" }
|
||||
{
|
||||
"from": "resources/",
|
||||
"to": "resources/"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"node_modules/sqlite3/**/*",
|
||||
@@ -81,7 +84,7 @@
|
||||
"@mantine/form": "^8.3.12",
|
||||
"@mantine/hooks": "^8.3.12",
|
||||
"@mantine/modals": "^8.3.12",
|
||||
"@noble/ciphers": "^1.2.1",
|
||||
"@noble/ciphers": "^1.3.0",
|
||||
"@noble/secp256k1": "^3.0.0",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
@@ -108,6 +111,8 @@
|
||||
"jsencrypt": "^3.3.2",
|
||||
"jszip": "^3.10.1",
|
||||
"libsodium": "^0.8.2",
|
||||
"libsodium-wrappers": "^0.8.2",
|
||||
"libsodium-wrappers-sumo": "^0.8.2",
|
||||
"lottie-react": "^2.4.1",
|
||||
"node-forge": "^1.3.1",
|
||||
"node-machine-id": "^1.1.12",
|
||||
@@ -133,6 +138,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",
|
||||
|
||||
Reference in New Issue
Block a user