diff --git a/lib/connection.js b/lib/connection.js index 8c20350..a5a237d 100644 --- a/lib/connection.js +++ b/lib/connection.js @@ -1,347 +1,352 @@ 'use strict'; import bowser from 'bowser'; import debug from 'debug'; import { v4 as uuidv4 } from 'uuid'; import { EventEmitter } from 'events'; import { setImmediate } from 'timers'; import { w3cwebsocket as W3CWebSocket } from 'websocket'; import { Account } from './account'; +import utils from './utils'; const SYLKRTC_PROTO = 'sylkRTC-2'; const DEBUG = debug('sylkrtc:Connection'); const MSECS = 1000; const INITIAL_DELAY = 0.5 * MSECS; const MAX_DELAY = 16 * MSECS; // compute a string for our well-known platforms const browserData = bowser.parse(window.navigator.userAgent); let platform; platform = browserData.os.name; if (browserData.os.version) { platform = `${platform} ${browserData.os.version}`; } if (browserData.platform.type !== 'desktop') { if (browserData.platform.vendor) { platform = `${platform} ${browserData.platform.vendor}`; } if (browserData.platform.model) { platform = `${platform} ${browserData.platform.model}`; } } let browser = browserData.browser.name; if (browserData.browser.version) { browser = `${browser} ${browserData.browser.version}`; } let USER_AGENT = `SylkRTC (${browser} on ${platform})`; +if (browserData.browser.name === 'Electron' && browserData.browser.version.startsWith('3.1')) { + DEBUG('Removing allowExtmap from window'); + utils.removeAllowExtmapMixed(); +} class Connection extends EventEmitter { constructor(options = {}) { if (!options.server) { throw new Error('"server" must be specified'); } super(); this._wsUri = options.server; this._sock = null; this._state = null; this._closed = false; this._timer = null; this._delay = INITIAL_DELAY; this._accounts = new Map(); this._requests = new Map(); if (options.userAgent) { let userAgent = options.userAgent.name && options.userAgent.name !== '' ? options.userAgent.name : 'Unknown'; if (options.userAgent.version) { userAgent = `${userAgent} ${options.userAgent.version}`; } USER_AGENT = `${USER_AGENT} - ${userAgent}`; } } get state() { return this._state; } close() { if (this._closed) { return; } this._closed = true; if (this._timer) { clearTimeout(this._timer); this._timer = null; } if (this._sock) { this._sock.close(); this._sock = null; } else { setImmediate(() => { this._setState('closed'); }); } } addAccount(options = {}, cb = null) { if (typeof options.account !== 'string' || typeof options.password !== 'string') { throw new Error('Invalid options, "account" and "password" must be supplied'); } if (this._accounts.has(options.account)) { throw new Error('Account already added'); } const acc = new Account(options, this); // add it early to the set so we don't add it more than once, ever this._accounts.set(acc.id, acc); const req = { sylkrtc: 'account-add', account: acc.id, password: acc.password, display_name: acc.displayName, user_agent: USER_AGENT }; this._sendRequest(req, (error) => { if (error) { DEBUG('add_account error: %s', error); this._accounts.delete(acc.id); } if (cb) { cb(error, error ? null : acc); } }); } removeAccount(account, cb=null) { const acc = this._accounts.get(account.id); if (account !== acc) { throw new Error('Unknown account'); } // delete the account from the mapping, regardless of the result this._accounts.delete(account.id); const req = { sylkrtc: 'account-remove', account: acc.id }; this._sendRequest(req, (error) => { if (error) { DEBUG('remove_account error: %s', error); } if (cb) { cb(error); } }); } reconnect() { if (this._state === 'disconnected') { clearTimeout(this._timer); this._delay = INITIAL_DELAY; this._timer = setTimeout(() => { this._connect(); }, this._delay); } } // Private API _initialize() { if (this._sock !== null) { throw new Error('WebSocket already initialized'); } if (this._timer !== null) { throw new Error('Initialize is in progress'); } DEBUG('Initializing'); if (process.browser) { window.addEventListener('beforeunload', () => { if (this._sock !== null) { const noop = function() {}; this._sock.onerror = noop; this._sock.onmessage = noop; this._sock.onclose = noop; this._sock.close(); } }); } this._timer = setTimeout(() => { this._connect(); }, this._delay); } _connect() { DEBUG('WebSocket connecting'); this._setState('connecting'); this._sock = new W3CWebSocket(this._wsUri, SYLKRTC_PROTO); this._sock.onopen = () => { DEBUG('WebSocket connection open'); this._onOpen(); }; this._sock.onerror = () => { DEBUG('WebSocket connection got error'); }; this._sock.onclose = (event) => { DEBUG('WebSocket connection closed: %d: (reason="%s", clean=%s)', event.code, event.reason, event.wasClean); this._onClose(); }; this._sock.onmessage = (event) => { DEBUG('WebSocket received message: %o', event); this._onMessage(event); }; } _sendRequest(req, cb) { const transaction = uuidv4(); req.transaction = transaction; if (this._state !== 'ready') { setImmediate(() => { cb(new Error('Connection is not ready')); }); return; } this._requests.set(transaction, {req: req, cb: cb}); this._sock.send(JSON.stringify(req)); } _setState(newState) { DEBUG('Set state: %s -> %s', this._state, newState); const oldState = this._state; this._state = newState; this.emit('stateChanged', oldState, newState); } // WebSocket callbacks _onOpen() { clearTimeout(this._timer); this._timer = null; this._delay = INITIAL_DELAY; this._setState('connected'); this._missedPings = 0; this._pingInterval = setInterval(() => { const req = { sylkrtc: 'ping', }; this._sendRequest(req, (error) => { if (error) { DEBUG('Error sending ping: %s', error); } }); this._missedPings = this._missedPings + 1; if (this._missedPings >= 6) { DEBUG('Disconnected, 6 pings are missed'); clearInterval(this._pingInterval); if (this._sock !== null) { const noop = function() {}; this._sock.onerror = noop; this._sock.onmessage = noop; this._sock.onclose = noop; this._sock.close(); } this._onClose(); } }, 5000); } _onClose() { this._sock = null; if (this._timer) { clearTimeout(this._timer); this._timer = null; } // remove all accounts, the server no longer has them anyway this._accounts.clear(); clearInterval(this._pingInterval); this._setState('disconnected'); if (!this._closed) { this._delay = this._delay * 2; if (this._delay > MAX_DELAY) { DEBUG('Connection retry timeout (%s/%s) reached, reset', this._delay / MSECS, MAX_DELAY); this._delay = INITIAL_DELAY; } DEBUG('Retrying connection in %s seconds', this._delay / MSECS); this._timer = setTimeout(() => { this._connect(); }, this._delay); } else { this._setState('closed'); } } _onMessage(event) { const message = JSON.parse(event.data); if (typeof message.sylkrtc === 'undefined') { DEBUG('Unrecognized message received'); return; } DEBUG('Received "%s" message: %o', message.sylkrtc, message); if (message.sylkrtc === 'ready-event') { DEBUG('Received ready-event'); this._setState('ready'); } else if (message.sylkrtc === 'account-event') { let acc = this._accounts.get(message.account); if (!acc) { DEBUG('Account %s not found', message.account); return; } acc._handleEvent(message); } else if (message.sylkrtc === 'session-event') { const sessionId = message.session; for (let acc of this._accounts.values()) { let call = acc._calls.get(sessionId); if (call) { call._handleEvent(message); break; } } } else if (message.sylkrtc === 'videoroom-event') { const confId = message.session; for (let acc of this._accounts.values()) { let confCall = acc._confCalls.get(confId); if (confCall) { confCall._handleEvent(message); break; } } } else if (message.sylkrtc === 'ack' || message.sylkrtc === 'error') { const transaction = message.transaction; const data = this._requests.get(transaction); if (!data) { DEBUG('Could not find transaction %s', transaction); return; } this._requests.delete(transaction); DEBUG('Received "%s" for request: %o', message.sylkrtc, data.req); if (data.req.sylkrtc === 'ping') { this._missedPings = 0; } if (data.cb) { if (message.sylkrtc === 'ack') { data.cb(null); } else { data.cb(new Error(message.error)); } } } } } export { Connection }; diff --git a/lib/utils.js b/lib/utils.js index f510dc5..fc7f682 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,178 +1,195 @@ 'use strict'; import transform from 'sdp-transform'; import attachMediaStream from '@rifflearning/attachmediastream'; import DOMPurify from 'dompurify'; class Identity { constructor(uri, displayName=null) { this._uri = uri; this._displayName = displayName; } get uri() { return this._uri; } get displayName() { return this._displayName; } toString() { if (!this._displayName) { return this._uri; } else { return `${this._displayName} <${this._uri}>`; } } } class SharedFile { constructor(filename, filesize, uploader, session) { this._filename = filename; this._filesize = filesize; this._uploader = uploader; this._session = session; } get filename() { return this._filename; } get filesize() { return this._filesize; } get uploader() { return this._uploader; } get session() { return this._session; } } function createLocalSdp(pc, type, options) { if (type !== 'offer' && type !== 'answer') { return Promise.reject('type must be "offer" or "answer", but "' +type+ '" was given'); } let p = new Promise(function(resolve, reject) { let createFunc; if (type === 'offer' ) { createFunc = 'createOffer'; } else { createFunc = 'createAnswer'; } pc[createFunc](options) .then((desc) => { return pc.setLocalDescription(desc); }) .then(() => { resolve(mungeSdp(pc.localDescription.sdp)); }) // failure .catch((error) => { reject('Error creating local SDP or setting local description: ' + error); }); }); return p; } function mungeSdp(sdp, fixmsid=false) { let parsedSdp = transform.parse(sdp); let h264payload = null; let hasProfileLevelId = false; // try to fix H264 support for (let media of parsedSdp.media) { if (media.type === 'video') { for (let rtp of media.rtp) { if (rtp.codec === 'H264') { h264payload = rtp.payload; break; } } if (h264payload !== null) { for (let fmtp of media.fmtp) { if (fmtp.payload === h264payload && fmtp.config.indexOf('profile-level-id') !== -1) { hasProfileLevelId = true; break; } } if (!hasProfileLevelId) { media.fmtp.push({payload: h264payload, config: 'profile-level-id=42e01f;packetization-mode=1;level-asymmetry-allowed=1'}); } break; } } } if (fixmsid === true) { const randomNumber = Math.floor(100000 + Math.random() * 900000); for (let media of parsedSdp.media) { media.msid = media.msid + '-' + randomNumber; for(let ssrc of media.ssrcs) { if (ssrc.attribute === 'msid') { ssrc.value = ssrc.value + '-' + randomNumber; } } } } // remove bogus rtcp-fb elements for (let media of parsedSdp.media) { let payloads = String(media.payloads).split(' '); if (media.rtcpFb) { media.rtcpFb = media.rtcpFb.filter((item) => { return payloads.indexOf(String(item.payload)) !== -1; }); } } return transform.write(parsedSdp); } function getMediaDirections(sdp) { const parsedSdp = transform.parse(sdp); const directions = {}; for (let media of parsedSdp.media) { directions[media.type] = (directions[media.type] || []).concat(media.direction); } return directions; } function closeMediaStream(stream) { if (!stream) { return; } // Latest spec states that MediaStream has no stop() method and instead must // call stop() on every MediaStreamTrack. if (MediaStreamTrack && MediaStreamTrack.prototype && MediaStreamTrack.prototype.stop) { if (stream.getTracks) { for (let track of stream.getTracks()) { track.stop(); } } else { for (let track of stream.getAudioTracks()) { track.stop(); } for (let track of stream.getVideoTracks()) { track.stop(); } } // Deprecated by the spec, but still in use. } else if (typeof stream.stop === 'function') { stream.stop(); } } function sanatizeHtml(html) { return DOMPurify.sanitize(html.trim()); } -export default { Identity, SharedFile, createLocalSdp, mungeSdp, getMediaDirections, attachMediaStream, closeMediaStream, sanatizeHtml}; + +export function removeAllowExtmapMixed() { + /* remove a=extmap-allow-mixed for Chrome < M71 */ + if (!window.RTCPeerConnection) { + return; + } + const nativeSRD = window.RTCPeerConnection.prototype.setRemoteDescription; + window.RTCPeerConnection.prototype.setRemoteDescription = function(desc) { + if (desc && desc.sdp && desc.sdp.indexOf('\na=extmap-allow-mixed') !== -1) { + desc.sdp = desc.sdp.split('\n').filter((line) => { + return line.trim() !== 'a=extmap-allow-mixed'; + }).join('\n'); + } + return nativeSRD.apply(this, arguments); + }; +} + +export default { Identity, SharedFile, createLocalSdp, mungeSdp, getMediaDirections, attachMediaStream, closeMediaStream, sanatizeHtml, removeAllowExtmapMixed};