* Add app review * Use overlay instead of modal * Add fixes for ios * i18n-extract * Add to milliseconds function * Address review feedback * Add try to queryGlobalValue * added app review illustration * add feedback illustration * Add animations and feedback bot message * Restrict reviews to build environment variable * Fix bug with "dont ask anymore" * Add check for only supported servers * Add missing change * Use for await Co-authored-by: Daniel Espino <danielespino@MacBook-Pro-de-Daniel.local> Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>
1034 lines
31 KiB
TypeScript
1034 lines
31 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
/*! based on simple-peer. MIT License. Feross Aboukhadijeh
|
|
* <https://feross.org/opensource> */
|
|
|
|
import {Buffer} from 'buffer';
|
|
|
|
import {
|
|
RTCPeerConnection,
|
|
RTCIceCandidate,
|
|
RTCSessionDescription,
|
|
MediaStream,
|
|
MediaStreamTrack,
|
|
EventOnCandidate,
|
|
EventOnAddStream,
|
|
RTCDataChannel,
|
|
RTCSessionDescriptionType,
|
|
MessageEvent,
|
|
RTCIceCandidateType,
|
|
} from 'react-native-webrtc';
|
|
import stream from 'readable-stream';
|
|
|
|
import {toMilliseconds} from '@utils/datetime';
|
|
|
|
import type {ICEServersConfigs} from '@calls/types/calls';
|
|
|
|
const queueMicrotask = (callback: any) => {
|
|
Promise.resolve().then(callback).catch((e) => setTimeout(() => {
|
|
throw e;
|
|
}));
|
|
};
|
|
|
|
const errCode = (err: Error, code: string) => {
|
|
Object.defineProperty(err, 'code', {value: code, enumerable: true, configurable: true});
|
|
return err;
|
|
};
|
|
|
|
function generateId(): string {
|
|
// Implementation taken from http://stackoverflow.com/a/2117523
|
|
let id = 'xxxxxxxxxxxxxxxxxxxx';
|
|
|
|
id = id.replace(/[xy]/g, (c) => {
|
|
const r = Math.floor(Math.random() * 16);
|
|
|
|
let v;
|
|
if (c === 'x') {
|
|
v = r;
|
|
} else {
|
|
v = (r & 0x3) | 0x8;
|
|
}
|
|
|
|
return v.toString(16);
|
|
});
|
|
|
|
return id;
|
|
}
|
|
|
|
const MAX_BUFFERED_AMOUNT = 64 * 1024;
|
|
const ICECOMPLETE_TIMEOUT = toMilliseconds({seconds: 5});
|
|
const CHANNEL_CLOSING_TIMEOUT = toMilliseconds({seconds: 5});
|
|
|
|
/**
|
|
* WebRTC peer connection. Same API as node core `net.Socket`, plus a few extra methods.
|
|
* Duplex stream.
|
|
* @param {Object} opts
|
|
*/
|
|
export default class Peer extends stream.Duplex {
|
|
destroyed = false;
|
|
destroying = false;
|
|
connecting = false;
|
|
isConnected = false;
|
|
id = generateId().slice(0, 7);
|
|
channelName = generateId();
|
|
streams: MediaStream[];
|
|
|
|
private pcReady = false;
|
|
private channelReady = false;
|
|
private iceComplete = false; // ice candidate trickle done (got null candidate)
|
|
private iceCompleteTimer: ReturnType<typeof setTimeout>|null = null; // send an offer/answer anyway after some timeout
|
|
private channel: RTCDataChannel|null = null;
|
|
private pendingCandidates: RTCIceCandidateType[] = [];
|
|
|
|
private isNegotiating = false; // is this peer waiting for negotiation to complete?
|
|
private batchedNegotiation = false; // batch synchronous negotiations
|
|
private queuedNegotiation = false; // is there a queued negotiation request?
|
|
private sendersAwaitingStable = [];
|
|
private senderMap = new Map();
|
|
private closingInterval: ReturnType<typeof setInterval>|null = null;
|
|
|
|
private remoteTracks: MediaStreamTrack[] = [];
|
|
private remoteStreams: MediaStream[] = [];
|
|
|
|
private chunk = null;
|
|
private cb: ((error?: Error | null) => void) | null = null;
|
|
private interval: ReturnType<typeof setInterval>|null = null;
|
|
|
|
private pc: RTCPeerConnection|null = null;
|
|
private onFinishBound?: () => void;
|
|
|
|
constructor(localStream: MediaStream | null, iceServers: ICEServersConfigs) {
|
|
super({allowHalfOpen: false});
|
|
|
|
this.streams = localStream ? [localStream] : [];
|
|
|
|
this.onFinishBound = () => {
|
|
this.onFinish();
|
|
};
|
|
|
|
const connConfig = {
|
|
iceServers,
|
|
sdpSemantics: 'unified-plan',
|
|
};
|
|
|
|
try {
|
|
this.pc = new RTCPeerConnection(connConfig);
|
|
} catch (err) {
|
|
this.destroy(errCode(err as Error, 'ERR_PC_CONSTRUCTOR'));
|
|
return;
|
|
}
|
|
|
|
// We prefer feature detection whenever possible, but sometimes that's not
|
|
// possible for certain implementations.
|
|
this.pc.oniceconnectionstatechange = () => {
|
|
this.onIceStateChange();
|
|
};
|
|
this.pc.onicegatheringstatechange = () => {
|
|
this.onIceStateChange();
|
|
};
|
|
this.pc.onconnectionstatechange = () => {
|
|
this.onConnectionStateChange();
|
|
};
|
|
this.pc.onsignalingstatechange = () => {
|
|
this.onSignalingStateChange();
|
|
};
|
|
this.pc.onicecandidate = (event: EventOnCandidate) => {
|
|
this.onIceCandidate(event);
|
|
};
|
|
|
|
// Other spec events, unused by this implementation:
|
|
// - onconnectionstatechange
|
|
// - onicecandidateerror
|
|
// - onfingerprintfailure
|
|
// - onnegotiationneeded
|
|
|
|
this.setupData(this.pc.createDataChannel(this.channelName, {}));
|
|
|
|
if (this.streams) {
|
|
this.streams.forEach((s) => {
|
|
this.addStream(s);
|
|
});
|
|
}
|
|
|
|
this.pc.onaddstream = (event: EventOnAddStream) => {
|
|
this.onStream(event);
|
|
};
|
|
|
|
this.needsNegotiation();
|
|
|
|
this.once('finish', this.onFinishBound);
|
|
}
|
|
|
|
get bufferSize() {
|
|
return (this.channel && this.channel.bufferedAmount) || 0;
|
|
}
|
|
|
|
// HACK: it's possible channel.readyState is "closing" before peer.destroy() fires
|
|
// https://bugs.chromium.org/p/chromium/issues/detail?id=882743
|
|
get connected() {
|
|
return this.isConnected && this.channel?.readyState === 'open';
|
|
}
|
|
|
|
signal(dataIn: string | any) {
|
|
if (this.destroying) {
|
|
return;
|
|
}
|
|
if (this.destroyed) {
|
|
throw errCode(
|
|
new Error('cannot signal after peer is destroyed'),
|
|
'ERR_DESTROYED',
|
|
);
|
|
}
|
|
|
|
let data = dataIn;
|
|
if (typeof data === 'string') {
|
|
try {
|
|
data = JSON.parse(dataIn);
|
|
} catch (err) {
|
|
data = {};
|
|
}
|
|
}
|
|
|
|
if (data.renegotiate) {
|
|
this.needsNegotiation();
|
|
}
|
|
if (data.transceiverRequest) {
|
|
this.addTransceiver(
|
|
data.transceiverRequest.kind,
|
|
data.transceiverRequest.init,
|
|
);
|
|
}
|
|
if (data.candidate) {
|
|
if (this.pc?.remoteDescription && this.pc?.remoteDescription.type) {
|
|
this.addIceCandidate(data.candidate);
|
|
} else {
|
|
this.pendingCandidates.push(data.candidate);
|
|
}
|
|
}
|
|
if (data.sdp) {
|
|
this.pc?.
|
|
setRemoteDescription(
|
|
new RTCSessionDescription(data),
|
|
).
|
|
then(() => {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
this.pendingCandidates.forEach((candidate: RTCIceCandidateType) => {
|
|
this.addIceCandidate(candidate);
|
|
});
|
|
this.pendingCandidates = [];
|
|
|
|
if (this.pc?.remoteDescription.type === 'offer') {
|
|
this.createAnswer();
|
|
}
|
|
}).
|
|
catch((err: Error) => {
|
|
this.destroy(errCode(err, 'ERR_SET_REMOTE_DESCRIPTION'));
|
|
});
|
|
}
|
|
if (
|
|
!data.sdp &&
|
|
!data.candidate &&
|
|
!data.renegotiate &&
|
|
!data.transceiverRequest
|
|
) {
|
|
this.destroy(
|
|
errCode(
|
|
new Error('signal() called with invalid signal data'),
|
|
'ERR_SIGNALING',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
addIceCandidate(candidate: RTCIceCandidateType) {
|
|
const iceCandidateObj = new RTCIceCandidate(candidate);
|
|
this.pc?.addIceCandidate(iceCandidateObj).catch((err: Error) => {
|
|
this.destroy(errCode(err, 'ERR_ADD_ICE_CANDIDATE'));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send text/binary data to the remote peer.
|
|
* @param {ArrayBufferView|ArrayBuffer|Buffer|string|Blob} chunk
|
|
*/
|
|
send(chunk: string | ArrayBuffer | ArrayBufferView) {
|
|
if (this.destroying) {
|
|
return;
|
|
}
|
|
if (this.destroyed) {
|
|
throw errCode(
|
|
new Error('cannot send after peer is destroyed'),
|
|
'ERR_DESTROYED',
|
|
);
|
|
}
|
|
this.channel?.send(chunk);
|
|
}
|
|
|
|
/**
|
|
* Add a Transceiver to the connection.
|
|
* @param {String} kind
|
|
* @param {Object} init
|
|
*/
|
|
addTransceiver(kind: 'audio'|'video'|MediaStreamTrack, init: any) {
|
|
if (this.destroying) {
|
|
return;
|
|
}
|
|
if (this.destroyed) {
|
|
throw errCode(
|
|
new Error('cannot addTransceiver after peer is destroyed'),
|
|
'ERR_DESTROYED',
|
|
);
|
|
}
|
|
|
|
try {
|
|
this.pc?.addTransceiver(kind, init);
|
|
this.needsNegotiation();
|
|
} catch (err) {
|
|
this.destroy(errCode(err as Error, 'ERR_ADD_TRANSCEIVER'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a MediaStream to the connection.
|
|
* @param {MediaStream} s
|
|
*/
|
|
addStream(s: MediaStream) {
|
|
if (this.destroying) {
|
|
return;
|
|
}
|
|
if (this.destroyed) {
|
|
throw errCode(
|
|
new Error('cannot addStream after peer is destroyed'),
|
|
'ERR_DESTROYED',
|
|
);
|
|
}
|
|
|
|
s.getTracks().forEach((track: MediaStreamTrack) => {
|
|
this.addTrack(track, s);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Add a MediaStreamTrack to the connection.
|
|
* @param {MediaStreamTrack} track
|
|
* @param {MediaStream} s
|
|
*/
|
|
async addTrack(track: MediaStreamTrack, s: MediaStream) {
|
|
if (this.destroying) {
|
|
return;
|
|
}
|
|
if (this.destroyed || !this.pc) {
|
|
throw errCode(
|
|
new Error('cannot addTrack after peer is destroyed'),
|
|
'ERR_DESTROYED',
|
|
);
|
|
}
|
|
|
|
const submap = this.senderMap.get(track) || new Map(); // nested Maps map [track, stream] to sender
|
|
const sender = submap.get(s);
|
|
if (!sender) {
|
|
const transceiver = await this.pc.addTransceiver(track, {direction: 'sendrecv'}) as any;
|
|
/* eslint-disable no-underscore-dangle */
|
|
submap.set(s, transceiver._sender);
|
|
this.senderMap.set(track, submap);
|
|
this.needsNegotiation();
|
|
} else if (sender.removed) {
|
|
throw errCode(
|
|
new Error(
|
|
'Track has been removed. You should enable/disable tracks that you want to re-add.',
|
|
),
|
|
'ERR_SENDER_REMOVED',
|
|
);
|
|
} else {
|
|
throw errCode(
|
|
new Error('Track has already been added to that stream.'),
|
|
'ERR_SENDER_ALREADY_ADDED',
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace a MediaStreamTrack by another in the connection.
|
|
* @param {MediaStreamTrack} oldTrack
|
|
* @param {MediaStreamTrack} newTrack
|
|
* @param {MediaStream} stream
|
|
*/
|
|
replaceTrack(oldTrack: MediaStreamTrack, newTrack: MediaStreamTrack | null, s: MediaStream) {
|
|
if (this.destroying) {
|
|
return;
|
|
}
|
|
if (this.destroyed) {
|
|
throw errCode(new Error('cannot replaceTrack after peer is destroyed'), 'ERR_DESTROYED');
|
|
}
|
|
|
|
const submap = this.senderMap.get(oldTrack);
|
|
const sender = submap ? submap.get(s) : null;
|
|
if (!sender) {
|
|
throw errCode(new Error('Cannot replace track that was never added.'), 'ERR_TRACK_NOT_ADDED');
|
|
}
|
|
if (newTrack) {
|
|
this.senderMap.set(newTrack, submap);
|
|
}
|
|
|
|
if (sender.replaceTrack == null) {
|
|
this.destroy(errCode(new Error('replaceTrack is not supported in this browser'), 'ERR_UNSUPPORTED_REPLACETRACK'));
|
|
} else {
|
|
sender.replaceTrack(newTrack);
|
|
}
|
|
}
|
|
|
|
needsNegotiation() {
|
|
if (this.batchedNegotiation) {
|
|
return;
|
|
} // batch synchronous renegotiations
|
|
this.batchedNegotiation = true;
|
|
queueMicrotask(() => {
|
|
this.batchedNegotiation = false;
|
|
this.negotiate();
|
|
});
|
|
}
|
|
|
|
negotiate() {
|
|
if (this.destroying) {
|
|
return;
|
|
}
|
|
if (this.destroyed) {
|
|
throw errCode(
|
|
new Error('cannot negotiate after peer is destroyed'),
|
|
'ERR_DESTROYED',
|
|
);
|
|
}
|
|
|
|
if (this.isNegotiating) {
|
|
this.queuedNegotiation = true;
|
|
} else {
|
|
setTimeout(() => {
|
|
// HACK: Chrome crashes if we immediately call createOffer
|
|
this.createOffer();
|
|
}, 0);
|
|
}
|
|
this.isNegotiating = true;
|
|
}
|
|
|
|
destroy(err?: Error, cb?: (error: Error | null) => void, cbPCClose?: () => void): this {
|
|
this._destroy(err, cb, cbPCClose);
|
|
return this;
|
|
}
|
|
|
|
_destroy(err?: Error | null, cb?: (error: Error | null) => void, cbPcClose?: () => void) {
|
|
if (this.destroyed || this.destroying) {
|
|
return;
|
|
}
|
|
this.destroying = true;
|
|
|
|
setTimeout(() => {
|
|
// allow events concurrent with the call to _destroy() to fire (see #692)
|
|
this.destroyed = true;
|
|
this.destroying = false;
|
|
|
|
this.readable = false;
|
|
this.writable = false;
|
|
|
|
// if (!this._readableState?.ended) this.push(null);
|
|
// if (!this._writableState?.finished) this.end();
|
|
|
|
this.isConnected = false;
|
|
this.pcReady = false;
|
|
this.channelReady = false;
|
|
this.remoteTracks = [];
|
|
this.remoteStreams = [];
|
|
this.senderMap = new Map();
|
|
|
|
if (this.closingInterval) {
|
|
clearInterval(this.closingInterval);
|
|
}
|
|
this.closingInterval = null;
|
|
|
|
if (this.interval) {
|
|
clearInterval(this.interval);
|
|
}
|
|
this.interval = null;
|
|
this.chunk = null;
|
|
this.cb = null;
|
|
|
|
if (this.onFinishBound) {
|
|
this.removeListener('finish', this.onFinishBound);
|
|
}
|
|
this.onFinishBound = undefined;
|
|
|
|
if (this.channel) {
|
|
try {
|
|
this.channel.close();
|
|
} catch (err) {} // eslint-disable-line
|
|
|
|
// allow events concurrent with destruction to be handled
|
|
this.channel.onmessage = undefined;
|
|
this.channel.onopen = undefined;
|
|
this.channel.onclose = undefined;
|
|
this.channel.onerror = undefined;
|
|
}
|
|
if (this.pc) {
|
|
try {
|
|
this.pc.close(cbPcClose);
|
|
} catch (err) {} // eslint-disable-line
|
|
|
|
// allow events concurrent with destruction to be handled
|
|
this.pc.oniceconnectionstatechange = () => undefined;
|
|
this.pc.onicegatheringstatechange = () => undefined;
|
|
this.pc.onsignalingstatechange = () => undefined;
|
|
this.pc.onicecandidate = () => undefined;
|
|
}
|
|
this.pc = null;
|
|
this.channel = null;
|
|
|
|
if (err) {
|
|
this.emit('error', err);
|
|
}
|
|
this.emit('close');
|
|
cb?.(null);
|
|
}, 0);
|
|
}
|
|
|
|
setupData(channel: RTCDataChannel) {
|
|
if (!channel) {
|
|
// In some situations `pc.createDataChannel()` returns `undefined` (in wrtc),
|
|
// which is invalid behavior. Handle it gracefully.
|
|
// See: https://github.com/feross/simple-peer/issues/163
|
|
this.destroy(
|
|
errCode(
|
|
new Error(
|
|
'Data channel is missing `channel` property',
|
|
),
|
|
'ERR_DATA_CHANNEL',
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.channel = channel;
|
|
this.channel.binaryType = 'arraybuffer';
|
|
|
|
if (typeof this.channel.bufferedAmountLowThreshold === 'number') {
|
|
this.channel.bufferedAmountLowThreshold = MAX_BUFFERED_AMOUNT;
|
|
}
|
|
|
|
this.channelName = this.channel.label;
|
|
|
|
this.channel.onmessage = (e: MessageEvent) => {
|
|
this.onChannelMessage(e);
|
|
};
|
|
this.channel.onbufferedamountlow = () => {
|
|
this.onChannelBufferedAmountLow();
|
|
};
|
|
this.channel.onopen = () => {
|
|
this.onChannelOpen();
|
|
};
|
|
this.channel.onclose = () => {
|
|
this.onChannelClose();
|
|
};
|
|
this.channel.onerror = (e: any) => {
|
|
const err =
|
|
e.error instanceof Error ? e.error : new Error(
|
|
`Datachannel error: ${e.message} ${e.filename}:${e.lineno}:${e.colno}`,
|
|
);
|
|
this.destroy(errCode(err, 'ERR_DATA_CHANNEL'));
|
|
};
|
|
|
|
// HACK: Chrome will sometimes get stuck in readyState "closing", let's check for this condition
|
|
// https://bugs.chromium.org/p/chromium/issues/detail?id=882743
|
|
let isClosing = false;
|
|
this.closingInterval = setInterval(() => {
|
|
// No "onclosing" event
|
|
if (this.channel && this.channel.readyState === 'closing') {
|
|
if (isClosing) {
|
|
this.onChannelClose();
|
|
} // closing timed out: equivalent to onclose firing
|
|
isClosing = true;
|
|
} else {
|
|
isClosing = false;
|
|
}
|
|
}, CHANNEL_CLOSING_TIMEOUT);
|
|
}
|
|
|
|
_read() {
|
|
return null;
|
|
}
|
|
|
|
_write(chunk: any, encoding: string, cb: (error?: Error | null) => void): void {
|
|
if (this.destroyed) {
|
|
cb(
|
|
errCode(
|
|
new Error('cannot write after peer is destroyed'),
|
|
'ERR_DATA_CHANNEL',
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (this.isConnected) {
|
|
try {
|
|
this.send(chunk);
|
|
} catch (err) {
|
|
this.destroy(errCode(err as Error, 'ERR_DATA_CHANNEL'));
|
|
return;
|
|
}
|
|
if (this.channel?.bufferedAmount && this.channel?.bufferedAmount > MAX_BUFFERED_AMOUNT) {
|
|
this.cb = cb;
|
|
} else {
|
|
cb(null);
|
|
}
|
|
} else {
|
|
this.chunk = chunk;
|
|
this.cb = cb;
|
|
}
|
|
}
|
|
|
|
// When stream finishes writing, close socket. Half open connections are not
|
|
// supported.
|
|
onFinish() {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
// Wait a bit before destroying so the socket flushes.
|
|
const destroySoon = () => {
|
|
setTimeout(() => this.destroy(), 1000);
|
|
};
|
|
|
|
if (this.isConnected) {
|
|
destroySoon();
|
|
} else {
|
|
this.once('connect', destroySoon);
|
|
}
|
|
}
|
|
|
|
startIceCompleteTimeout() {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
if (this.iceCompleteTimer) {
|
|
return;
|
|
}
|
|
this.iceCompleteTimer = setTimeout(() => {
|
|
if (!this.iceComplete) {
|
|
this.iceComplete = true;
|
|
this.emit('iceTimeout');
|
|
this.emit('iceComplete');
|
|
}
|
|
}, ICECOMPLETE_TIMEOUT);
|
|
}
|
|
|
|
createOffer() {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
this.pc?.
|
|
createOffer({}).
|
|
then((offer: RTCSessionDescriptionType) => {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
const sendOffer = () => {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
const signal = this.pc?.localDescription || offer;
|
|
this.emit('signal', {
|
|
type: signal.type,
|
|
sdp: signal.sdp,
|
|
});
|
|
};
|
|
|
|
const onSuccess = () => {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
sendOffer();
|
|
};
|
|
|
|
const onError = (err: Error) => {
|
|
this.destroy(errCode(err, 'ERR_SET_LOCAL_DESCRIPTION'));
|
|
};
|
|
|
|
this.pc?.
|
|
setLocalDescription(offer).
|
|
then(onSuccess).
|
|
catch(onError);
|
|
}).
|
|
catch((err: Error) => {
|
|
this.destroy(errCode(err, 'ERR_CREATE_OFFER'));
|
|
});
|
|
}
|
|
|
|
createAnswer() {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
this.pc?.
|
|
createAnswer({}).
|
|
then((answer: RTCSessionDescriptionType) => {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
const sendAnswer = () => {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
const signal = this.pc?.localDescription || answer;
|
|
this.emit('signal', {
|
|
type: signal.type,
|
|
sdp: signal.sdp,
|
|
});
|
|
};
|
|
|
|
const onSuccess = () => {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
sendAnswer();
|
|
};
|
|
|
|
const onError = (err: Error) => {
|
|
this.destroy(errCode(err, 'ERR_SET_LOCAL_DESCRIPTION'));
|
|
};
|
|
|
|
this.pc?.
|
|
setLocalDescription(answer).
|
|
then(onSuccess).
|
|
catch(onError);
|
|
}).
|
|
catch((err: Error) => {
|
|
this.destroy(errCode(err, 'ERR_CREATE_ANSWER'));
|
|
});
|
|
}
|
|
|
|
onConnectionStateChange() {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
if (this.pc?.connectionState === 'failed') {
|
|
this.destroy(
|
|
errCode(
|
|
new Error('Connection failed.'),
|
|
'ERR_CONNECTION_FAILURE',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
onIceStateChange() {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
const iceConnectionState = this.pc?.iceConnectionState;
|
|
const iceGatheringState = this.pc?.iceGatheringState;
|
|
|
|
this.emit('iceStateChange', iceConnectionState, iceGatheringState);
|
|
|
|
if (
|
|
iceConnectionState === 'connected' ||
|
|
iceConnectionState === 'completed'
|
|
) {
|
|
this.pcReady = true;
|
|
this.maybeReady();
|
|
}
|
|
if (iceConnectionState === 'failed') {
|
|
this.destroy(
|
|
errCode(
|
|
new Error('Ice connection failed.'),
|
|
'ERR_ICE_CONNECTION_FAILURE',
|
|
),
|
|
);
|
|
}
|
|
if (iceConnectionState === 'closed') {
|
|
this.destroy(
|
|
errCode(
|
|
new Error('Ice connection closed.'),
|
|
'ERR_ICE_CONNECTION_CLOSED',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
getStats(cb: (error: Error|null, reports?: any) => void) {
|
|
// statreports can come with a value array instead of properties
|
|
const flattenValues = (report: any) => {
|
|
if (
|
|
Object.prototype.toString.call(report.values) ===
|
|
'[object Array]'
|
|
) {
|
|
report.values.forEach((value: any) => {
|
|
Object.assign(report, value);
|
|
});
|
|
}
|
|
return report;
|
|
};
|
|
|
|
this.pc?.getStats().then(
|
|
(res: any) => {
|
|
const reports: any[] = [];
|
|
res.forEach((report: any) => {
|
|
reports.push(flattenValues(report));
|
|
});
|
|
cb(null, reports);
|
|
},
|
|
(err: Error) => cb(err),
|
|
);
|
|
}
|
|
|
|
maybeReady() {
|
|
if (
|
|
this.isConnected ||
|
|
this.connecting ||
|
|
!this.pcReady ||
|
|
!this.channelReady
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.connecting = true;
|
|
|
|
// HACK: We can't rely on order here, for details see https://github.com/js-platform/node-webrtc/issues/339
|
|
const findCandidatePair = () => {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
this.getStats((err, itemsParam) => {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
let items = itemsParam;
|
|
|
|
// Treat getStats error as non-fatal. It's not essential.
|
|
if (err) {
|
|
items = [];
|
|
}
|
|
|
|
const remoteCandidates: {[key: string]: any} = {};
|
|
const localCandidates: {[key: string]: any} = {};
|
|
const candidatePairs: {[key: string]: any} = {};
|
|
let foundSelectedCandidatePair = false;
|
|
|
|
items.forEach((item: any) => {
|
|
if (
|
|
item.type === 'remotecandidate' ||
|
|
item.type === 'remote-candidate'
|
|
) {
|
|
remoteCandidates[item.id] = item;
|
|
}
|
|
if (
|
|
item.type === 'localcandidate' ||
|
|
item.type === 'local-candidate'
|
|
) {
|
|
localCandidates[item.id] = item;
|
|
}
|
|
if (
|
|
item.type === 'candidatepair' ||
|
|
item.type === 'candidate-pair'
|
|
) {
|
|
candidatePairs[item.id] = item;
|
|
}
|
|
});
|
|
|
|
items.forEach((item: any) => {
|
|
if (
|
|
(item.type === 'transport' &&
|
|
item.selectedCandidatePairId) ||
|
|
(item.type === 'googCandidatePair' &&
|
|
item.googActiveConnection === 'true') ||
|
|
((item.type === 'candidatepair' ||
|
|
item.type === 'candidate-pair') &&
|
|
item.selected)
|
|
) {
|
|
foundSelectedCandidatePair = true;
|
|
}
|
|
});
|
|
|
|
// Ignore candidate pair selection in browsers like Safari 11 that do not have any local or remote candidates
|
|
// But wait until at least 1 candidate pair is available
|
|
if (
|
|
!foundSelectedCandidatePair &&
|
|
(!Object.keys(candidatePairs).length ||
|
|
Object.keys(localCandidates).length)
|
|
) {
|
|
setTimeout(findCandidatePair, 100);
|
|
return;
|
|
}
|
|
this.connecting = false;
|
|
this.isConnected = true;
|
|
|
|
if (this.chunk) {
|
|
try {
|
|
this.send(this.chunk);
|
|
} catch (err2) {
|
|
this.destroy(errCode(err2 as Error, 'ERR_DATA_CHANNEL'));
|
|
return;
|
|
}
|
|
this.chunk = null;
|
|
|
|
const cb = this.cb;
|
|
this.cb = null;
|
|
if (cb) {
|
|
cb(null);
|
|
}
|
|
}
|
|
|
|
// If `bufferedAmountLowThreshold` and 'onbufferedamountlow' are unsupported,
|
|
// fallback to using setInterval to implement backpressure.
|
|
if (
|
|
typeof this.channel?.bufferedAmountLowThreshold !== 'number'
|
|
) {
|
|
this.interval = setInterval(() => this.onInterval(), 150);
|
|
if (this.interval.unref) {
|
|
this.interval.unref();
|
|
}
|
|
}
|
|
|
|
this.emit('connect');
|
|
});
|
|
};
|
|
findCandidatePair();
|
|
}
|
|
|
|
onInterval() {
|
|
if (
|
|
!this.cb ||
|
|
!this.channel ||
|
|
this.channel.bufferedAmount > MAX_BUFFERED_AMOUNT
|
|
) {
|
|
return;
|
|
}
|
|
this.onChannelBufferedAmountLow();
|
|
}
|
|
|
|
onSignalingStateChange() {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
if (this.pc?.signalingState === 'stable') {
|
|
this.isNegotiating = false;
|
|
|
|
// HACK: Firefox doesn't yet support removing tracks when signalingState !== 'stable'
|
|
this.sendersAwaitingStable.forEach((sender) => {
|
|
this.pc?.removeTrack(sender);
|
|
this.queuedNegotiation = true;
|
|
});
|
|
this.sendersAwaitingStable = [];
|
|
|
|
if (this.queuedNegotiation) {
|
|
this.queuedNegotiation = false;
|
|
this.needsNegotiation(); // negotiate again
|
|
} else {
|
|
this.emit('negotiated');
|
|
}
|
|
}
|
|
|
|
this.emit('signalingStateChange', this.pc?.signalingState);
|
|
}
|
|
|
|
onIceCandidate(event: EventOnCandidate) {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
if (event.candidate) {
|
|
this.emit('signal', {
|
|
type: 'candidate',
|
|
candidate: {
|
|
candidate: event.candidate.candidate,
|
|
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
|
sdpMid: event.candidate.sdpMid,
|
|
},
|
|
});
|
|
} else if (!event.candidate && !this.iceComplete) {
|
|
this.iceComplete = true;
|
|
this.emit('iceComplete');
|
|
}
|
|
|
|
// as soon as we've received one valid candidate start timeout
|
|
if (event.candidate) {
|
|
this.startIceCompleteTimeout();
|
|
}
|
|
}
|
|
|
|
onChannelMessage(event: MessageEvent) {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
let data = event.data;
|
|
if (data instanceof ArrayBuffer) {
|
|
data = Buffer.from(data);
|
|
}
|
|
this.push(data);
|
|
}
|
|
|
|
onChannelBufferedAmountLow() {
|
|
if (this.destroyed || !this.cb) {
|
|
return;
|
|
}
|
|
const cb = this.cb;
|
|
this.cb = null;
|
|
cb(null);
|
|
}
|
|
|
|
onChannelOpen() {
|
|
if (this.isConnected || this.destroyed) {
|
|
return;
|
|
}
|
|
this.channelReady = true;
|
|
this.maybeReady();
|
|
}
|
|
|
|
onChannelClose() {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
this.destroy();
|
|
}
|
|
|
|
onStream(event: EventOnAddStream) {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
event.target._remoteStreams.forEach((eventStream: MediaStream) => { // eslint-disable-line
|
|
eventStream._tracks.forEach((eventTrack: MediaStreamTrack) => { // eslint-disable-line
|
|
if (
|
|
this.remoteTracks.some((remoteTrack: MediaStreamTrack) => { // eslint-disable-line
|
|
return remoteTrack.id === eventTrack.id;
|
|
})
|
|
) {
|
|
return;
|
|
} // Only fire one 'stream' event, even though there may be multiple tracks per stream
|
|
|
|
if (event.track) {
|
|
this.remoteTracks.push(event.track);
|
|
this.emit('track', eventTrack, eventStream);
|
|
}
|
|
});
|
|
|
|
if (
|
|
this.remoteStreams.some((remoteStream) => {
|
|
return remoteStream.id === eventStream.id;
|
|
})
|
|
) {
|
|
return;
|
|
} // Only fire one 'stream' event, even though there may be multiple tracks per stream
|
|
|
|
this.remoteStreams.push(eventStream);
|
|
queueMicrotask(() => {
|
|
this.emit('stream', eventStream); // ensure all tracks have been added
|
|
});
|
|
});
|
|
}
|
|
}
|