Calls: Upgrade library (#6916)

This commit is contained in:
zubiden 2026-05-05 13:46:51 +02:00 committed by Alexander Zinchuk
parent f2b3eed845
commit 4586064410
45 changed files with 4421 additions and 938 deletions

View File

@ -32,7 +32,6 @@ export default defineConfig(
'src/lib/gramjs/tl/', 'src/lib/gramjs/tl/',
'src/lib/lovely-chart/**', 'src/lib/lovely-chart/**',
'src/lib/music-metadata-browser', 'src/lib/music-metadata-browser',
'src/lib/secret-sauce/',
'src/lib/fastBlur.js', 'src/lib/fastBlur.js',
'src/types/language.d.ts', 'src/types/language.d.ts',
'dist/', 'dist/',

View File

@ -6,11 +6,30 @@ import type {
GroupCallParticipant, GroupCallParticipant,
GroupCallParticipantVideo, GroupCallParticipantVideo,
SsrcGroup, SsrcGroup,
} from '../../../lib/secret-sauce'; } from '../../../lib/vibecalls';
import type { ApiGroupCall, ApiPhoneCall } from '../../types'; import type { ApiGroupCall, ApiPhoneCall } from '../../types';
import { CALL_PROTOCOL_LIBRARY_VERSIONS } from '../../../config';
import { sanitizePrimitiveRecord } from '../../../util/primitives/primitiveRecord';
import { getApiChatIdFromMtpPeer, isMtpPeerUser } from './peers'; import { getApiChatIdFromMtpPeer, isMtpPeerUser } from './peers';
function parseCallParameters(data?: GramJs.TypeDataJSON) {
if (!data?.data) {
return undefined;
}
try {
const parsed = JSON.parse(data.data);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return undefined;
}
return sanitizePrimitiveRecord(parsed as Record<string, unknown>);
} catch {
return undefined;
}
}
export function buildApiGroupCallParticipant(participant: GramJs.GroupCallParticipant): GroupCallParticipant { export function buildApiGroupCallParticipant(participant: GramJs.GroupCallParticipant): GroupCallParticipant {
const { const {
self, min, about, date, versioned, canSelfUnmute, justJoined, left, muted, mutedByYou, source, volume, self, min, about, date, versioned, canSelfUnmute, justJoined, left, muted, mutedByYou, source, volume,
@ -134,7 +153,7 @@ export function buildPhoneCall(call: GramJs.TypePhoneCall): ApiPhoneCall {
if (call instanceof GramJs.PhoneCall) { if (call instanceof GramJs.PhoneCall) {
const { const {
p2pAllowed, gAOrB, keyFingerprint, connections, startDate, p2pAllowed, gAOrB, keyFingerprint, connections, startDate, customParameters,
} = call; } = call;
phoneCall = { phoneCall = {
@ -145,6 +164,7 @@ export function buildPhoneCall(call: GramJs.TypePhoneCall): ApiPhoneCall {
startDate, startDate,
isP2pAllowed: Boolean(p2pAllowed), isP2pAllowed: Boolean(p2pAllowed),
connections: connections.map(buildApiCallConnection).filter(Boolean), connections: connections.map(buildApiCallConnection).filter(Boolean),
customParameters: parseCallParameters(customParameters),
}; };
} }
@ -234,8 +254,9 @@ export function buildApiCallProtocol(protocol: GramJs.PhoneCallProtocol): ApiCal
export function buildCallProtocol() { export function buildCallProtocol() {
return new GramJs.PhoneCallProtocol({ return new GramJs.PhoneCallProtocol({
libraryVersions: ['4.0.0'], libraryVersions: CALL_PROTOCOL_LIBRARY_VERSIONS,
minLayer: 92, // Hardcoded values according to the docs
minLayer: 65,
maxLayer: 92, maxLayer: 92,
udpReflector: true, udpReflector: true,
udpP2p: true, udpP2p: true,

View File

@ -1,9 +1,9 @@
import { Api as GramJs } from '../../../lib/gramjs'; import { Api as GramJs } from '../../../lib/gramjs';
import { generateRandomInt32 } from '../../../lib/gramjs/Helpers'; import { generateRandomInt32 } from '../../../lib/gramjs/Helpers';
import type { JoinGroupCallPayload } from '../../../lib/secret-sauce'; import type { JoinGroupCallPayload } from '../../../lib/vibecalls';
import type { import type {
ApiChat, ApiGroupCall, ApiPhoneCall, ApiUser, ApiChat, ApiGroupCall, ApiPeer, ApiPhoneCall, ApiUser,
} from '../../types'; } from '../../types';
import { GROUP_CALL_PARTICIPANTS_LIMIT } from '../../../limits'; import { GROUP_CALL_PARTICIPANTS_LIMIT } from '../../../limits';
@ -18,6 +18,14 @@ import {
import { sendApiUpdate } from '../updates/apiUpdateEmitter'; import { sendApiUpdate } from '../updates/apiUpdateEmitter';
import { invokeRequest, invokeRequestBeacon } from './client'; import { invokeRequest, invokeRequestBeacon } from './client';
const MAX_SIGNED_INT64 = (1n << 63n) - 1n;
const UINT64_MOD = 1n << 64n;
function buildSignedLong(value: string) {
const parsed = BigInt(value);
return parsed > MAX_SIGNED_INT64 ? parsed - UINT64_MOD : parsed;
}
export async function getGroupCall({ export async function getGroupCall({
call, call,
}: { }: {
@ -127,13 +135,13 @@ export async function fetchGroupCallParticipants({
} }
export function leaveGroupCall({ export function leaveGroupCall({
call, isPageUnload, call, isPageUnload, source,
}: { }: {
call: ApiGroupCall; isPageUnload?: boolean; call: ApiGroupCall; isPageUnload?: boolean; source?: number;
}) { }) {
const request = new GramJs.phone.LeaveGroupCall({ const request = new GramJs.phone.LeaveGroupCall({
call: buildInputGroupCall(call), call: buildInputGroupCall(call),
source: DEFAULT_PRIMITIVES.INT, source: source ?? DEFAULT_PRIMITIVES.INT,
}); });
if (isPageUnload) { if (isPageUnload) {
@ -141,19 +149,19 @@ export function leaveGroupCall({
return; return;
} }
invokeRequest(request, { return invokeRequest(request, {
shouldReturnTrue: true, shouldReturnTrue: true,
}); });
} }
export async function joinGroupCall({ export async function joinGroupCall({
call, inviteHash, params, call, inviteHash, params, joinAs,
}: { }: {
call: ApiGroupCall; inviteHash?: string; params: JoinGroupCallPayload; call: ApiGroupCall; inviteHash?: string; params: JoinGroupCallPayload; joinAs?: ApiPeer;
}) { }) {
const result = await invokeRequest(new GramJs.phone.JoinGroupCall({ const result = await invokeRequest(new GramJs.phone.JoinGroupCall({
call: buildInputGroupCall(call), call: buildInputGroupCall(call),
joinAs: new GramJs.InputPeerSelf(), joinAs: joinAs ? buildInputPeer(joinAs.id, joinAs.accessHash) : new GramJs.InputPeerSelf(),
muted: true, muted: true,
videoStopped: true, videoStopped: true,
params: new GramJs.DataJSON({ params: new GramJs.DataJSON({
@ -287,7 +295,7 @@ export async function requestCall({
randomId: generateRandomInt32(), randomId: generateRandomInt32(),
userId: buildInputUser(user.id, user.accessHash), userId: buildInputUser(user.id, user.accessHash),
gAHash: Buffer.from(gAHash), gAHash: Buffer.from(gAHash),
...(isVideo && { video: true }), video: isVideo ? true : undefined,
protocol: buildCallProtocol(), protocol: buildCallProtocol(),
})); }));
@ -362,7 +370,7 @@ export async function confirmCall({
const result = await invokeRequest(new GramJs.phone.ConfirmCall({ const result = await invokeRequest(new GramJs.phone.ConfirmCall({
peer: buildInputPhoneCall(call), peer: buildInputPhoneCall(call),
gA: Buffer.from(gA), gA: Buffer.from(gA),
keyFingerprint: BigInt(keyFingerprint), keyFingerprint: buildSignedLong(keyFingerprint),
protocol: buildCallProtocol(), protocol: buildCallProtocol(),
})); }));
@ -390,3 +398,18 @@ export function sendSignalingData({
peer: buildInputPhoneCall(call), peer: buildInputPhoneCall(call),
})); }));
} }
export async function fetchCallConfig() {
const result = await invokeRequest(new GramJs.phone.GetCallConfig());
if (!result) {
return undefined;
}
try {
const parsed = JSON.parse(result.data);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown> : undefined;
} catch {
return undefined;
}
}

View File

@ -1,9 +1,10 @@
import { AuthKey } from '../../../lib/gramjs/crypto/AuthKey'; import { gunzipSync, gzipSync } from 'fflate';
import { Logger } from '../../../lib/gramjs/extensions'; import { CTR } from '../../../lib/gramjs/crypto/CTR';
import { import {
convertToLittle, getByteArray, modExp, readBigIntFromBuffer, sha1, sha256, getByteArray, modExp, readBigIntFromBuffer, readBufferFromBigInt, sha1, sha256,
} from '../../../lib/gramjs/Helpers'; } from '../../../lib/gramjs/Helpers';
import MTProtoState from '../../../lib/gramjs/network/MTProtoState';
import { isSctpPacket, SctpSignaling } from './sctpSignaling';
type DhConfig = { type DhConfig = {
p: number[]; p: number[];
@ -14,10 +15,16 @@ type DhConfig = {
let currentPhoneCallState: PhoneCallState | undefined; let currentPhoneCallState: PhoneCallState | undefined;
class PhoneCallState { class PhoneCallState {
private state?: MTProtoState; private authKey?: Buffer;
private sctp = new SctpSignaling();
private seq = 0; private seq = 0;
private maxInboundSeq = 0;
private inboundSeqs = new Set<number>();
private gA?: bigint; private gA?: bigint;
private gB?: bigint; private gB?: bigint;
@ -30,14 +37,27 @@ class PhoneCallState {
private resolveState?: VoidFunction; private resolveState?: VoidFunction;
private isDestroyed = false;
constructor( constructor(
private isOutgoing: boolean, private isOutgoing: boolean,
private shouldUseSctp = true,
) { ) {
this.waitForState = new Promise<void>((resolve) => { this.waitForState = new Promise<void>((resolve) => {
this.resolveState = resolve; this.resolveState = resolve;
}); });
} }
destroy() {
this.isDestroyed = true;
this.resolveState?.();
this.resolveState = undefined;
}
setShouldUseSctp(shouldUseSctp: boolean) {
this.shouldUseSctp = shouldUseSctp;
}
async requestCall({ p, g, random }: DhConfig) { async requestCall({ p, g, random }: DhConfig) {
const pBN = readBigIntFromBuffer(Buffer.from(p), false); const pBN = readBigIntFromBuffer(Buffer.from(p), false);
const randomBN = readBigIntFromBuffer(Buffer.from(random), false); const randomBN = readBigIntFromBuffer(Buffer.from(random), false);
@ -80,7 +100,7 @@ class PhoneCallState {
this.p, this.p,
); );
const fingerprint: Buffer = await sha1(getByteArray(authKey)); const fingerprint: Buffer = await sha1(getByteArray(authKey));
const keyFingerprint = readBigIntFromBuffer(fingerprint.slice(-8).reverse(), false); const keyFingerprint = readBigIntFromBuffer(fingerprint.slice(-8), true, true);
const emojis = await generateEmojiFingerprint( const emojis = await generateEmojiFingerprint(
getByteArray(authKey), getByteArray(authKey),
@ -89,35 +109,142 @@ class PhoneCallState {
emojiOffsets, emojiOffsets,
); );
const key = new AuthKey(); this.authKey = readBufferFromBigInt(authKey, 256, false);
await key.setKey(getByteArray(authKey)); this.resolveState?.();
this.state = new MTProtoState(key, new Logger(), true, this.isOutgoing); this.resolveState = undefined;
this.resolveState!();
return { gA: Array.from(getByteArray(this.gA!)), keyFingerprint: keyFingerprint.toString(), emojis }; return { gA: Array.from(getByteArray(this.gA!)), keyFingerprint: keyFingerprint.toString(), emojis };
} }
async encode(data: string) { private async calcKey(msgKey: Buffer, isClient: boolean) {
if (!this.state) return undefined; if (!this.authKey) {
throw new Error('Auth key unset');
}
const seqArray = new Uint32Array(1); const x = 128 + (this.isOutgoing !== isClient ? 8 : 0);
seqArray[0] = this.seq++; const [sha256a, sha256b] = await Promise.all([
const encodedData = await this.state.encryptMessageData( sha256(Buffer.concat([msgKey, this.authKey.slice(x, x + 36)])),
Buffer.concat([convertToLittle(seqArray), Buffer.from(data)]), sha256(Buffer.concat([this.authKey.slice(x + 40, x + 76), msgKey])),
); ]);
return Array.from(encodedData);
return {
key: Buffer.concat([sha256a.slice(0, 8), sha256b.slice(8, 24), sha256a.slice(24, 32)]),
iv: Buffer.concat([sha256b.slice(0, 4), sha256a.slice(8, 16), sha256b.slice(24, 28)]),
};
}
async encode(data: unknown) {
if (!this.authKey) return undefined;
const message = Buffer.from(gzipSync(Buffer.from(JSON.stringify(data))));
const packet = Buffer.alloc(4 + message.length);
packet.writeUInt32BE(++this.seq, 0);
message.copy(packet, 4);
const x = 128 + (this.isOutgoing ? 0 : 8);
const msgKeyLarge = await sha256(Buffer.concat([this.authKey.slice(88 + x, 88 + x + 32), packet]));
const msgKey = msgKeyLarge.slice(8, 24);
const { key, iv } = await this.calcKey(msgKey, true);
const encrypted = new CTR(key, iv).encrypt(packet);
const body = Buffer.concat([msgKey, encrypted]);
return this.shouldUseSctp ? this.sctp.wrapPayload(body) : Array.from(body);
} }
async decode(data: number[]): Promise<any> { async decode(data: number[]): Promise<any> {
if (!this.state) { if (this.isDestroyed) {
return this.waitForState.then(() => { return undefined;
return this.decode(data);
});
} }
const message = await this.state.decryptMessageData(Buffer.from(data)) as Buffer; if (!this.authKey) {
await this.waitForState;
if (this.isDestroyed || !this.authKey) {
return undefined;
}
return this.decode(data);
}
return JSON.parse(message.toString()); const incoming = Buffer.from(data);
const payloads = isSctpPacket(incoming) ? this.sctp.receive(incoming) : [];
const bodies = payloads.length ? payloads : [incoming];
const messages = [];
for (const body of bodies) {
const message = await this.decodeBody(body);
if (message) {
messages.push(message);
}
}
if (messages.length > 1) {
return messages;
}
return messages[0];
}
private async decodeBody(body: Buffer): Promise<any> {
if (body.length < 21) {
return undefined;
}
const authKey = this.authKey;
if (!authKey) {
return undefined;
}
const msgKey = body.slice(0, 16);
const encryptedData = body.slice(16);
const { key, iv } = await this.calcKey(msgKey, false);
const decrypted = new CTR(key, iv).decrypt(encryptedData);
const x = 128 + (this.isOutgoing ? 8 : 0);
const msgKeyLarge = await sha256(Buffer.concat([authKey.slice(88 + x, 88 + x + 32), decrypted]));
if (!msgKey.equals(msgKeyLarge.slice(8, 24))) {
return undefined;
}
if (decrypted.length < 4) {
return undefined;
}
const inboundSeq = decrypted.readUInt32BE(0);
if (!this.shouldAcceptInboundSeq(inboundSeq)) {
return undefined;
}
const message = decrypted.slice(4);
try {
const payload = message[0] === 0x1F && message[1] === 0x8B ? Buffer.from(gunzipSync(message)) : message;
this.markInboundSeq(inboundSeq);
return JSON.parse(payload.toString());
} catch {
return undefined;
}
}
private shouldAcceptInboundSeq(seq: number) {
return Boolean(seq && seq > this.maxInboundSeq - 64 && !this.inboundSeqs.has(seq));
}
private markInboundSeq(seq: number) {
this.inboundSeqs.add(seq);
if (seq > this.maxInboundSeq) {
this.maxInboundSeq = seq;
}
const minSeq = this.maxInboundSeq - 64;
this.inboundSeqs.forEach((item) => {
if (item <= minSeq) {
this.inboundSeqs.delete(item);
}
});
}
drainSignalingData() {
if (!this.shouldUseSctp) {
return [];
}
return this.sctp.drainPackets();
} }
} }
@ -150,11 +277,22 @@ async function generateEmojiFingerprint(
return result.join(''); return result.join('');
} }
export function createPhoneCallState(params: ConstructorParameters<typeof PhoneCallState>) { export function createPhoneCallState({
currentPhoneCallState = new PhoneCallState(...params); isOutgoing,
shouldUseSctp = true,
}: {
isOutgoing: boolean;
shouldUseSctp?: boolean;
}) {
currentPhoneCallState = new PhoneCallState(isOutgoing, shouldUseSctp);
}
export function setPhoneCallSctpEnabled(shouldUseSctp: boolean) {
currentPhoneCallState?.setShouldUseSctp(shouldUseSctp);
} }
export function destroyPhoneCallState() { export function destroyPhoneCallState() {
currentPhoneCallState?.destroy();
currentPhoneCallState = undefined; currentPhoneCallState = undefined;
} }
@ -179,6 +317,10 @@ export async function decodePhoneCallData(params: ParamsOf<'decode'>) {
return result; return result;
} }
export function drainPhoneCallSignalingData() {
return currentPhoneCallState?.drainSignalingData() || [];
}
export function confirmPhoneCall(params: ParamsOf<'confirmCall'>): ReturnTypeOf<'confirmCall'> { export function confirmPhoneCall(params: ParamsOf<'confirmCall'>): ReturnTypeOf<'confirmCall'> {
return currentPhoneCallState!.confirmCall(...params); return currentPhoneCallState!.confirmCall(...params);
} }

View File

@ -0,0 +1,640 @@
import { DEBUG_CALLS } from '../../../config';
type SctpChunk = {
type: number;
flags: number;
body: Buffer;
};
type SctpDataChunk = {
flags: number;
body: Buffer;
};
const SCTP_PORT = 5000;
const SCTP_RECEIVE_WINDOW = 0x500000;
const SCTP_MAX_PENDING_PEER_DATA_CHUNKS = 256;
const SCTP_MAX_PENDING_PEER_DATA_BYTES = 0x100000;
const SCTP_MAX_PENDING_PEER_TSN_GAP = 1024;
const SCTP_INIT_RETRY_DELAY = 1000;
const SCTP_MAX_INIT_RETRY_DELAY = 8000;
const SCTP_STREAM_ID = 0;
const SCTP_BINARY_PPID = 53;
const SCTP_INIT = 1;
const SCTP_INIT_ACK = 2;
const SCTP_SACK = 3;
const SCTP_HEARTBEAT = 4;
const SCTP_HEARTBEAT_ACK = 5;
const SCTP_ABORT = 6;
const SCTP_STATE_COOKIE = 7;
const SCTP_COOKIE_ECHO = 10;
const SCTP_COOKIE_ACK = 11;
const SCTP_DATA = 0;
const CRC32C_TABLE = createCrc32cTable();
export class SctpSignaling {
private localTag = generateUint32();
private localTsn = generateUint32();
private localSsn = 0;
private peerTag?: number;
private peerInitialTsn?: number;
private peerCumulativeTsn?: number;
private initSent = false;
private initSentAt = 0;
private initRetryCount = 0;
private isEstablished = false;
private cookie = Buffer.alloc(0);
private pendingPayloads: Buffer[] = [];
private pendingPackets: number[][] = [];
private pendingPeerData = new Map<number, SctpDataChunk>();
private pendingPeerDataSize = 0;
private reassembly?: Buffer[];
wrapPayload(payload: Buffer) {
if (this.isEstablished && this.peerTag !== undefined) {
return Array.from(this.createDataPacket(payload));
}
this.pendingPayloads.push(payload);
if (!this.initSent) {
return this.createInitRetryPacket();
}
if (this.shouldRetryInit()) {
logSctp('INIT retrying', {
retryCount: this.initRetryCount,
pendingPayloadCount: this.pendingPayloads.length,
});
return this.createInitRetryPacket();
}
return undefined;
}
private shouldRetryInit() {
if (!this.initSentAt || this.peerTag !== undefined) {
return false;
}
return Date.now() - this.initSentAt >= this.getInitRetryDelay();
}
private getInitRetryDelay() {
if (!this.initRetryCount) {
return SCTP_INIT_RETRY_DELAY;
}
return Math.min(SCTP_INIT_RETRY_DELAY * 2 ** (this.initRetryCount - 1), SCTP_MAX_INIT_RETRY_DELAY);
}
private createInitRetryPacket() {
this.initSent = true;
this.initSentAt = Date.now();
this.initRetryCount++;
return Array.from(this.createInitPacket());
}
drainPackets() {
const result = this.pendingPackets;
this.pendingPackets = [];
return result;
}
receive(packet: Buffer) {
const payloads: Buffer[] = [];
if (packet.length < 12) {
logSctp('packet dropped: too short', {
length: packet.length,
});
return payloads;
}
if (!hasValidSctpChecksum(packet)) {
logSctp('packet dropped: invalid CRC32C', {
length: packet.length,
sourcePort: packet.readUInt16BE(0),
destinationPort: packet.readUInt16BE(2),
verificationTag: packet.readUInt32BE(4),
});
return payloads;
}
const chunks = parseSctpChunks(packet);
chunks.forEach((chunk) => {
if (!this.validateVerificationTag(packet, chunk.type)) {
logSctp('chunk dropped: invalid verification tag', {
chunkType: chunk.type,
verificationTag: packet.readUInt32BE(4),
expectedVerificationTag: chunk.type === SCTP_INIT ? 0 : this.localTag,
});
return;
}
if (chunk.type === SCTP_INIT) {
this.handleInit(chunk.body);
} else if (chunk.type === SCTP_INIT_ACK) {
this.handleInitAck(chunk.body);
} else if (chunk.type === SCTP_COOKIE_ECHO) {
if (!this.validateCookieEcho(chunk.body)) {
logSctp('COOKIE_ECHO ignored: invalid cookie', {
cookieLength: chunk.body.length,
expectedCookieLength: this.cookie.length,
});
return;
}
this.pendingPackets.push(Array.from(this.createPacket(SCTP_COOKIE_ACK, 0, Buffer.alloc(0))));
this.markEstablished();
} else if (chunk.type === SCTP_COOKIE_ACK) {
this.markEstablished();
} else if (chunk.type === SCTP_DATA) {
payloads.push(...this.handleData(chunk.flags, chunk.body));
} else if (chunk.type === SCTP_SACK) {
this.handleSack(chunk.body);
} else if (chunk.type === SCTP_HEARTBEAT) {
this.pendingPackets.push(Array.from(this.createPacket(SCTP_HEARTBEAT_ACK, 0, chunk.body)));
} else if (chunk.type === SCTP_HEARTBEAT_ACK) {
// Nothing to do; accepting the chunk prevents Firefox/native peers from being logged as unsupported.
} else if (chunk.type === SCTP_ABORT) {
logSctp('ABORT received; resetting association', {
bodyLength: chunk.body.length,
});
this.resetAssociation();
} else {
logSctp('chunk ignored: unsupported type', {
chunkType: chunk.type,
flags: chunk.flags,
bodyLength: chunk.body.length,
});
}
});
return payloads;
}
private validateCookieEcho(cookie: Buffer) {
return Boolean(this.cookie.length && cookie.length === this.cookie.length && cookie.equals(this.cookie));
}
private validateVerificationTag(packet: Buffer, chunkType: number) {
const verificationTag = packet.readUInt32BE(4);
if (chunkType === SCTP_INIT) {
return verificationTag === 0;
}
return verificationTag === this.localTag;
}
private handleInit(body: Buffer) {
if (body.length < 16) {
logSctp('INIT ignored: body too short', {
bodyLength: body.length,
});
return;
}
this.peerTag = body.readUInt32BE(0);
this.peerInitialTsn = body.readUInt32BE(12);
this.peerCumulativeTsn = (this.peerInitialTsn - 1) >>> 0;
this.initSent = true;
this.cookie = this.createCookie();
this.pendingPackets.push(Array.from(this.createInitAckPacket()));
}
private handleInitAck(body: Buffer) {
if (body.length < 16) {
logSctp('INIT_ACK ignored: body too short', {
bodyLength: body.length,
});
return;
}
this.peerTag = body.readUInt32BE(0);
this.peerInitialTsn = body.readUInt32BE(12);
this.peerCumulativeTsn = (this.peerInitialTsn - 1) >>> 0;
const cookie = findSctpParameter(body.slice(16), SCTP_STATE_COOKIE);
if (cookie) {
this.pendingPackets.push(Array.from(this.createPacket(SCTP_COOKIE_ECHO, 0, cookie)));
} else {
logSctp('INIT_ACK ignored: missing state cookie', {
bodyLength: body.length,
});
}
}
private handleData(flags: number, body: Buffer) {
if (body.length < 12) {
logSctp('DATA ignored: body too short', {
bodyLength: body.length,
});
return [];
}
const tsn = body.readUInt32BE(0);
const streamId = body.readUInt16BE(4);
const ppid = body.readUInt32BE(8);
if (streamId !== SCTP_STREAM_ID || ppid !== SCTP_BINARY_PPID) {
logSctp('DATA ignored: unsupported stream or PPID', {
streamId,
ppid,
expectedStreamId: SCTP_STREAM_ID,
expectedPpid: SCTP_BINARY_PPID,
});
return [];
}
const expectedTsn = this.getNextPeerTsn();
if (expectedTsn !== undefined && tsn !== expectedTsn) {
if (isTsnAfter(tsn, expectedTsn)) {
this.bufferPendingPeerData(tsn, flags, body, expectedTsn);
} else {
logSctp('DATA ignored: duplicate TSN', {
tsn,
peerCumulativeTsn: this.peerCumulativeTsn,
expectedTsn,
});
}
this.pendingPackets.push(Array.from(this.createSackPacket()));
return [];
}
return this.acceptData(tsn, flags, body);
}
private acceptData(tsn: number, flags: number, body: Buffer) {
const payloads: Buffer[] = [];
let currentTsn = tsn;
let currentFlags = flags;
let currentBody = body;
while (true) {
const payload = this.readDataPayload(currentTsn, currentFlags, currentBody);
if (payload) {
payloads.push(payload);
}
const nextTsn = this.getNextPeerTsn();
const nextChunk = nextTsn === undefined ? undefined : this.pendingPeerData.get(nextTsn);
if (nextTsn === undefined || !nextChunk) {
break;
}
this.deletePendingPeerData(nextTsn);
currentTsn = nextTsn;
currentFlags = nextChunk.flags;
currentBody = nextChunk.body;
}
this.pendingPackets.push(Array.from(this.createSackPacket()));
return payloads;
}
private readDataPayload(tsn: number, flags: number, body: Buffer) {
this.markEstablished();
this.peerCumulativeTsn = tsn;
const userData = body.slice(12);
const isBegin = Boolean(flags & 0x02);
const isEnd = Boolean(flags & 0x01);
if (isBegin && isEnd) {
return userData;
}
if (isBegin) {
this.reassembly = [userData];
return undefined;
}
if (!this.reassembly) {
logSctp('DATA ignored: missing reassembly start', {
flags,
tsn,
});
return undefined;
}
this.reassembly.push(userData);
if (!isEnd) {
return undefined;
}
const result = Buffer.concat(this.reassembly);
this.reassembly = undefined;
return result;
}
private handleSack(body: Buffer) {
if (body.length < 12) {
logSctp('SACK ignored: body too short', {
bodyLength: body.length,
});
return;
}
this.markEstablished();
}
private getNextPeerTsn() {
if (this.peerCumulativeTsn === undefined) {
return undefined;
}
return (this.peerCumulativeTsn + 1) >>> 0;
}
private bufferPendingPeerData(tsn: number, flags: number, body: Buffer, expectedTsn: number) {
if (this.pendingPeerData.has(tsn)) {
logSctp('DATA ignored: already buffered TSN', {
tsn,
expectedTsn,
pendingCount: this.pendingPeerData.size,
pendingBytes: this.pendingPeerDataSize,
});
return;
}
const tsnGap = getForwardTsnGap(tsn, expectedTsn);
if (
tsnGap > SCTP_MAX_PENDING_PEER_TSN_GAP
|| this.pendingPeerData.size >= SCTP_MAX_PENDING_PEER_DATA_CHUNKS
|| this.pendingPeerDataSize + body.length > SCTP_MAX_PENDING_PEER_DATA_BYTES
) {
logSctp('DATA ignored: TSN gap buffer full', {
tsn,
peerCumulativeTsn: this.peerCumulativeTsn,
expectedTsn,
tsnGap,
pendingCount: this.pendingPeerData.size,
pendingBytes: this.pendingPeerDataSize,
});
return;
}
this.pendingPeerData.set(tsn, { flags, body });
this.pendingPeerDataSize += body.length;
logSctp('DATA buffered: TSN gap', {
tsn,
peerCumulativeTsn: this.peerCumulativeTsn,
expectedTsn,
tsnGap,
pendingCount: this.pendingPeerData.size,
pendingBytes: this.pendingPeerDataSize,
});
}
private deletePendingPeerData(tsn: number) {
const chunk = this.pendingPeerData.get(tsn);
if (!chunk) {
return;
}
this.pendingPeerDataSize -= chunk.body.length;
this.pendingPeerData.delete(tsn);
}
private clearPendingPeerData() {
this.pendingPeerData.clear();
this.pendingPeerDataSize = 0;
}
private flushPendingPayloads() {
if (this.peerTag === undefined) {
return;
}
const payloads = this.pendingPayloads;
this.pendingPayloads = [];
payloads.forEach((payload) => {
this.pendingPackets.push(Array.from(this.createDataPacket(payload)));
});
}
private markEstablished() {
if (this.isEstablished) {
return;
}
this.isEstablished = true;
this.flushPendingPayloads();
}
private resetAssociation() {
this.localTag = generateUint32();
this.localTsn = generateUint32();
this.localSsn = 0;
this.peerTag = undefined;
this.peerInitialTsn = undefined;
this.peerCumulativeTsn = undefined;
this.initSent = false;
this.initSentAt = 0;
this.initRetryCount = 0;
this.isEstablished = false;
this.cookie = Buffer.alloc(0);
this.pendingPayloads = [];
this.pendingPackets = [];
this.clearPendingPeerData();
this.reassembly = undefined;
}
private createInitPacket() {
const body = Buffer.alloc(16);
body.writeUInt32BE(this.localTag, 0);
body.writeUInt32BE(SCTP_RECEIVE_WINDOW, 4);
body.writeUInt16BE(0xFFFF, 8);
body.writeUInt16BE(0xFFFF, 10);
body.writeUInt32BE(this.localTsn, 12);
return this.createPacket(SCTP_INIT, 0, body, 0);
}
private createInitAckPacket() {
const body = Buffer.alloc(16);
body.writeUInt32BE(this.localTag, 0);
body.writeUInt32BE(SCTP_RECEIVE_WINDOW, 4);
body.writeUInt16BE(0xFFFF, 8);
body.writeUInt16BE(0xFFFF, 10);
body.writeUInt32BE(this.localTsn, 12);
return this.createPacket(SCTP_INIT_ACK, 0, Buffer.concat([
body,
createSctpParameter(SCTP_STATE_COOKIE, this.cookie),
]));
}
private createDataPacket(payload: Buffer) {
const body = Buffer.alloc(12 + payload.length);
body.writeUInt32BE(this.localTsn, 0);
this.localTsn = (this.localTsn + 1) >>> 0;
body.writeUInt16BE(SCTP_STREAM_ID, 4);
body.writeUInt16BE(this.localSsn, 6);
this.localSsn = (this.localSsn + 1) & 0xFFFF;
body.writeUInt32BE(SCTP_BINARY_PPID, 8);
payload.copy(body, 12);
return this.createPacket(SCTP_DATA, 0x03, body);
}
private createSackPacket() {
const body = Buffer.alloc(12);
body.writeUInt32BE(this.peerCumulativeTsn || 0, 0);
body.writeUInt32BE(SCTP_RECEIVE_WINDOW, 4);
body.writeUInt16BE(0, 8);
body.writeUInt16BE(0, 10);
return this.createPacket(SCTP_SACK, 0, body);
}
private createPacket(type: number, flags: number, body: Buffer, verificationTag = this.peerTag || 0) {
const chunk = createSctpChunk(type, flags, body);
const packet = Buffer.alloc(12 + chunk.length);
packet.writeUInt16BE(SCTP_PORT, 0);
packet.writeUInt16BE(SCTP_PORT, 2);
packet.writeUInt32BE(verificationTag, 4);
chunk.copy(packet, 12);
packet.writeUInt32LE(crc32c(packet), 8);
return packet;
}
private createCookie() {
const cookie = Buffer.alloc(16);
cookie.writeUInt32BE(this.localTag, 0);
cookie.writeUInt32BE(this.peerTag || 0, 4);
cookie.writeUInt32BE(this.localTsn, 8);
cookie.writeUInt32BE(this.peerInitialTsn || 0, 12);
return cookie;
}
}
function createSctpChunk(type: number, flags: number, body: Buffer) {
const length = 4 + body.length;
const paddedLength = align4(length);
const chunk = Buffer.alloc(paddedLength);
chunk[0] = type;
chunk[1] = flags;
chunk.writeUInt16BE(length, 2);
body.copy(chunk, 4);
return chunk;
}
function createSctpParameter(type: number, value: Buffer) {
const length = 4 + value.length;
const parameter = Buffer.alloc(align4(length));
parameter.writeUInt16BE(type, 0);
parameter.writeUInt16BE(length, 2);
value.copy(parameter, 4);
return parameter;
}
function findSctpParameter(parameters: Buffer, type: number) {
let offset = 0;
while (offset + 4 <= parameters.length) {
const parameterType = parameters.readUInt16BE(offset);
const length = parameters.readUInt16BE(offset + 2);
if (length < 4 || offset + length > parameters.length) {
return undefined;
}
if (parameterType === type) {
return parameters.slice(offset + 4, offset + length);
}
offset += align4(length);
}
return undefined;
}
function parseSctpChunks(packet: Buffer) {
const chunks: SctpChunk[] = [];
let offset = 12;
while (offset + 4 <= packet.length) {
const type = packet[offset];
const flags = packet[offset + 1];
const length = packet.readUInt16BE(offset + 2);
if (length < 4 || offset + length > packet.length) {
break;
}
chunks.push({
type,
flags,
body: packet.slice(offset + 4, offset + length),
});
offset += align4(length);
}
return chunks;
}
export function isSctpPacket(packet: Buffer) {
return packet.length >= 12
&& packet.readUInt16BE(0) === SCTP_PORT
&& packet.readUInt16BE(2) === SCTP_PORT;
}
function hasValidSctpChecksum(packet: Buffer) {
return isSctpPacket(packet) && packet.readUInt32LE(8) === crc32c(packet);
}
function align4(value: number) {
return (value + 3) & ~3;
}
function generateUint32() {
const values = new Uint32Array(1);
crypto.getRandomValues(values);
return values[0] >>> 0;
}
function isTsnAfter(tsn: number, expectedTsn: number) {
return ((tsn - expectedTsn) >>> 0) < 0x80000000;
}
function getForwardTsnGap(tsn: number, expectedTsn: number) {
return (tsn - expectedTsn) >>> 0;
}
function createCrc32cTable() {
const table: number[] = [];
for (let i = 0; i < 256; i++) {
let value = i;
for (let bit = 0; bit < 8; bit++) {
value = value & 1 ? (0x82F63B78 ^ (value >>> 1)) : value >>> 1;
}
table[i] = value >>> 0;
}
return table;
}
function crc32c(data: Buffer) {
const buffer = Buffer.from(data);
buffer.writeUInt32LE(0, 8);
let crc = 0xFFFFFFFF;
for (let i = 0; i < buffer.length; i++) {
crc = CRC32C_TABLE[(crc ^ buffer[i]) & 0xFF] ^ (crc >>> 8);
}
return (~crc) >>> 0;
}
function logSctp(message: string, data: Record<string, unknown> = {}) {
if (!DEBUG_CALLS) {
return;
}
// eslint-disable-next-line no-console
console.debug(`[PhoneCall][SCTP] ${message}`, data);
}

View File

@ -1,7 +1,7 @@
import { Api as GramJs, type Update } from '../../../lib/gramjs'; import { Api as GramJs, type Update } from '../../../lib/gramjs';
import { UpdateConnectionState, UpdateServerTimeOffset } from '../../../lib/gramjs/network'; import { UpdateConnectionState, UpdateServerTimeOffset } from '../../../lib/gramjs/network';
import type { GroupCallConnectionData } from '../../../lib/secret-sauce'; import type { GroupCallConnectionData } from '../../../lib/vibecalls';
import { import {
type ApiMessage, type ApiMessage,
type ApiMessagePoll, type ApiMessagePoll,

View File

@ -4,7 +4,10 @@ import type {
GroupCallParticipant, GroupCallParticipant,
VideoRotation, VideoRotation,
VideoState, VideoState,
} from '../../lib/secret-sauce'; } from '../../lib/vibecalls';
import type { PrimitiveRecord } from '../../util/primitives/primitiveRecord';
export type ApiPhoneCallCustomParameters = PrimitiveRecord;
export interface ApiGroupCall { export interface ApiGroupCall {
chatId?: string; chatId?: string;
@ -26,6 +29,8 @@ export interface ApiGroupCall {
inviteHash?: string; inviteHash?: string;
nextOffset?: string; nextOffset?: string;
localSource?: number;
localJoinAsId?: string;
participants: Record<string, GroupCallParticipant>; participants: Record<string, GroupCallParticipant>;
connectionState: GroupCallConnectionState; connectionState: GroupCallConnectionState;
isSpeakerDisabled?: boolean; isSpeakerDisabled?: boolean;
@ -50,6 +55,7 @@ export interface ApiPhoneCall {
needDebug?: boolean; needDebug?: boolean;
reason?: 'missed' | 'disconnect' | 'hangup' | 'busy'; reason?: 'missed' | 'disconnect' | 'hangup' | 'busy';
duration?: number; duration?: number;
customParameters?: ApiPhoneCallCustomParameters;
emojis?: string; emojis?: string;
gA?: number[]; gA?: number[];

View File

@ -5,7 +5,7 @@ import type {
GroupCallParticipant, GroupCallParticipant,
VideoRotation, VideoRotation,
VideoState, VideoState,
} from '../../lib/secret-sauce'; } from '../../lib/vibecalls';
import type { ThreadId, ThreadReadState, TranslationTone } from '../../types'; import type { ThreadId, ThreadReadState, TranslationTone } from '../../types';
import type { RegularLangFnParameters } from '../../util/localization'; import type { RegularLangFnParameters } from '../../util/localization';
import type { ApiBotCommand, ApiBotMenuButton } from './bots'; import type { ApiBotCommand, ApiBotMenuButton } from './bots';

View File

@ -8,10 +8,10 @@ import { getActions, withGlobal } from '../../../global';
import type { import type {
GroupCallConnectionState, GroupCallParticipant as TypeGroupCallParticipant, GroupCallConnectionState, GroupCallParticipant as TypeGroupCallParticipant,
} from '../../../lib/secret-sauce'; } from '../../../lib/vibecalls';
import type { VideoParticipant } from './hooks/useGroupCallVideoLayout'; import type { VideoParticipant } from './hooks/useGroupCallVideoLayout';
import { IS_SCREENSHARE_SUPPORTED } from '../../../lib/secret-sauce'; import { IS_SCREENSHARE_SUPPORTED } from '../../../lib/vibecalls';
import { selectChat, selectTabState } from '../../../global/selectors'; import { selectChat, selectTabState } from '../../../global/selectors';
import { import {
selectCanInviteToActiveGroupCall, selectCanInviteToActiveGroupCall,

View File

@ -5,10 +5,10 @@ import {
import { withGlobal } from '../../../global'; import { withGlobal } from '../../../global';
import type { ApiPeer } from '../../../api/types'; import type { ApiPeer } from '../../../api/types';
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce'; import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/vibecalls';
import { GROUP_CALL_DEFAULT_VOLUME } from '../../../config'; import { GROUP_CALL_DEFAULT_VOLUME } from '../../../config';
import { THRESHOLD } from '../../../lib/secret-sauce'; import { THRESHOLD } from '../../../lib/vibecalls';
import { selectChat, selectUser } from '../../../global/selectors'; import { selectChat, selectUser } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';

View File

@ -2,7 +2,7 @@ import type { FC } from '../../../lib/teact/teact';
import { memo, useMemo } from '../../../lib/teact/teact'; import { memo, useMemo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce'; import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/vibecalls';
import { selectActiveGroupCall } from '../../../global/selectors/calls'; import { selectActiveGroupCall } from '../../../global/selectors/calls';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';

View File

@ -3,7 +3,7 @@ import type React from '../../../lib/teact/teact';
import { memo, useEffect, useState } from '../../../lib/teact/teact'; import { memo, useEffect, useState } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { GroupCallParticipant } from '../../../lib/secret-sauce'; import type { GroupCallParticipant } from '../../../lib/vibecalls';
import type { MenuPositionOptions } from '../../ui/Menu'; import type { MenuPositionOptions } from '../../ui/Menu';
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config'; import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';

View File

@ -5,13 +5,13 @@ import {
import { withGlobal } from '../../../global'; import { withGlobal } from '../../../global';
import type { ApiChat, ApiUser } from '../../../api/types'; import type { ApiChat, ApiUser } from '../../../api/types';
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce'; import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/vibecalls';
import type { VideoLayout, VideoParticipant } from './hooks/useGroupCallVideoLayout'; import type { VideoLayout, VideoParticipant } from './hooks/useGroupCallVideoLayout';
import { GROUP_CALL_DEFAULT_VOLUME } from '../../../config'; import { GROUP_CALL_DEFAULT_VOLUME } from '../../../config';
import fastBlur from '../../../lib/fastBlur'; import fastBlur from '../../../lib/fastBlur';
import { requestMutation } from '../../../lib/fasterdom/fasterdom'; import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { getUserStreams, THRESHOLD } from '../../../lib/secret-sauce'; import { getUserStreams, THRESHOLD } from '../../../lib/vibecalls';
import { selectChat, selectUser } from '../../../global/selectors'; import { selectChat, selectUser } from '../../../global/selectors';
import { animate } from '../../../util/animation'; import { animate } from '../../../util/animation';
import { IS_CANVAS_FILTER_SUPPORTED } from '../../../util/browser/windowEnvironment'; import { IS_CANVAS_FILTER_SUPPORTED } from '../../../util/browser/windowEnvironment';

View File

@ -4,7 +4,7 @@ import {
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { GroupCallConnectionState } from '../../../lib/secret-sauce'; import type { GroupCallConnectionState } from '../../../lib/vibecalls';
import { selectActiveGroupCall, selectGroupCallParticipant } from '../../../global/selectors/calls'; import { selectActiveGroupCall, selectGroupCallParticipant } from '../../../global/selectors/calls';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';

View File

@ -1,9 +1,9 @@
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import { memo, useMemo } from '../../../lib/teact/teact'; import { memo, useMemo } from '../../../lib/teact/teact';
import type { GroupCallParticipant } from '../../../lib/secret-sauce'; import type { GroupCallParticipant } from '../../../lib/vibecalls';
import { THRESHOLD } from '../../../lib/secret-sauce'; import { THRESHOLD } from '../../../lib/vibecalls';
import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets'; import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated'; import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated';

View File

@ -1,4 +1,4 @@
import type { GroupCallParticipant } from '../../../../lib/secret-sauce'; import type { GroupCallParticipant } from '../../../../lib/vibecalls';
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../../config'; import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../../config';

View File

@ -48,6 +48,10 @@
} }
} }
.fullscreenDialog {
border-radius: 0;
}
.header { .header {
position: absolute; position: absolute;

View File

@ -9,7 +9,7 @@ import type { ApiPhoneCall, ApiUser } from '../../../api/types';
import { import {
getStreams, IS_SCREENSHARE_SUPPORTED, switchCameraInputP2p, toggleStreamP2p, getStreams, IS_SCREENSHARE_SUPPORTED, switchCameraInputP2p, toggleStreamP2p,
} from '../../../lib/secret-sauce'; } from '../../../lib/vibecalls';
import { selectTabState } from '../../../global/selectors'; import { selectTabState } from '../../../global/selectors';
import { selectPhoneCallUser } from '../../../global/selectors/calls'; import { selectPhoneCallUser } from '../../../global/selectors/calls';
import { import {
@ -59,42 +59,56 @@ const PhoneCall = ({
const [isFullscreen, openFullscreen, closeFullscreen] = useFlag(); const [isFullscreen, openFullscreen, closeFullscreen] = useFlag();
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
const toggleFullscreen = useCallback(() => { const isOpen = Boolean(phoneCall && phoneCall.state !== 'discarded' && !isCallPanelVisible);
if (isFullscreen) {
closeFullscreen(); const exitFullscreenIfNeeded = useCallback(() => {
if (document.fullscreenElement === containerRef.current) {
void document.exitFullscreen().catch(() => undefined).then(closeFullscreen);
} else { } else {
openFullscreen(); closeFullscreen();
} }
}, [closeFullscreen, isFullscreen, openFullscreen]); }, [closeFullscreen]);
const handleToggleFullscreen = useCallback(() => { const handleToggleFullscreen = useCallback(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
if (isFullscreen) { if (isFullscreen) {
document.exitFullscreen().then(closeFullscreen); exitFullscreenIfNeeded();
} else { } else {
containerRef.current.requestFullscreen().then(openFullscreen); void containerRef.current.requestFullscreen().then(openFullscreen).catch(() => undefined);
} }
}, [closeFullscreen, isFullscreen, openFullscreen]); }, [exitFullscreenIfNeeded, isFullscreen, openFullscreen]);
useEffect(() => { useEffect(() => {
if (!IS_REQUEST_FULLSCREEN_SUPPORTED) return undefined; if (!IS_REQUEST_FULLSCREEN_SUPPORTED) return undefined;
const container = containerRef.current;
if (!container) return undefined;
container.addEventListener('fullscreenchange', toggleFullscreen); const handleFullscreenChange = () => {
if (document.fullscreenElement === containerRef.current) {
openFullscreen();
} else {
closeFullscreen();
}
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => { return () => {
container.removeEventListener('fullscreenchange', toggleFullscreen); document.removeEventListener('fullscreenchange', handleFullscreenChange);
}; };
}, [toggleFullscreen]); }, [closeFullscreen, openFullscreen]);
useEffect(() => {
if (isOpen || !isFullscreen) return;
exitFullscreenIfNeeded();
}, [exitFullscreenIfNeeded, isFullscreen, isOpen]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
toggleGroupCallPanel(); toggleGroupCallPanel();
if (isFullscreen) { if (isFullscreen) {
closeFullscreen(); exitFullscreenIfNeeded();
} }
}, [closeFullscreen, isFullscreen, toggleGroupCallPanel]); }, [exitFullscreenIfNeeded, isFullscreen, toggleGroupCallPanel]);
const isDiscarded = phoneCall?.state === 'discarded'; const isDiscarded = phoneCall?.state === 'discarded';
const isBusy = phoneCall?.reason === 'busy'; const isBusy = phoneCall?.reason === 'busy';
@ -161,9 +175,9 @@ const PhoneCall = ({
const hasPresentation = phoneCall?.screencastState === 'active'; const hasPresentation = phoneCall?.screencastState === 'active';
const streams = getStreams(); const streams = getStreams();
const hasOwnAudio = streams?.ownAudio?.getTracks()[0].enabled; const hasOwnAudio = streams?.ownAudio?.getTracks()?.[0]?.enabled ?? false;
const hasOwnPresentation = streams?.ownPresentation?.getTracks()[0].enabled; const hasOwnPresentation = streams?.ownPresentation?.getTracks()?.[0]?.enabled ?? false;
const hasOwnVideo = streams?.ownVideo?.getTracks()[0].enabled; const hasOwnVideo = streams?.ownVideo?.getTracks()?.[0]?.enabled ?? false;
const [isHidingPresentation, startHidingPresentation, stopHidingPresentation] = useFlag(); const [isHidingPresentation, startHidingPresentation, stopHidingPresentation] = useFlag();
const [isHidingVideo, startHidingVideo, stopHidingVideo] = useFlag(); const [isHidingVideo, startHidingVideo, stopHidingVideo] = useFlag();
@ -228,12 +242,13 @@ const PhoneCall = ({
return ( return (
<Modal <Modal
isOpen={phoneCall && phoneCall?.state !== 'discarded' && !isCallPanelVisible} isOpen={isOpen}
onClose={handleClose} onClose={handleClose}
className={buildClassName( className={buildClassName(
styles.root, styles.root,
isMobile && styles.singleColumn, isMobile && styles.singleColumn,
)} )}
dialogClassName={buildClassName(isFullscreen && styles.fullscreenDialog)}
dialogRef={containerRef} dialogRef={containerRef}
> >
<Avatar <Avatar
@ -249,23 +264,23 @@ const PhoneCall = ({
className={buildClassName( className={buildClassName(
styles.secondVideo, styles.secondVideo,
!isHidingPresentation && hasOwnPresentation && styles.visible, !isHidingPresentation && hasOwnPresentation && styles.visible,
isFullscreen && styles.fullscreen, hasOwnPresentation && isFullscreen && styles.fullscreen,
)} )}
muted muted
autoPlay autoPlay
playsInline playsInline
srcObject={streams?.ownPresentation} srcObject={hasOwnPresentation ? streams?.ownPresentation : undefined}
/> />
<video <video
className={buildClassName( className={buildClassName(
styles.secondVideo, styles.secondVideo,
!isHidingVideo && hasOwnVideo && styles.visible, !isHidingVideo && hasOwnVideo && styles.visible,
isFullscreen && styles.fullscreen, hasOwnVideo && isFullscreen && styles.fullscreen,
)} )}
muted muted
autoPlay autoPlay
playsInline playsInline
srcObject={streams?.ownVideo} srcObject={hasOwnVideo ? streams?.ownVideo : undefined}
/> />
<div className={styles.header}> <div className={styles.header}>
{IS_REQUEST_FULLSCREEN_SUPPORTED && ( {IS_REQUEST_FULLSCREEN_SUPPORTED && (

View File

@ -24,6 +24,7 @@ export const PAID_MESSAGES_PURPOSE = 'paid_messages';
export const DEBUG = process.env.APP_ENV !== 'production'; export const DEBUG = process.env.APP_ENV !== 'production';
export const DEBUG_MORE = false; export const DEBUG_MORE = false;
export const DEBUG_CALLS = false;
export const DEBUG_LOG_FILENAME = 'tt-log.json'; export const DEBUG_LOG_FILENAME = 'tt-log.json';
export const STRICTERDOM_ENABLED = DEBUG; export const STRICTERDOM_ENABLED = DEBUG;
export const FORCE_FALLBACK_LANG = DEBUG; export const FORCE_FALLBACK_LANG = DEBUG;
@ -378,6 +379,8 @@ export const FRAGMENT_PHONE_CODE = '888';
export const FRAGMENT_PHONE_LENGTH = 11; export const FRAGMENT_PHONE_LENGTH = 11;
export const BOT_VERIFICATION_PEERS_LIMIT = 20; export const BOT_VERIFICATION_PEERS_LIMIT = 20;
export const CALL_PROTOCOL_LIBRARY_VERSIONS = ['13.0.0'];
export const LIGHT_THEME_BG_COLOR = '#99BA92'; export const LIGHT_THEME_BG_COLOR = '#99BA92';
export const DARK_THEME_BG_COLOR = '#000000'; export const DARK_THEME_BG_COLOR = '#000000';
export const DEFAULT_PATTERN_COLOR = '#4A8E3A8C'; export const DEFAULT_PATTERN_COLOR = '#4A8E3A8C';

View File

@ -1,6 +1,6 @@
import type { ActionReturnType } from '../../types'; import type { ActionReturnType } from '../../types';
import { GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config'; import { DEBUG_CALLS, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
import { import {
isStreamEnabled, isStreamEnabled,
joinGroupCall, joinGroupCall,
@ -8,7 +8,8 @@ import {
setVolume, startSharingScreen, setVolume, startSharingScreen,
stopPhoneCall, stopPhoneCall,
toggleStream, toggleStream,
} from '../../../lib/secret-sauce'; } from '../../../lib/vibecalls';
import { logDebugMessage } from '../../../util/debugConsole';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addActionHandler, getGlobal, setGlobal } from '../../index';
@ -17,7 +18,9 @@ import {
updateActiveGroupCall, updateActiveGroupCall,
} from '../../reducers/calls'; } from '../../reducers/calls';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import { selectChat, selectTabState, selectUser } from '../../selectors'; import {
selectChat, selectPeer, selectTabState, selectUser,
} from '../../selectors';
import { import {
selectActiveGroupCall, selectPhoneCallUser, selectActiveGroupCall, selectPhoneCallUser,
} from '../../selectors/calls'; } from '../../selectors/calls';
@ -47,8 +50,11 @@ addActionHandler('leaveGroupCall', async (global, actions, payload): Promise<voi
}; };
setGlobal(global); setGlobal(global);
const localParticipantId = groupCall.localJoinAsId ?? global.currentUserId;
const source = groupCall.localSource
?? (localParticipantId ? groupCall.participants[localParticipantId]?.source : undefined);
await callApi('leaveGroupCall', { await callApi('leaveGroupCall', {
call: groupCall, isPageUnload, call: groupCall, isPageUnload, source,
}); });
await callApi('abortRequestGroup', 'call'); await callApi('abortRequestGroup', 'call');
@ -204,18 +210,30 @@ addActionHandler('connectToActiveGroupCall', async (global, actions, payload): P
return; return;
} }
const { const { currentUserId } = global;
currentUserId,
} = global;
if (!currentUserId) return; if (!currentUserId) return;
const params = await joinGroupCall(currentUserId, audioContext, audioElement, actions.apiUpdate); const localParticipantId = groupCall.localJoinAsId ?? currentUserId;
const joinAs = groupCall.localJoinAsId ? selectPeer(global, groupCall.localJoinAsId) : undefined;
if (groupCall.localJoinAsId && !joinAs) return;
const params = await joinGroupCall(localParticipantId, audioContext, audioElement, actions.apiUpdate);
if (!params) {
actions.showNotification({
// TODO[lang] Localize error message
message: 'Failed to join voice chat',
tabId,
});
actions.leaveGroupCall({ tabId });
return;
}
const result = await callApi('joinGroupCall', { const result = await callApi('joinGroupCall', {
call: groupCall, call: groupCall,
params, params,
inviteHash: groupCall.inviteHash, inviteHash: groupCall.inviteHash,
joinAs,
}); });
global = getGlobal(); global = getGlobal();
@ -230,6 +248,11 @@ addActionHandler('connectToActiveGroupCall', async (global, actions, payload): P
return; return;
} }
if (params.ssrc !== undefined) {
global = updateActiveGroupCall(global, { localSource: params.ssrc });
setGlobal(global);
}
actions.loadMoreGroupCallParticipants(); actions.loadMoreGroupCallParticipants();
if (groupCall.chatId) { if (groupCall.chatId) {
@ -252,7 +275,10 @@ addActionHandler('connectToActivePhoneCall', async (global, actions): Promise<vo
if (!dhConfig) return; if (!dhConfig) return;
await callApi('createPhoneCallState', [true]); await callApi('createPhoneCallState', {
isOutgoing: true,
shouldUseSctp: !phoneCall.customParameters?.network_signaling_nosctp,
});
const gAHash = await callApi('requestPhoneCall', [dhConfig]); const gAHash = await callApi('requestPhoneCall', [dhConfig]);
@ -271,7 +297,10 @@ addActionHandler('acceptCall', async (global): Promise<void> => {
const dhConfig = await callApi('getDhConfig'); const dhConfig = await callApi('getDhConfig');
if (!dhConfig) return; if (!dhConfig) return;
await callApi('createPhoneCallState', [false]); await callApi('createPhoneCallState', {
isOutgoing: false,
shouldUseSctp: !phoneCall.customParameters?.network_signaling_nosctp,
});
const gB = await callApi('acceptPhoneCall', [dhConfig]); const gB = await callApi('acceptPhoneCall', [dhConfig]);
await callApi('acceptCall', { call: phoneCall, gB }); await callApi('acceptCall', { call: phoneCall, gB });
@ -283,17 +312,42 @@ addActionHandler('sendSignalingData', (global, actions, payload): ActionReturnTy
return; return;
} }
const data = JSON.stringify(payload);
(async () => { (async () => {
const encodedData = await callApi('encodePhoneCallData', [data]); try {
const encodedData = await callApi('encodePhoneCallData', [payload]);
if (!encodedData) return; if (!encodedData) {
return;
}
callApi('sendSignalingData', { data: encodedData, call: phoneCall }); await callApi('sendSignalingData', { data: encodedData, call: phoneCall });
const pendingPackets = await callApi('drainPhoneCallSignalingData');
if (!pendingPackets) return;
for (const data of pendingPackets) {
await callApi('sendSignalingData', { data, call: phoneCall });
}
} catch (error) {
logPhoneCallDebug('Failed to send phone call signaling data', {
error: summarizeError(error),
});
}
})(); })();
}); });
function logPhoneCallDebug(message: string, data: Record<string, unknown>) {
if (!DEBUG_CALLS) return;
logDebugMessage('warn', `[PhoneCall] ${message}`, data);
}
function summarizeError(error: unknown) {
return error instanceof Error ? {
name: error.name,
message: error.message,
} : String(error);
}
addActionHandler('closeCallRatingModal', (global, actions, payload): ActionReturnType => { addActionHandler('closeCallRatingModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {}; const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, { return updateTabState(global, {

View File

@ -1,13 +1,15 @@
import type { ApiPhoneCall } from '../../../api/types'; import type { ApiPhoneCall, ApiPhoneCallCustomParameters } from '../../../api/types';
import type { ApiCallProtocol } from '../../../lib/secret-sauce'; import type { ApiCallProtocol } from '../../../lib/vibecalls';
import type { ActionReturnType } from '../../types'; import type { ActionReturnType } from '../../types';
import { CALL_PROTOCOL_LIBRARY_VERSIONS, DEBUG_CALLS } from '../../../config';
import { import {
handleUpdateGroupCallConnection, handleUpdateGroupCallConnection,
handleUpdateGroupCallParticipants, handleUpdateGroupCallParticipants,
joinPhoneCall, processSignalingMessage, joinPhoneCall, processSignalingMessage, sanitizePrimitiveRecord,
} from '../../../lib/secret-sauce'; } from '../../../lib/vibecalls';
import { ARE_CALLS_SUPPORTED } from '../../../util/browser/windowEnvironment'; import { ARE_CALLS_SUPPORTED } from '../../../util/browser/windowEnvironment';
import { logDebugMessage } from '../../../util/debugConsole';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { omit } from '../../../util/iteratees'; import { omit } from '../../../util/iteratees';
import * as langProvider from '../../../util/oldLangProvider'; import * as langProvider from '../../../util/oldLangProvider';
@ -18,6 +20,25 @@ import { updateGroupCall, updateGroupCallParticipant } from '../../reducers/call
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import { selectActiveGroupCall, selectGroupCallParticipant, selectPhoneCallUser } from '../../selectors/calls'; import { selectActiveGroupCall, selectGroupCallParticipant, selectPhoneCallUser } from '../../selectors/calls';
let phoneCallSignalingDataPromise = Promise.resolve();
let groupCallNegotiationPromise = Promise.resolve();
type QueuedPhoneCallSignalingData = {
callId?: string;
data: number[];
};
function enqueueGroupCallNegotiation(callback: () => Promise<void>) {
groupCallNegotiationPromise = groupCallNegotiationPromise
.catch(() => undefined)
.then(callback)
.catch((err) => {
logPhoneCallDebug('Failed to process group call negotiation update', {
error: err instanceof Error ? err.message : String(err),
});
});
}
addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
const { activeGroupCallId } = global.groupCalls; const { activeGroupCallId } = global.groupCalls;
@ -48,7 +69,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
case 'updateGroupCallParticipants': { case 'updateGroupCallParticipants': {
const { groupCallId, participants } = update; const { groupCallId, participants } = update;
if (activeGroupCallId === groupCallId) { if (activeGroupCallId === groupCallId) {
void handleUpdateGroupCallParticipants(participants); enqueueGroupCallNegotiation(() => handleUpdateGroupCallParticipants(participants));
} }
break; break;
} }
@ -58,12 +79,15 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
if ('leaveGroupCall' in actions) actions.leaveGroupCall({ tabId: getCurrentTabId() }); if ('leaveGroupCall' in actions) actions.leaveGroupCall({ tabId: getCurrentTabId() });
break; break;
} }
void handleUpdateGroupCallConnection(update.data, update.presentation); enqueueGroupCallNegotiation(async () => {
await handleUpdateGroupCallConnection(update.data, update.presentation);
const groupCall = selectActiveGroupCall(global); global = getGlobal();
if (groupCall?.participants && Object.keys(groupCall.participants).length > 0) { const groupCall = selectActiveGroupCall(global);
void handleUpdateGroupCallParticipants(Object.values(groupCall.participants)); if (groupCall?.participants && Object.keys(groupCall.participants).length > 0) {
} await handleUpdateGroupCallParticipants(Object.values(groupCall.participants));
}
});
break; break;
} }
case 'updatePhoneCallMediaState': case 'updatePhoneCallMediaState':
@ -128,51 +152,138 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
}, getCurrentTabId()); }, getCurrentTabId());
} else if (state === 'accepted' && accessHash && gB) { } else if (state === 'accepted' && accessHash && gB) {
(async () => { (async () => {
const { gA, keyFingerprint, emojis } = await callApi('confirmPhoneCall', [gB, EMOJI_DATA, EMOJI_OFFSETS]); try {
const activeCallId = call.id;
global = getGlobal(); const result = await callApi('confirmPhoneCall', [gB, EMOJI_DATA, EMOJI_OFFSETS]);
const newCall = { if (!result) {
...global.phoneCall, logPhoneCallDebug('Failed to confirm accepted phone call', {
emojis, callId: activeCallId,
} as ApiPhoneCall; });
return;
global = { }
...global, const { gA, keyFingerprint, emojis } = result;
phoneCall: newCall,
};
setGlobal(global);
callApi('confirmCall', {
call, gA, keyFingerprint,
});
})();
} else if (state === 'active' && connections && phoneCall?.state !== 'active') {
if (!isOutgoing) {
callApi('receivedCall', { call });
(async () => {
const { emojis } = await callApi('confirmPhoneCall', [call.gAOrB!, EMOJI_DATA, EMOJI_OFFSETS]);
global = getGlobal(); global = getGlobal();
if (global.phoneCall?.id !== activeCallId) {
return;
}
await callApi('confirmCall', {
call, gA, keyFingerprint,
});
global = getGlobal();
if (global.phoneCall?.id !== activeCallId) {
return;
}
const newCall = { const newCall = {
...global.phoneCall, ...global.phoneCall,
emojis, emojis,
} as ApiPhoneCall; };
global = { global = {
...global, ...global,
phoneCall: newCall, phoneCall: newCall,
}; };
setGlobal(global); setGlobal(global);
})(); } catch (err) {
} logPhoneCallDebug('Failed to confirm accepted phone call', {
void joinPhoneCall( callId: call.id,
connections, error: err instanceof Error ? err.message : String(err),
actions.sendSignalingData, });
isOutgoing, }
Boolean(call?.isVideo), })();
Boolean(call.isP2pAllowed), } else if (state === 'active' && connections && phoneCall?.state !== 'active') {
actions.apiUpdate, (async () => {
); try {
const activeCallId = call.id;
let callConfigResult: Record<string, unknown> | undefined;
try {
callConfigResult = await callApi('fetchCallConfig');
} catch (err) {
logPhoneCallDebug('Failed to fetch phone call config', {
error: err instanceof Error ? err.message : String(err),
});
}
const callConfig = sanitizePrimitiveRecord(callConfigResult) || {};
const customParameters: ApiPhoneCallCustomParameters = Object.assign(
{},
callConfig,
call.customParameters,
);
call.customParameters = customParameters;
global = getGlobal();
if (global.phoneCall?.id === call.id) {
global = {
...global,
phoneCall: {
...global.phoneCall,
customParameters,
},
};
setGlobal(global);
}
global = getGlobal();
if (global.phoneCall?.id === call.id) {
await callApi('setPhoneCallSctpEnabled', !customParameters.network_signaling_nosctp);
}
if (!isOutgoing) {
await callApi('receivedCall', { call });
global = getGlobal();
if (global.phoneCall?.id !== activeCallId) {
return;
}
const result = await callApi('confirmPhoneCall', [call.gAOrB!, EMOJI_DATA, EMOJI_OFFSETS]);
if (!result) {
logPhoneCallDebug('Failed to confirm phone call', {
callId: activeCallId,
});
return;
}
const { emojis } = result;
global = getGlobal();
if (global.phoneCall?.id !== activeCallId) {
return;
}
const newCall = {
...global.phoneCall,
emojis,
};
global = {
...global,
phoneCall: newCall,
};
setGlobal(global);
}
global = getGlobal();
if (global.phoneCall?.id !== activeCallId) {
return;
}
await joinPhoneCall(
connections,
actions.sendSignalingData,
isOutgoing,
Boolean(call?.isVideo),
Boolean(call.isP2pAllowed),
actions.apiUpdate,
);
} catch (err) {
logPhoneCallDebug('Failed to start phone call', {
error: err instanceof Error ? err.message : String(err),
callId: call.id,
});
}
})();
} }
return global; return global;
@ -202,7 +313,20 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
break; break;
} }
callApi('decodePhoneCallData', [update.data])?.then(processSignalingMessage); const queued: QueuedPhoneCallSignalingData = {
callId: phoneCall.id,
data: update.data,
};
phoneCallSignalingDataPromise = phoneCallSignalingDataPromise
.then(() => processPhoneCallSignalingData(queued))
.catch((err) => {
logPhoneCallDebug('Failed to process phone call signaling data', {
error: err instanceof Error ? err.message : String(err),
isSctp: isSctpSignalingData(queued.data),
length: queued.data.length,
});
});
break; break;
} }
} }
@ -211,7 +335,79 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
}); });
function verifyPhoneCallProtocol(protocol?: ApiCallProtocol) { function verifyPhoneCallProtocol(protocol?: ApiCallProtocol) {
return protocol?.libraryVersions.some((version) => { return Boolean(
return version === '4.0.0' || version === '4.0.1'; protocol
}); && CALL_PROTOCOL_LIBRARY_VERSIONS.some((version) => protocol.libraryVersions.includes(version)),
);
}
async function processPhoneCallSignalingData(queued: QueuedPhoneCallSignalingData) {
const { data } = queued;
let global = getGlobal();
if (global.phoneCall?.id !== queued.callId) {
return;
}
let message;
try {
message = await callApi('decodePhoneCallData', [data]);
} catch (err) {
logPhoneCallDebug('Failed to decode phone call signaling data', {
error: err instanceof Error ? err.message : String(err),
isSctp: isSctpSignalingData(data),
length: data.length,
});
return;
}
global = getGlobal();
const activeCall = global.phoneCall;
if (activeCall?.id !== queued.callId) {
return;
}
let packetCount = 0;
if (activeCall) {
try {
const packets = await callApi('drainPhoneCallSignalingData');
packetCount = packets?.length || 0;
if (packets) {
for (const packetData of packets) {
await callApi('sendSignalingData', { data: packetData, call: activeCall });
}
}
} catch (err) {
logPhoneCallDebug('Failed to drain phone call signaling data', {
error: err instanceof Error ? err.message : String(err),
isSctp: isSctpSignalingData(data),
length: data.length,
});
}
}
if (Array.isArray(message)) {
for (const item of message) {
await processSignalingMessage(item);
}
} else if (message) {
await processSignalingMessage(message);
} else if (!packetCount && !isSctpSignalingData(data)) {
logPhoneCallDebug('Failed to decode phone call signaling data', {
length: data.length,
});
}
}
function logPhoneCallDebug(message: string, data: Record<string, unknown>) {
if (!DEBUG_CALLS) return;
logDebugMessage('warn', `[PhoneCall] ${message}`, data);
}
function isSctpSignalingData(data: number[]) {
return data.length >= 12
&& data[0] === 0x13
&& data[1] === 0x88
&& data[2] === 0x13
&& data[3] === 0x88;
} }

View File

@ -54,7 +54,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
const { groupCallId, participants, nextOffset } = update; const { groupCallId, participants, nextOffset } = update;
const { currentUserId } = global; const { currentUserId } = global;
// `secret-sauce` should disconnect if the participant is us but from another device // `vibecalls` should disconnect if the participant is us but from another device
global = getGlobal(); global = getGlobal();
participants.forEach((participant) => { participants.forEach((participant) => {
if (participant.id) { if (participant.id) {

View File

@ -301,6 +301,7 @@ addActionHandler('joinGroupCall', async (global, actions, payload): Promise<void
{ {
...groupCall, ...groupCall,
inviteHash, inviteHash,
localJoinAsId: undefined,
}, },
undefined, undefined,
groupCall.participantsCount + 1, groupCall.participantsCount + 1,

View File

@ -1,5 +1,5 @@
import type { ApiGroupCall } from '../../api/types'; import type { ApiGroupCall } from '../../api/types';
import type { GroupCallParticipant } from '../../lib/secret-sauce'; import type { GroupCallParticipant } from '../../lib/vibecalls';
import type { GlobalState } from '../types'; import type { GlobalState } from '../types';
import { omit } from '../../util/iteratees'; import { omit } from '../../util/iteratees';

View File

@ -70,7 +70,7 @@ import type {
import type { ApiCredentials } from '../../components/payment/PaymentModal'; import type { ApiCredentials } from '../../components/payment/PaymentModal';
import type { FoldersActions } from '../../hooks/reducers/useFoldersReducer'; import type { FoldersActions } from '../../hooks/reducers/useFoldersReducer';
import type { ReducerAction } from '../../hooks/useReducer'; import type { ReducerAction } from '../../hooks/useReducer';
import type { P2pMessage } from '../../lib/secret-sauce'; import type { P2pMessage } from '../../lib/vibecalls';
import type { import type {
AccountSettings, AccountSettings,
AttachmentCompression, AttachmentCompression,

View File

@ -436,9 +436,11 @@ export default class MTProtoSender {
const encryptedData = await this._state.encryptMessageData(data); const encryptedData = await this._state.encryptMessageData(data);
postMessage({ postMessage({
type: 'sendBeacon', payloads: [{
data: encryptedData, type: 'sendBeacon',
url: this._fallbackConnection.href, data: encryptedData,
url: this._fallbackConnection.href,
}],
}); });
} }

View File

@ -1933,6 +1933,7 @@ payments.resolveStarGiftOffer#e9ce781c flags:# decline:flags.0?true offer_msg_id
payments.getStarGiftUpgradeAttributes#6d038b58 gift_id:long = payments.StarGiftUpgradeAttributes; payments.getStarGiftUpgradeAttributes#6d038b58 gift_id:long = payments.StarGiftUpgradeAttributes;
payments.getCraftStarGifts#fd05dd00 gift_id:long offset:string limit:int = payments.SavedStarGifts; payments.getCraftStarGifts#fd05dd00 gift_id:long offset:string limit:int = payments.SavedStarGifts;
payments.craftStarGift#b0f9684f stargift:Vector<InputSavedStarGift> = Updates; payments.craftStarGift#b0f9684f stargift:Vector<InputSavedStarGift> = Updates;
phone.getCallConfig#55451fa9 = DataJSON;
phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall;
phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall; phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall;
phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall; phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall;

View File

@ -387,6 +387,7 @@
"phone.toggleGroupCallStartSubscription", "phone.toggleGroupCallStartSubscription",
"phone.joinGroupCallPresentation", "phone.joinGroupCallPresentation",
"phone.leaveGroupCallPresentation", "phone.leaveGroupCallPresentation",
"phone.getCallConfig",
"phone.requestCall", "phone.requestCall",
"phone.acceptCall", "phone.acceptCall",
"phone.confirmCall", "phone.confirmCall",

View File

@ -1,506 +0,0 @@
import { black, silence } from './blacksilence';
import type { ApiPhoneCallConnection, P2pParsedSdp } from './types';
import parseSdp from './parseSdp';
import type { MediaContent, MediaStateMessage, P2pMessage } from './p2pMessage';
import {
fromTelegramSource,
IS_ECHO_CANCELLATION_SUPPORTED,
IS_NOISE_SUPPRESSION_SUPPORTED,
p2pPayloadTypeToConference,
} from './utils';
import buildSdp, { Conference } from './buildSdp';
import { StreamType } from './secretsauce';
type P2pState = {
connection: RTCPeerConnection;
dataChannel: RTCDataChannel;
emitSignalingData: (data: P2pMessage) => void;
onUpdate: (...args: any[]) => void;
conference?: Partial<Conference>;
isOutgoing: boolean;
pendingCandidates: string[];
streams: {
video?: MediaStream;
audio?: MediaStream;
presentation?: MediaStream;
ownAudio?: MediaStream;
ownVideo?: MediaStream;
ownPresentation?: MediaStream;
};
silence: MediaStream;
blackVideo: MediaStream;
blackPresentation: MediaStream;
mediaState: Omit<MediaStateMessage, '@type'>;
audio: HTMLAudioElement;
gotInitialSetup?: boolean;
facingMode?: VideoFacingModeEnum;
};
let state: P2pState | undefined;
const ICE_CANDIDATE_POOL_SIZE = 10;
export function getStreams() {
return state?.streams;
}
function updateStreams() {
state?.onUpdate({
...state.mediaState,
'@type': 'updatePhoneCallMediaState',
});
}
function getUserStream(streamType: StreamType, facing: VideoFacingModeEnum = 'user') {
if (streamType === 'presentation') {
return (navigator.mediaDevices as any).getDisplayMedia({
audio: false,
video: true,
});
}
return navigator.mediaDevices.getUserMedia({
audio: streamType === 'audio' ? {
...(IS_ECHO_CANCELLATION_SUPPORTED && { echoCancellation: true }),
...(IS_NOISE_SUPPRESSION_SUPPORTED && { noiseSuppression: true }),
} : false,
video: streamType === 'video' ? {
facingMode: facing,
} : false,
});
}
export async function switchCameraInputP2p() {
if (!state || !state.facingMode) {
return;
}
const stream = state.streams.ownVideo;
if (!stream) return;
const track = stream.getTracks()[0];
if (!track) {
return;
}
const sender = state.connection.getSenders().find((l) => track.id === l.track?.id);
if (!sender) {
return;
}
state.facingMode = state.facingMode === 'environment' ? 'user' : 'environment';
try {
const newStream = await getUserStream('video', state.facingMode);
await sender.replaceTrack(newStream.getTracks()[0]);
state.streams.ownVideo = newStream;
updateStreams();
} catch (e) {
}
}
export async function toggleStreamP2p(streamType: StreamType, value: boolean | undefined = undefined) {
if (!state) return;
const stream = streamType === 'audio' ? state.streams.ownAudio
: (streamType === 'video' ? state.streams.ownVideo : state.streams.ownPresentation);
if (!stream) return;
const track = stream.getTracks()[0];
if (!track) {
return;
}
const sender = state.connection.getSenders().find((l) => track.id === l.track?.id);
if (!sender) {
return;
}
value = value === undefined ? !track.enabled : value;
try {
if (value && !track.enabled) {
const newStream = await getUserStream(streamType);
newStream.getTracks()[0].onended = () => {
toggleStreamP2p(streamType, false);
};
await sender.replaceTrack(newStream.getTracks()[0]);
if (streamType === 'audio') {
state.streams.ownAudio = newStream;
} else if (streamType === 'video') {
state.streams.ownVideo = newStream;
state.facingMode = 'user';
} else {
state.streams.ownPresentation = newStream;
}
if (streamType === 'video' || streamType === 'presentation') {
toggleStreamP2p(streamType === 'video' ? 'presentation' : 'video', false);
}
// if (streamType === 'video') {
// state.facingMode = 'user';
// }
} else if (!value && track.enabled) {
track.stop();
const newStream = streamType === 'audio' ? state.silence
: (streamType === 'video' ? state.blackVideo : state.blackPresentation);
if (!newStream) return;
await sender.replaceTrack(newStream.getTracks()[0]);
if (streamType === 'audio') {
state.streams.ownAudio = newStream;
} else if (streamType === 'video') {
state.streams.ownVideo = newStream;
} else {
state.streams.ownPresentation = newStream;
}
// if (streamType === 'video') {
// state.facingMode = undefined;
// }
}
updateStreams();
sendMediaState();
} catch (err) {
console.error(err)
}
}
export async function joinPhoneCall(
connections: ApiPhoneCallConnection[],
emitSignalingData: (data: P2pMessage) => void,
isOutgoing: boolean,
shouldStartVideo: boolean,
isP2p: boolean,
onUpdate: (...args: any[]) => void,
) {
const conn = new RTCPeerConnection({
iceServers: connections.map((connection) => (
{
urls: [
connection.isTurn && `turn:${connection.ip}:${connection.port}`,
connection.isStun && `stun:${connection.ip}:${connection.port}`,
].filter(Boolean),
username: connection.username,
credentialType: 'password',
credential: connection.password,
}
)),
iceTransportPolicy: isP2p ? 'all' : 'relay',
bundlePolicy: 'max-bundle',
iceCandidatePoolSize: ICE_CANDIDATE_POOL_SIZE,
});
conn.onicecandidate = (e) => {
if (!e.candidate) {
return;
};
emitSignalingData({
'@type': 'Candidates',
candidates: [{
sdpString: e.candidate.candidate,
}],
});
};
conn.onconnectionstatechange = () => {
onUpdate({
'@type': 'updatePhoneCallConnectionState',
connectionState: conn.connectionState,
});
};
conn.ontrack = (e) => {
if (!state) return;
const stream = e.streams[0];
if (e.track.kind === 'audio') {
state.audio.srcObject = stream;
state.audio.play().catch();
state.streams.audio = stream;
} else if (e.transceiver.mid === '1') {
state.streams.video = stream;
} else {
state.streams.presentation = stream;
}
updateStreams();
};
conn.oniceconnectionstatechange = async (e) => {
switch(conn.iceConnectionState) {
case 'disconnected':
case 'failed':
if (isOutgoing) {
await createOffer(conn, {
offerToReceiveAudio: true,
offerToReceiveVideo: true,
iceRestart: true,
});
}
default:
break;
}
}
const slnc = silence(new AudioContext());
const video = black({ width: 640, height: 480 });
const screenshare = black({ width: 640, height: 480 });
conn.addTrack(slnc.getTracks()[0], slnc);
conn.addTrack(video.getTracks()[0], video);
conn.addTrack(screenshare.getTracks()[0], screenshare);
const dc = conn.createDataChannel('data', {
id: 0,
negotiated: true,
});
dc.onmessage = (e) => {
processSignalingMessage(JSON.parse(e.data));
};
const audio = new Audio();
state = {
audio,
connection: conn,
emitSignalingData,
isOutgoing,
pendingCandidates: [],
onUpdate,
streams: {
ownVideo: video,
ownAudio: slnc,
ownPresentation: screenshare,
},
mediaState: {
isBatteryLow: false,
screencastState: 'inactive',
videoState: 'inactive',
videoRotation: 0,
isMuted: true,
},
blackVideo: video,
blackPresentation: screenshare,
silence: slnc,
dataChannel: dc,
};
try {
toggleStreamP2p('audio', true);
} catch (err) {
console.error(err)
}
if (isOutgoing) {
await createOffer(conn, {
offerToReceiveAudio: true,
offerToReceiveVideo: true,
})
}
}
export function stopPhoneCall() {
if (!state) return;
state.streams.ownVideo?.getTracks().forEach((track) => track.stop());
state.streams.ownPresentation?.getTracks().forEach((track) => track.stop());
state.streams.ownAudio?.getTracks().forEach((track) => track.stop());
state.dataChannel.close();
state.connection.close();
state = undefined;
}
function sendMediaState() {
if (!state) return;
const { emitSignalingData, streams } = state;
emitSignalingData({
'@type': 'MediaState',
videoRotation: 0,
isMuted: !streams.ownAudio?.getTracks()[0].enabled,
isBatteryLow: true,
videoState: streams.ownVideo?.getTracks()[0].enabled ? 'active' : 'inactive',
screencastState: streams.ownPresentation?.getTracks()[0].enabled ? 'active' : 'inactive',
});
}
function filterVP8(mediaContent: MediaContent) {
if (!state || state.isOutgoing) return mediaContent;
const { payloadTypes } = mediaContent!;
const idx = payloadTypes.findIndex((payloadType) => payloadType.name === 'VP8');
const vp8PayloadType = payloadTypes[idx];
const rtxIdx = payloadTypes.findIndex((payloadType) => Number(payloadType.parameters?.apt) === vp8PayloadType.id);
mediaContent.payloadTypes = [payloadTypes[idx], payloadTypes[rtxIdx]];
return mediaContent;
}
function sendInitialSetup(sdp: P2pParsedSdp) {
if (!state) return;
const { emitSignalingData } = state;
if (!sdp.ssrc || !sdp['ssrc-groups'] || !sdp['ssrc-groups'][0] || !sdp['ssrc-groups'][1]) return;
emitSignalingData({
'@type': 'InitialSetup',
fingerprints: sdp.fingerprints,
ufrag: sdp.ufrag,
pwd: sdp.pwd,
audio: {
ssrc: fromTelegramSource(sdp.ssrc).toString(),
ssrcGroups: [],
payloadTypes: sdp.audioPayloadTypes,
rtpExtensions: sdp.audioExtmap,
},
video: filterVP8({
ssrc: fromTelegramSource(sdp['ssrc-groups'][0].sources[0]).toString(),
ssrcGroups: [{
semantics: sdp['ssrc-groups'][0].semantics,
ssrcs: sdp['ssrc-groups'][0].sources.map(fromTelegramSource),
}],
payloadTypes: sdp.videoPayloadTypes,
rtpExtensions: sdp.videoExtmap,
}),
screencast: filterVP8({
ssrc: fromTelegramSource(sdp['ssrc-groups'][1].sources[0]).toString(),
ssrcGroups: [{
semantics: sdp['ssrc-groups'][1].semantics,
ssrcs: sdp['ssrc-groups'][1].sources.map(fromTelegramSource),
}],
payloadTypes: sdp.screencastPayloadTypes,
rtpExtensions: sdp.screencastExtmap,
}),
});
}
export async function processSignalingMessage(message: P2pMessage) {
if (!state || !state.connection) return;
switch (message['@type']) {
case 'MediaState': {
state.mediaState = message;
updateStreams();
sendMediaState();
break;
}
case 'Candidates': {
const { pendingCandidates, gotInitialSetup } = state;
message.candidates.forEach((candidate) => {
pendingCandidates.push(candidate.sdpString);
});
if (gotInitialSetup) {
await commitPendingIceCandidates();
}
break;
}
case 'InitialSetup': {
const {
connection, isOutgoing,
} = state;
if (!connection) return;
const newConference = {
transport: {
candidates: [],
ufrag: message.ufrag,
pwd: message.pwd,
fingerprints: message.fingerprints,
'rtcp-mux': false,
xmlns: '',
},
sessionId: Date.now(),
ssrcs: [
message.audio && {
isVideo: false,
isMain: false,
userId: '123',
endpoint: '0',
mid: '0',
sourceGroups: [{
sources: [message.audio.ssrc],
}],
},
message.video && {
isVideo: true,
isPresentation: false,
isMain: false,
userId: '123',
endpoint: '1',
mid: '1',
sourceGroups: message.video.ssrcGroups.map((l) => ({
semantics: l.semantics,
sources: l.ssrcs,
})),
},
message.screencast && {
isVideo: true,
isPresentation: true,
isMain: false,
userId: '123',
endpoint: '2',
mid: '2',
sourceGroups: message.screencast.ssrcGroups.map((l) => ({
semantics: l.semantics,
sources: l.ssrcs,
})),
},
],
audioPayloadTypes: message.audio!.payloadTypes?.map(p2pPayloadTypeToConference) || [],
audioExtensions: message.audio!.rtpExtensions,
videoPayloadTypes: filterVP8(message.video!).payloadTypes?.map(p2pPayloadTypeToConference) || [],
videoExtensions: message.video!.rtpExtensions,
} as Conference;
await connection.setRemoteDescription({
sdp: buildSdp(newConference, isOutgoing, undefined, true),
type: isOutgoing ? 'answer' : 'offer',
});
state.conference = newConference;
if (!isOutgoing) {
const answer = await connection.createAnswer();
await connection.setLocalDescription(answer);
sendInitialSetup(parseSdp(connection.localDescription!, true) as P2pParsedSdp);
}
state.gotInitialSetup = true;
await commitPendingIceCandidates();
break;
}
}
}
async function commitPendingIceCandidates() {
if (!state) {
return;
}
const { pendingCandidates, connection } = state;
if (!pendingCandidates.length) {
return;
}
await Promise.all(pendingCandidates.map((c) => tryAddCandidate(connection, c)));
state.pendingCandidates = [];
}
async function tryAddCandidate(connection: RTCPeerConnection, candidate: string) {
try {
await connection.addIceCandidate({
candidate,
sdpMLineIndex: 0,
})
} catch (err) {
console.error(err);
}
}
async function createOffer(conn: RTCPeerConnection, params: RTCOfferOptions) {
const offer = await conn.createOffer(params);
await conn.setLocalDescription(offer);
sendInitialSetup(parseSdp(conn.localDescription!, true) as P2pParsedSdp);
}

View File

@ -1,140 +0,0 @@
import { toTelegramSource } from './utils';
import type { JoinGroupCallPayload, SsrcGroup } from './types';
export default (sessionDescription: RTCSessionDescriptionInit, isP2p = false): JoinGroupCallPayload => {
if (!sessionDescription || !sessionDescription.sdp) {
throw Error('Failed parsing SDP: session description is null');
}
const sections = sessionDescription
.sdp
.split('\r\nm=')
.map((s, i) => (i === 0 ? s : `m=${s}`))
.reduce((acc: Record<string, string[]>, el) => {
const name = el.match(/^m=(.+?)\s/)?.[1] || 'header';
acc[acc.hasOwnProperty(name) && name === 'video' ? 'screencast' : name] = el.split('\r\n').filter(Boolean);
return acc;
}, {});
const lookup = (prefix: string, sectionName?: string) => {
if (!sectionName) {
return Object.values(sections).map((section) => {
return section.find((line) => line.startsWith(prefix))?.substr(prefix.length);
}).filter(Boolean)[0];
} else {
return sections[sectionName]?.find((line) => line.startsWith(prefix))?.substr(prefix.length);
}
};
const parseExtmaps = (sectionName: string) => {
return sections[sectionName].filter((l) => l.startsWith('a=extmap')).map((l) => {
const [, id, uri] = l.match(/extmap:(\d+)(?:\/.+)?\s(.+)/)!;
return { id: Number(id), uri };
});
};
const parsePayloadTypes = (sectionName: string) => {
const payloads = sections[sectionName].filter((l) => l.startsWith('a=rtpmap')).map((l) => {
const [, id, data] = l.match(/:(\d+)\s(.+)/) || [];
const [name, clockrate, channels] = data.split('/');
return {
id: Number(id), name, clockrate: Number(clockrate), ...(channels && { channels: Number(channels) }),
};
});
const fbParams = sections[sectionName].filter((l) => l.startsWith('a=rtcp-fb')).map((l) => {
const [, id, data] = l.match(/:(\d+)\s(.+)/) || [];
const [type, subtype] = data.split(' ');
return { id: Number(id), type, subtype: subtype || '' };
});
const parameters = sections[sectionName].filter((l) => l.startsWith('a=fmtp')).map((l) => {
const [, id, data] = l.match(/:(\d+)\s(.+)/) || [];
const d = data?.split(';').reduce((acc: Record<string, string>, q) => {
const [name, value] = q.split('=');
acc[name] = value;
return acc;
}, {});
if (!d || Object.values(d).some((z) => !z)) return undefined;
return { id: Number(id), data: d };
}).filter(Boolean);
return payloads.map((payload) => {
const p = parameters.filter((l) => l!.id === payload.id).map((q) => q!.data).reduce((acc, el) => {
return Object.assign(acc, el);
}, {});
const f = fbParams.filter((l) => l.id === payload.id).map((l) => {
return {
type: l.type,
subtype: l.subtype,
};
});
return {
...payload,
...(Object.keys(p).length > 0 && { parameters: p }),
...(f.length > 0 && { feedbackTypes: f }),
};
});
};
const rawSource = lookup('a=ssrc:', 'audio');
const sourceAudio = rawSource && Number(rawSource.split(' ')[0]);
// TODO multiple source groups
const rawSourceVideo = lookup('a=ssrc-group:', 'video')?.split(' ') || undefined;
const rawSourceScreencast = lookup('a=ssrc-group:', 'screencast')?.split(' ') || undefined;
if (!rawSourceVideo) {
throw Error('Failed parsing SDP: no video ssrc');
}
const [hash, fingerprint] = lookup('a=fingerprint:')?.split(' ') || [];
const setup = lookup('a=setup:');
if (!hash || !fingerprint) {
throw Error('Failed parsing SDP: no fingerprint');
}
console.log(sections);
const ufrag = lookup('a=ice-ufrag:');
const pwd = lookup('a=ice-pwd:');
if (!ufrag || !pwd) {
throw Error('Failed parsing SDP: no ICE ufrag or pwd');
}
return {
fingerprints: [
{
fingerprint,
hash,
setup: isP2p ? setup! : 'active',
},
],
pwd,
ufrag,
...(sourceAudio && { ssrc: toTelegramSource(sourceAudio) }),
...(rawSourceVideo && {
'ssrc-groups': [
{
semantics: rawSourceVideo[0],
sources: rawSourceVideo.slice(1, rawSourceVideo.length).map(Number).map(toTelegramSource),
},
(isP2p && rawSourceScreencast && {
semantics: rawSourceScreencast[0],
sources: rawSourceScreencast.slice(1, rawSourceScreencast.length).map(Number).map(toTelegramSource),
}),
].filter(Boolean) as SsrcGroup[],
}),
...(isP2p && {
audioExtmap: parseExtmaps('audio'),
videoExtmap: parseExtmaps('video'),
screencastExtmap: parseExtmaps('screencast'),
audioPayloadTypes: parsePayloadTypes('audio'),
videoPayloadTypes: parsePayloadTypes('video'),
screencastPayloadTypes: parsePayloadTypes('screencast'),
}),
};
};

View File

@ -39,7 +39,7 @@ export type ReceiverVideoConstraints = {
onStageEndpoints: string[]; onStageEndpoints: string[];
}; };
export type ColibriClass = ( export type GroupCallDataChannelMessage = (
LastNEndpointsChangeEvent | DebugMessage | EndpointConnectivityStatusChangeEvent | LastNEndpointsChangeEvent | DebugMessage | EndpointConnectivityStatusChangeEvent |
SenderVideoConstraints | DominantSpeakerEndpointChangeEvent | ReceiverVideoConstraints SenderVideoConstraints | DominantSpeakerEndpointChangeEvent | ReceiverVideoConstraints
); );

View File

@ -3,12 +3,13 @@ export {
getDevices, getUserStreams, setVolume, isStreamEnabled, toggleStream, getDevices, getUserStreams, setVolume, isStreamEnabled, toggleStream,
leaveGroupCall, handleUpdateGroupCallParticipants, switchCameraInput, leaveGroupCall, handleUpdateGroupCallParticipants, switchCameraInput,
toggleSpeaker, toggleNoiseSuppression, toggleSpeaker, toggleNoiseSuppression,
} from './secretsauce'; } from './group/groupCall';
export { export {
joinPhoneCall, processSignalingMessage, getStreams, toggleStreamP2p, stopPhoneCall, switchCameraInputP2p, joinPhoneCall, processSignalingMessage, getStreams, toggleStreamP2p, stopPhoneCall, switchCameraInputP2p,
} from './p2p'; } from './phone/phoneCall';
export * from './p2pMessage'; export * from './phone/signalingMessages';
export { export {
IS_SCREENSHARE_SUPPORTED, THRESHOLD, IS_SCREENSHARE_SUPPORTED, sanitizePrimitiveRecord, THRESHOLD,
} from './utils'; } from './utils';
export type { PrimitiveRecord, PrimitiveRecordValue } from './utils';
export * from './types'; export * from './types';

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import type { import type {
Fingerprint, RTCPFeedbackParam, RTPExtension, Fingerprint, RTCPFeedbackParam, RTPExtension,
} from './types'; } from '../types';
export type VideoState = 'inactive' | 'active' | 'suspended'; export type VideoState = 'inactive' | 'active' | 'suspended';
@ -8,15 +8,17 @@ export type VideoRotation = 0 | 90 | 180 | 270;
export type MediaStateMessage = { export type MediaStateMessage = {
'@type': 'MediaState'; '@type': 'MediaState';
isMuted: boolean; muted: boolean;
videoState: VideoState; videoState: VideoState;
videoRotation: VideoRotation; videoRotation: VideoRotation;
screencastState: VideoState; screencastState: VideoState;
isBatteryLow: boolean; lowBattery: boolean;
}; };
type CandidatesMessage = { type CandidatesMessage = {
'@type': 'Candidates'; '@type': 'Candidates';
exchangeId?: string;
ufrag?: string;
candidates: P2pCandidate[]; candidates: P2pCandidate[];
}; };
@ -24,17 +26,22 @@ export type InitialSetupMessage = {
'@type': 'InitialSetup'; '@type': 'InitialSetup';
ufrag: string; ufrag: string;
pwd: string; pwd: string;
renomination: boolean;
fingerprints: Fingerprint[]; fingerprints: Fingerprint[];
audio?: MediaContent;
video?: MediaContent;
screencast?: MediaContent;
}; };
export type MediaContent = { export type MediaContent = {
type: 'audio' | 'video';
ssrc: string; ssrc: string;
ssrcGroups: P2pSsrcGroup[]; ssrcGroups?: P2pSsrcGroup[];
payloadTypes: P2PPayloadType[]; payloadTypes?: P2PPayloadType[];
rtpExtensions: RTPExtension[]; rtpExtensions?: RTPExtension[];
};
export type NegotiateChannelsMessage = {
'@type': 'NegotiateChannels';
exchangeId: string;
contents: MediaContent[];
}; };
export interface P2PPayloadType { export interface P2PPayloadType {
@ -53,6 +60,9 @@ type P2pSsrcGroup = {
type P2pCandidate = { type P2pCandidate = {
sdpString: string; sdpString: string;
sdpMid?: string;
sdpMLineIndex?: number;
usernameFragment?: string;
}; };
export type P2pMessage = CandidatesMessage | InitialSetupMessage | MediaStateMessage; export type P2pMessage = CandidatesMessage | InitialSetupMessage | MediaStateMessage | NegotiateChannelsMessage;

View File

@ -1,7 +1,8 @@
import type { import type {
Candidate, GroupCallTransport, PayloadType, RTPExtension, SsrcGroup, Candidate, GroupCallTransport, PayloadType, RTPExtension, SsrcGroup,
} from './types'; } from '../types';
import { fromTelegramSource } from './utils';
import { fromTelegramSource } from '../utils';
export type Conference = { export type Conference = {
sessionId: number; sessionId: number;
@ -77,7 +78,9 @@ export default (conference: Conference, isAnswer = false, isPresentation = false
add(`a=ice-pwd:${pwd}`); add(`a=ice-pwd:${pwd}`);
fingerprints.forEach((fingerprint) => { fingerprints.forEach((fingerprint) => {
add(`a=fingerprint:${fingerprint.hash} ${fingerprint.fingerprint}`); add(`a=fingerprint:${fingerprint.hash} ${fingerprint.fingerprint}`);
add(`a=setup:${isP2p ? (fingerprint.setup) : 'passive'}`); const setup = isAnswer && fingerprint.setup !== 'active' && fingerprint.setup !== 'passive'
? 'passive' : fingerprint.setup || 'passive';
add(`a=setup:${setup}`);
}); });
candidates.forEach(addCandidate); candidates.forEach(addCandidate);
@ -93,7 +96,7 @@ export default (conference: Conference, isAnswer = false, isPresentation = false
if (parameters) { if (parameters) {
const parametersString = Object.keys(parameters).map((key) => { const parametersString = Object.keys(parameters).map((key) => {
return `${key}=${parameters![key]};`; return `${key}=${parameters[key]};`;
}).join(' '); }).join(' ');
add(`a=fmtp:${id} ${parametersString}`); add(`a=fmtp:${id} ${parametersString}`);
@ -108,7 +111,7 @@ export default (conference: Conference, isAnswer = false, isPresentation = false
const payloadTypes = entry.isVideo ? videoPayloadTypes : audioPayloadTypes; const payloadTypes = entry.isVideo ? videoPayloadTypes : audioPayloadTypes;
const type = entry.isVideo ? 'video' : 'audio'; const type = entry.isVideo ? 'video' : 'audio';
add(`m=${type} ${entry.isMain ? 1 : 0} RTP/SAVPF ${payloadTypes.map((l) => l.id).join(' ')}`); add(`m=${type} ${entry.isRemoved ? 0 : 1} RTP/SAVPF ${payloadTypes.map((l) => l.id).join(' ')}`);
add('c=IN IP4 0.0.0.0'); add('c=IN IP4 0.0.0.0');
add('b=AS:1300'); // 1300000 / 1000 add('b=AS:1300'); // 1300000 / 1000
add(`a=mid:${entry.mid}`); add(`a=mid:${entry.mid}`);
@ -162,9 +165,9 @@ export default (conference: Conference, isAnswer = false, isPresentation = false
}; };
if (!isP2p) { if (!isP2p) {
ssrcs.filter((ssrc) => ssrc.mid === '0' || ssrc.mid === '1').map(addSsrcEntry); ssrcs.filter((ssrc) => ssrc.mid === '0' || ssrc.mid === '1').forEach(addSsrcEntry);
} else { } else {
ssrcs.filter(addSsrcEntry); ssrcs.forEach(addSsrcEntry);
} }
if (!isPresentation) { if (!isPresentation) {
@ -178,7 +181,7 @@ export default (conference: Conference, isAnswer = false, isPresentation = false
} }
if (!isP2p) { if (!isP2p) {
ssrcs.filter((ssrc) => ssrc.mid !== '0' && ssrc.mid !== '1').map(addSsrcEntry); ssrcs.filter((ssrc) => ssrc.mid !== '0' && ssrc.mid !== '1').forEach(addSsrcEntry);
} }
return `${lines.join('\n')}\n`; return `${lines.join('\n')}\n`;

View File

@ -0,0 +1,231 @@
import type { P2PPayloadType } from '../phone/signalingMessages';
import type {
Fingerprint, RTPExtension,
} from '../types';
export type SdpSection = {
kind: string;
lines: string[];
mid?: string;
};
type SdpSummarySection = {
kind: string;
mid?: string;
port: number;
direction?: string;
payloads: string[];
ssrcs: number[];
};
export function parseSdpSections(sdp: string) {
return sdp
.split(/\r?\nm=/)
.map((section, index) => index === 0 ? section : `m=${section}`)
.map((section): SdpSection => {
const lines = section.split(/\r?\n/).filter(Boolean);
const kind = lines[0]?.match(/^m=([^\s]+)/)?.[1] || 'session';
const mid = lines.find((line) => line.startsWith('a=mid:'))?.slice('a=mid:'.length);
return { kind, lines, mid };
});
}
export function parseBundleMids(sdp: string) {
const line = sdp.split(/\r?\n/).find((item) => item.startsWith('a=group:BUNDLE '));
return line?.slice('a=group:BUNDLE '.length).split(' ').filter(Boolean);
}
export function findSdpLineValue(sections: SdpSection[], prefix: string, section?: SdpSection) {
if (section) {
const sectionLine = section.lines.find((line) => line.startsWith(prefix));
if (sectionLine) {
return sectionLine.slice(prefix.length);
}
for (const item of sections) {
if (item.kind !== 'session') {
continue;
}
const sessionLine = item.lines.find((line) => line.startsWith(prefix));
if (sessionLine) {
return sessionLine.slice(prefix.length);
}
}
return undefined;
}
for (const item of sections) {
const value = item.lines.find((line) => line.startsWith(prefix))?.slice(prefix.length);
if (value) {
return value;
}
}
return undefined;
}
export function getSdpDirection(section: SdpSection) {
return section.lines.find((line) => {
return line === 'a=sendrecv' || line === 'a=sendonly' || line === 'a=recvonly' || line === 'a=inactive';
})?.slice(2);
}
export function getSdpPort(section: SdpSection) {
return Number(section.lines[0]?.split(' ')[1] || 0);
}
export function parseFingerprints(sections: SdpSection[]): Fingerprint[] {
const values = new Map<string, Fingerprint>();
sections.forEach((section) => {
const fingerprint = findSdpLineValue(sections, 'a=fingerprint:', section);
if (!fingerprint) {
return;
}
const [hash, value] = fingerprint.split(' ');
const setup = findSdpLineValue(sections, 'a=setup:', section) || 'actpass';
if (!hash || !value) {
return;
}
values.set(`${hash}:${value}:${setup}`, {
hash,
setup,
fingerprint: value,
});
});
return Array.from(values.values());
}
export function parseSsrcGroups(section: SdpSection) {
return section.lines.filter((line) => line.startsWith('a=ssrc-group:')).map((line) => {
const [, value] = line.split(':');
const [semantics, ...ssrcs] = value.split(' ');
return {
semantics,
ssrcs: ssrcs.map(Number),
};
});
}
export function parseSsrcs(section: SdpSection, shouldIncludeGroups = false) {
const values = new Set<number>();
section.lines.forEach((line) => {
if (shouldIncludeGroups && line.startsWith('a=ssrc-group:')) {
line.match(/\d+/g)?.forEach((value) => {
values.add(Number(value));
});
return;
}
const match = line.match(/^a=ssrc:(\d+)/);
if (!match?.[1]) {
return;
}
values.add(Number(match[1]));
});
return Array.from(values);
}
export function parseExtmaps(section: SdpSection): RTPExtension[] {
return section.lines.filter((line) => line.startsWith('a=extmap:')).map((line) => {
const [, rawId, uri] = line.match(/^a=extmap:(\d+)(?:\/[^\s]+)?\s(.+)$/) || [];
if (!rawId || !uri) {
throw Error('Failed parsing SDP RTP extension');
}
return {
id: Number(rawId),
uri,
};
});
}
export function parsePayloadTypes(section: SdpSection): P2PPayloadType[] {
const payloadTypes: P2PPayloadType[] = section.lines.filter((line) => line.startsWith('a=rtpmap:')).map((line) => {
const [, rawId, name, rawClockrate, rawChannels] = line.match(/^a=rtpmap:(\d+)\s([^/]+)\/(\d+)(?:\/(\d+))?/) || [];
if (!rawId || !name || !rawClockrate) {
throw Error('Failed parsing SDP payload type');
}
return {
id: Number(rawId),
name,
clockrate: Number(rawClockrate),
channels: rawChannels ? Number(rawChannels) : 0,
};
});
payloadTypes.forEach((payloadType) => {
const parameters = parsePayloadParameters(section, payloadType.id);
const feedbackTypes = parseFeedbackTypes(section, payloadType.id);
if (Object.keys(parameters).length) {
payloadType.parameters = parameters;
}
if (feedbackTypes.length) {
payloadType.feedbackTypes = feedbackTypes;
}
});
return payloadTypes;
}
export function summarizeSdp(sdp: string, shouldIncludeSsrcGroups = false) {
return parseSdpSections(sdp).filter((section) => section.kind !== 'session').map((section): SdpSummarySection => {
const mLine = section.lines[0] || '';
const parts = mLine.split(' ');
return {
kind: section.kind,
mid: section.mid,
port: getSdpPort(section),
direction: getSdpDirection(section),
payloads: parts.slice(3),
ssrcs: parseSsrcs(section, shouldIncludeSsrcGroups),
};
});
}
function parsePayloadParameters(section: SdpSection, payloadId: number) {
const parameters: Record<string, string> = {};
const line = section.lines.find((item) => item.startsWith(`a=fmtp:${payloadId} `));
const rawParameters = line?.slice(`a=fmtp:${payloadId} `.length);
if (!rawParameters) {
return parameters;
}
rawParameters.split(';').forEach((item) => {
const trimmed = item.trim();
const separatorIndex = trimmed.indexOf('=');
if (separatorIndex === -1) {
return;
}
const key = trimmed.slice(0, separatorIndex);
const value = trimmed.slice(separatorIndex + 1);
if (key && value) {
parameters[key] = value;
}
});
return parameters;
}
function parseFeedbackTypes(section: SdpSection, payloadId: number): NonNullable<P2PPayloadType['feedbackTypes']> {
return section.lines.filter((line) => line.startsWith(`a=rtcp-fb:${payloadId} `)).map((line) => {
const value = line.slice(`a=rtcp-fb:${payloadId} `.length);
const [type, subtype] = value.split(' ');
return {
type,
subtype: subtype || '',
};
});
}

View File

@ -0,0 +1,127 @@
import type { JoinGroupCallPayload, P2pParsedSdp, SsrcGroup } from '../types';
import type { SdpSection } from './common';
import { toTelegramSource } from '../utils';
import {
findSdpLineValue,
parseExtmaps as parseSectionExtmaps,
parsePayloadTypes as parseSectionPayloadTypes,
parseSdpSections,
} from './common';
// Returns undefined when the SDP is missing or malformed.
export default (
sessionDescription: RTCSessionDescriptionInit,
isP2p = false,
): JoinGroupCallPayload | P2pParsedSdp | undefined => {
if (!sessionDescription.sdp) {
return undefined;
}
const sdpSections = parseSdpSections(sessionDescription.sdp);
const sections = sdpSections.reduce((acc: Record<string, SdpSection>, section) => {
const name = section.kind === 'session' ? 'header' : section.kind;
acc[acc.hasOwnProperty(name) && name === 'video' ? 'screencast' : name] = section;
return acc;
}, {});
const lookup = (prefix: string, sectionName?: string) => {
return findSdpLineValue(Object.values(sections), prefix, sectionName ? sections[sectionName] : undefined);
};
const lookupAll = (prefix: string, sectionName: string) => {
return sections[sectionName]?.lines
.filter((line) => line.startsWith(prefix))
.map((line) => line.slice(prefix.length)) || [];
};
const parseExtmaps = (sectionName: string) => parseSectionExtmaps(sections[sectionName]);
const parsePayloadTypes = (sectionName: string) => parseSectionPayloadTypes(sections[sectionName]);
const rawSource = lookup('a=ssrc:', 'audio');
const sourceAudio = rawSource && Number(rawSource.split(' ')[0]);
const rawSourceVideo = lookupAll('a=ssrc-group:', 'video').map((line) => line.split(' '));
const rawSourceScreencast = lookupAll('a=ssrc-group:', 'screencast').map((line) => line.split(' '));
if (!rawSourceVideo.length) {
return undefined;
}
const [hash, fingerprint] = lookup('a=fingerprint:')?.split(' ') || [];
const setup = lookup('a=setup:');
if (!hash || !fingerprint) {
return undefined;
}
const ufrag = lookup('a=ice-ufrag:');
const pwd = lookup('a=ice-pwd:');
if (!ufrag || !pwd) {
return undefined;
}
const payload: JoinGroupCallPayload = {
fingerprints: [
{
fingerprint,
hash,
setup: setup === 'active' || setup === 'passive' ? setup : 'passive',
},
],
pwd,
ufrag,
};
if (sourceAudio) {
payload.ssrc = toTelegramSource(sourceAudio);
}
const ssrcGroups = parseSourceGroups(rawSourceVideo);
if (!ssrcGroups.length) {
return undefined;
}
if (isP2p && rawSourceScreencast.length) {
ssrcGroups.push(...parseSourceGroups(rawSourceScreencast));
}
payload['ssrc-groups'] = ssrcGroups;
if (!isP2p) {
return payload;
}
const hasScreencast = Boolean(sections.screencast);
try {
return {
...payload,
audioExtmap: parseExtmaps('audio'),
videoExtmap: parseExtmaps('video'),
screencastExtmap: hasScreencast ? parseExtmaps('screencast') : [],
audioPayloadTypes: parsePayloadTypes('audio'),
videoPayloadTypes: parsePayloadTypes('video'),
screencastPayloadTypes: hasScreencast ? parsePayloadTypes('screencast') : [],
};
} catch {
return undefined;
}
};
function parseSourceGroups(rawGroups: string[][]): SsrcGroup[] {
const result: SsrcGroup[] = [];
rawGroups.forEach(([semantics, ...sources]) => {
if (!semantics || !sources.length) {
return;
}
result.push({
semantics,
sources: sources.map(Number).map(toTelegramSource),
});
});
return result;
}

View File

@ -1,4 +1,6 @@
import { P2PPayloadType } from './p2pMessage'; import type { P2PPayloadType } from './phone/signalingMessages';
export type StreamType = 'audio' | 'video' | 'presentation';
export interface GroupCallParticipant { export interface GroupCallParticipant {
isSelf?: boolean; isSelf?: boolean;
@ -111,7 +113,7 @@ export interface GroupCallTransport {
export interface GroupCallConnectionData { export interface GroupCallConnectionData {
transport: GroupCallTransport; transport: GroupCallTransport;
audio: { audio?: {
'payload-types': PayloadType[]; 'payload-types': PayloadType[];
'rtp-hdrexts': RTPExtension[]; 'rtp-hdrexts': RTPExtension[];
}; };

View File

@ -1,17 +1,23 @@
import type { P2PPayloadType } from './p2pMessage'; import type { P2PPayloadType } from './phone/signalingMessages';
import type { PayloadType } from './types'; import type { PayloadType } from './types';
export {
sanitizePrimitiveRecord,
} from '../../util/primitives/primitiveRecord';
export type {
PrimitiveRecord,
PrimitiveRecordValue,
} from '../../util/primitives/primitiveRecord';
/// NOTE: telegram returns sign source, while webrtc uses unsign source internally /// NOTE: telegram returns sign source, while webrtc uses unsign source internally
/// unsign => sign /// unsign => sign
export function toTelegramSource(source: number) { export function toTelegramSource(source: number) {
// eslint-disable-next-line no-bitwise
return source << 0; return source << 0;
} }
/// NOTE: telegram returns sign source, while webrtc uses unsign source internally /// NOTE: telegram returns sign source, while webrtc uses unsign source internally
/// sign => unsign /// sign => unsign
export function fromTelegramSource(source: number) { export function fromTelegramSource(source: number) {
// eslint-disable-next-line no-bitwise
return source >>> 0; return source >>> 0;
} }
@ -64,5 +70,4 @@ export const THRESHOLD = 0.1;
export const IS_SCREENSHARE_SUPPORTED = 'getDisplayMedia' in (navigator?.mediaDevices || {}); export const IS_SCREENSHARE_SUPPORTED = 'getDisplayMedia' in (navigator?.mediaDevices || {});
export const IS_ECHO_CANCELLATION_SUPPORTED = navigator?.mediaDevices?.getSupportedConstraints().echoCancellation; export const IS_ECHO_CANCELLATION_SUPPORTED = navigator?.mediaDevices?.getSupportedConstraints().echoCancellation;
// @ts-ignore
export const IS_NOISE_SUPPRESSION_SUPPORTED = navigator?.mediaDevices?.getSupportedConstraints().noiseSuppression; export const IS_NOISE_SUPPRESSION_SUPPORTED = navigator?.mediaDevices?.getSupportedConstraints().noiseSuppression;

View File

@ -82,23 +82,11 @@
display: block; display: block;
width: 100%; width: 100%;
height: 0.75rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
-webkit-appearance: none; -webkit-appearance: none;
background: transparent; background: transparent;
&::-ms-track {
cursor: var(--custom-cursor, pointer);
width: 100%;
border-color: transparent;
color: transparent;
background: transparent;
}
&::-webkit-slider-thumb { &::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
} }

View File

@ -73,7 +73,7 @@ export const IS_CANVAS_FILTER_SUPPORTED = (
!IS_TEST && 'filter' in (document.createElement('canvas').getContext('2d') || {}) !IS_TEST && 'filter' in (document.createElement('canvas').getContext('2d') || {})
); );
export const IS_REQUEST_FULLSCREEN_SUPPORTED = 'requestFullscreen' in document.createElement('div'); export const IS_REQUEST_FULLSCREEN_SUPPORTED = 'requestFullscreen' in document.createElement('div');
export const ARE_CALLS_SUPPORTED = true; export const ARE_CALLS_SUPPORTED = !IS_FIREFOX;
export const IS_WAVE_TRANSFORM_SUPPORTED = !IS_MOBILE export const IS_WAVE_TRANSFORM_SUPPORTED = !IS_MOBILE
&& !IS_FIREFOX // https://bugzilla.mozilla.org/show_bug.cgi?id=1961378 && !IS_FIREFOX // https://bugzilla.mozilla.org/show_bug.cgi?id=1961378

View File

@ -0,0 +1,13 @@
export type PrimitiveRecordValue = string | number | boolean;
export type PrimitiveRecord = Record<string, PrimitiveRecordValue>;
export function sanitizePrimitiveRecord(data?: Record<string, unknown>) {
const result: PrimitiveRecord = {};
Object.entries(data || {}).forEach(([key, value]) => {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
result[key] = value;
}
});
return Object.keys(result).length ? result : undefined;
}

View File

@ -97,12 +97,6 @@ export default function createConfig(
{ {
directory: path.resolve(__dirname, 'src/lib/rlottie'), directory: path.resolve(__dirname, 'src/lib/rlottie'),
}, },
{
directory: path.resolve(__dirname, 'src/lib/video-preview'),
},
{
directory: path.resolve(__dirname, 'src/lib/secret-sauce'),
},
], ],
devMiddleware: { devMiddleware: {
stats: 'minimal', stats: 'minimal',