diff --git a/lib/account.js b/lib/account.js index e839ec0..90445fa 100644 --- a/lib/account.js +++ b/lib/account.js @@ -1,635 +1,640 @@ 'use strict'; import debug from 'debug'; import md5 from 'blueimp-md5'; import transform from 'sdp-transform'; import utils from './utils'; import { EventEmitter } from 'events'; import { Call } from './call'; import { ConferenceCall } from './conference'; import { Message } from './message'; import { PGP } from './pgp'; const DEBUG = debug('sylkrtc:Account'); class Account extends EventEmitter { constructor(options, connection) { if (options.account.indexOf('@') === -1) { throw new Error('Invalid account id specified'); } super(); const id = options.account; const [username, domain] = id.split('@'); this._id = id; this._displayName = options.displayName; if (options.hasOwnProperty('ha1') && !options.ha1) { this._password = options.password; } else { this._password = md5(username + ':' + (options.realm || domain) + ':' + options.password); } + this._incomingHeaderPrefixes = options.incomingHeaderPrefixes this._pgp = null; this._connection = connection; this._registrationState = null; this._calls = new Map(); this._confCalls = new Map(); this._messages = new Map(); this._pendingMessageDecryption = new Set(); this._delayedDispositionMessages = new Map(); } get id() { return this._id; } get password() { return this._password; } get displayName() { return this._displayName; } + get incomingHeaderPrefixes() { + return this._incomingHeaderPrefixes; + } + get registrationState() { return this._registrationState; } get messages() { return Array.from(this._messages.values()); } get pgp() { return this._pgp; } register() { const req = { sylkrtc: 'account-register', account: this._id }; this._sendRequest(req, (error) => { if (error) { DEBUG('Register error: %s', error); const oldState = this._registrationState; const newState = 'failed'; const data = {reason: error.toString()}; this._registrationState = newState; this.emit('registrationStateChanged', oldState, newState, data); } }); } unregister() { const req = { sylkrtc: 'account-unregister', account: this._id }; this._sendRequest(req, (error) => { if (error) { DEBUG('Unregister error: %s', error); } const oldState = this._registrationState; const newState = null; this._registrationState = newState; this.emit('registrationStateChanged', oldState, newState, {}); }); } call(uri, options={}) { const callObj = new Call(this); callObj._initOutgoing(uri, options); this._calls.set(callObj.id, callObj); this.emit('outgoingCall', callObj); return callObj; } joinConference(uri, options={}) { const confCall = new ConferenceCall(this); confCall._initialize(uri, options); this._confCalls.set(confCall.id, confCall); this.emit('conferenceCall', confCall); return confCall; } setDeviceToken(token, platform, device, silent, app) { DEBUG('Setting device token: %s', token); const req = { sylkrtc: 'account-devicetoken', account: this._id, token, platform, device, silent, app }; this._sendRequest(req, (error) => { if (error) { DEBUG('Error setting device token: %s', error); } }); } addPGPKeys(keys) { this._pgp = new PGP(keys, this._connection); // Wipe received messages, they could be encrypted. After this you should always fetch from the server.... if (this._messages.size > 0) { this._messages.clear(); } } generatePGPKeys(cb=null) { if (this._pgp === null) { this._pgp = new PGP({}, this._connection); } this._pgp.generatePGPKeys((result) => { this.emit('privateKeysGenerated', result); cb(result); }); } checkIfKeyExists(cb=null) { this._connection.lookupPublicKey(this._id); new Promise((resolve, reject) => { this._connection.once('publicKey', (message) => { if (message.publicKey) { message.publicKey = message.publicKey.trim(); } resolve(message.publicKey); }); }).then(publicKey => cb(publicKey)); } decryptKeyImport(message, password, cb=null) { if (this._pgp === null) { this._pgp = new PGP({}, this._connection); } this._pgp.decryptKeyImport(message, password, (result) => { if (!this._pgp._privateKey && !this._pgp_publicKey) { this._pgp = null; } cb(result); }); } exportPrivateKey(password) { if (this._pgp === null) { return; } this._pgp.exportKeys(password).then(result => { if (result.didEncrypt) { this.sendMessage(this._id, result.message, 'text/pgp-private-key'); } }); } sendMessage(uri, message, contentType='text/plain', options={}, cb=null) { const outgoingMessage = new Message({ account: uri, content: message, contentType, timestamp: options.timestamp || new Date().toISOString(), type: 'normal' }, new utils.Identity(this._id, this._displayName), 'pending'); if (contentType !== 'text/pgp-private-key' && contentType !== 'text/pgp-public-key') { this._messages.set(outgoingMessage.id, outgoingMessage); } (async() => { let result = {}; if (this._pgp !== null && contentType !== 'text/pgp-private-key' && contentType !== 'text/pgp-public-key') { result = await this._pgp.encryptMessage(uri, outgoingMessage); if (result.didEncrypt) { outgoingMessage._isSecure = true; } } const req = { sylkrtc: 'account-message', account: this._id, uri: uri, message_id: outgoingMessage.id, content: result.message || message, content_type: outgoingMessage.contentType, timestamp: outgoingMessage.timestamp }; if (contentType !== 'text/pgp-private-key' && contentType !== 'text/pgp-public-key') { this.emit('sendingMessage', outgoingMessage); } DEBUG('Sending message: %o', outgoingMessage); this._sendRequest(req, (error) => { if (error) { DEBUG('Error sending message: %s', error); outgoingMessage._setState('failed'); } if (cb) { cb(error); } }); })(); return outgoingMessage; } sendDispositionNotification(uri, id, timestamp, state, cb=null) { const req = { sylkrtc: 'account-disposition-notification', account: this._id, uri: uri, message_id: id, state, timestamp }; DEBUG('Sending disposition notification: %o', req); this._sendRequest(req, (error) => { if (error) { DEBUG('Error sending disposition notification: %s', error); } else { const incomingMessage = this._messages.get(id); if (incomingMessage) { incomingMessage._setDispositionState(state); } } this.emit('sendingDispositionNotification', id, state, error); if (cb) { cb(error); } }); } syncConversations(id=null, cb=null) { const req = { sylkrtc: 'account-sync-conversations', account: this._id, message_id: id }; DEBUG('Sending replay journal: %o', req); this._sendRequest(req, (error) => { if (error) { DEBUG('Error sending sync request: %s', error); } if (cb) { cb(error); } }); } markConversationRead(contact) { const req = { sylkrtc: 'account-mark-conversation-read', account: this._id, contact: contact }; DEBUG('Sending markConversationRead: %o', req); this._sendRequest(req, (error) => { if (error) { DEBUG('Error sending markConversationRead request: %s', error); } }); this._readConversation(contact); } removeMessage(message, cb=null) { this._removeMessage(message.id); let contact = message.receiver; if (message.state === 'received') { contact = message.sender.uri; } const req = { sylkrtc: 'account-remove-message', account: this._id, message_id: message.id, contact: contact }; DEBUG('Sending remove message: %o', req); this._sendRequest(req, (error) => { if (error) { DEBUG('Error sending remove message: %s', error); } if (cb) { cb(error); } }); } removeConversation(uri, cb=null) { this._removeConversation(uri); const req = { sylkrtc: 'account-remove-conversation', account: this._id, contact: uri }; DEBUG('Sending remove conversation: %o', req); this._sendRequest(req, (error) => { if (error) { DEBUG('Error sending remove conversation: %s', error); } if (cb) { cb(error); } }); } // Private API _handleEvent(message) { DEBUG('Received account event: %s', message.event); const data = {}; switch (message.event) { case 'registration-state': const oldState = this._registrationState; const newState = message.state; this._registrationState = newState; if (newState === 'failed') { data.reason = message.reason; } this.emit('registrationStateChanged', oldState, newState, data); break; case 'incoming-session': let call = new Call(this); - call._initIncoming(message.session, message.originator, message.sdp, message.call_id); + call._initIncoming(message.session, message.originator, message.sdp, message.call_id, message.headers); this._calls.set(call.id, call); // see what media types are offered const mediaTypes = { audio: false, video: false }; const parsedSdp = transform.parse(message.sdp); for (let media of parsedSdp.media) { if (media.type === 'audio' && media.port !== 0 && media.direction === 'sendrecv') { mediaTypes.audio = true; } else if (media.type === 'video' && media.port !== 0 && media.direction === 'sendrecv') { mediaTypes.video = true; } } DEBUG('Incoming call from %s with media types: %o', message.originator.uri, mediaTypes); this.emit('incomingCall', call, mediaTypes); break; case 'missed-session': data.originator = new utils.Identity(message.originator.uri, message.originator.display_name); this.emit('missedCall', data); break; case 'conference-invite': data.originator = new utils.Identity(message.originator.uri, message.originator.display_name); data.room = message.room; data.id = message.session_id; this.emit('conferenceInvite', data); break; case 'message': DEBUG('Incoming message from %s: %o', message.sender.uri, message); const incomingMessage = this._messages.get(message.message_id); if (!incomingMessage) { (async() => { if (message.content.includes(`-----BEGIN PGP MESSAGE-----`) && message.content.trim().endsWith(`-----END PGP MESSAGE-----`) && message.content_type !== 'text/pgp-private-key' ) { if (this._pgp !== null) { message = await this._pgp.decryptMessage(message); if (message.didDecrypt) { message.isSecure = true; } else { this._sendError(message); // Decryption failed, add failure disposition return; } } } if (message.content_type === 'text/pgp-private-key') { DEBUG('Skipping message'); return; } if (message.content_type === 'application/sylk-contact-update') { DEBUG('Skipping message'); return; } const mappedMessage = new Message( message, new utils.Identity(message.sender.uri, message.sender.display_name), 'received' ); if (message.content_type === 'text/pgp-public-key') { if (this._pgp !== null) { this._pgp.addPublicPGPKeys({[message.sender.uri]: mappedMessage.content}); return; } } this._messages.set(mappedMessage.id, mappedMessage); this.emit('incomingMessage', mappedMessage); if (message.disposition_notification && message.disposition_notification.indexOf('positive-delivery') !== -1 ) { this.sendDispositionNotification( message.sender.uri, message.message_id, message.timestamp, 'delivered' ); } })(); } break; case 'disposition-notification': const outgoingMessage = this._messages.get(message.message_id); if (outgoingMessage) { if (outgoingMessage.state === 'displayed') { break; } outgoingMessage._setState(message.state); } // Delay state changes if message is being decrypted if (this._pendingMessageDecryption.has(message.message_id)) { const delayedMessage = this._delayedDispositionMessages.get(message.message_id) || []; delayedMessage.push(message); this._delayedDispositionMessages.set(message.message_id, delayedMessage); } else { const {reason, code} = message; this.emit('messageStateChanged', message.message_id, message.state, {reason, code}); } break; case 'sync-conversations': const specialContentTypes = new Set(['application/sylk-message-remove', 'message/imdn']); this.emit('processingFetchedMessages'); (async() => { if (this._pgp !== null) { let progress = 1; const items = message.messages.length; if (items >= 75) { this.emit('processingFetchedMessages', progress); } for (const [idx, messageEntry] of message.messages.entries()) { if (messageEntry.content.includes(`-----BEGIN PGP MESSAGE-----`) && messageEntry.content.trim().endsWith(`-----END PGP MESSAGE-----`) ) { await this._pgp.decryptMessage(messageEntry); } if (items >= 100) { const tempProgress = Math.round((100 / items) * idx); if (tempProgress !== progress && tempProgress % 5 === 0) { progress = tempProgress; this.emit('processingFetchedMessages', progress); } } } } const messageList = message.messages.filter((message) => { if (message.content_type === 'text/pgp-public-key') { DEBUG('Public key found, adding: %s', message.content); if (this._pgp !== null) { this._pgp.addPublicPGPKeys({[message.sender.uri]: message.content}); } return false; } if (message.didDecrypt === false) { this._sendError(message); // send disposition error return false; } if (message.content_type === 'application/sylk-contact-update') { return false; } return true; }).map((message) => { if (specialContentTypes.has(message.content_type)) { message.content = JSON.parse(message.content); } message.isSecure = message.didDecrypt; if (message.direction === 'outgoing') { message.account = message.contact; return new Message(message, new utils.Identity(this._id, this._displayName), message.state); } message.account = this._id; return new Message(message, new utils.Identity(message.contact, ''), message.state); }); this.emit('syncConversations', messageList); })(); break; case 'sync': if (message.type === 'message') { let content = message.content; switch (message.action) { case 'remove': const existingMessage = this._messages.get(content.message_id); if (existingMessage) { this.emit('removeMessage', existingMessage); this._removeMessage(message.content.message_id); } else { content.account = content.contact; this.emit('removeMessage', new Message(content, new utils.Identity(content.contact, ''), '')); } break; case 'add': content.account = content.uri; (async() => { if (content.content.includes(`-----BEGIN PGP MESSAGE-----`) && content.content.trim().endsWith(`-----END PGP MESSAGE-----`) && content.content_type !== 'text/pgp-private-key' ) { if (this._pgp !== null) { this._pendingMessageDecryption.add(content.message_id); content = await this._pgp.decryptMessage(content); this._pendingMessageDecryption.delete(content.message_id); if (content.didDecrypt) { content.isSecure = true; } else { return; } } } const outgoingMessage = new Message( content, new utils.Identity(this._id, this._displayName), content.account == this._id ? 'accepted' : 'pending' ); if ( content.content_type === 'text/pgp-public-key' || content.content_type === 'application/sylk-contact-update') { return; } if (content.content_type !== 'text/pgp-private-key') { this._messages.set(outgoingMessage.id, outgoingMessage); this.emit('sendingMessage', outgoingMessage); } this.emit('outgoingMessage', outgoingMessage); const delayedMessages = this._delayedDispositionMessages.get(outgoingMessage.id); if (delayedMessages) { setImmediate(() => { while(delayedMessages.length) { const delayedMessage = delayedMessages.shift(); this._handleEvent(delayedMessage); } this._delayedDispositionMessages.delete(outgoingMessage.id); }); } })(); break; default: break; } } if (message.type === 'conversation') { switch(message.action) { case 'remove': this._removeConversation(message.content.contact); this.emit('removeConversation', message.content.contact); break; case 'read': this._readConversation(message.content.contact); this.emit('readConversation', message.content.contact); break; default: break; } } break; default: break; } } _sendError(message) { const disposition = message.disposition_notification || message.disposition || false; if (disposition && disposition.indexOf('display') !== -1) { this.sendDispositionNotification( message.sender ? message.sender.uri : message.contact, message.message_id, message.timestamp, 'error' ); } } _removeMessage(id) { this._messages.delete(id); } _readConversation(uri) { for (const [id, message] of this._messages.entries()) { if (message.state === 'received' && message.sender.uri === uri && message.dispositionState !== 'displayed') { message._setDispositionState('displayed'); } } } _removeConversation(uri) { for (const [id, message] of this._messages.entries()) { if (message.state === 'received' && message.sender.uri === uri) { this._messages.delete(id); } else if (message.receiver === uri) { this._messages.delete(id); } } } _sendRequest(req, cb) { this._connection._sendRequest(req, cb); } } export { Account }; diff --git a/lib/call.js b/lib/call.js index 9c1a698..2da991e 100644 --- a/lib/call.js +++ b/lib/call.js @@ -1,525 +1,533 @@ 'use strict'; import debug from 'debug'; import { v4 as uuidv4 } from 'uuid'; import { EventEmitter } from 'events'; import { Statistics } from './statistics'; import utils from './utils'; const DEBUG = debug('sylkrtc:Call'); class Call extends EventEmitter { constructor(account) { super(); this._account = account; this._id = null; this._callId = null; this._direction = null; this._pc = null; this._state = null; this._terminated = false; this._incomingSdp = null; this._remoteMediaDirections = {}; this._localIdentity = new utils.Identity(account.id, account.displayName); this._remoteIdentity = null; this._remoteStreams = new MediaStream(); this._localStreams = new MediaStream(); this._previousTrack = null; this._sharingScreen = false; this._dtmfSender = null; this._delay_established = false; // set to true when we need to delay posting the state change to 'established' this._setup_in_progress = false; // set while we set the remote description and setup the peer copnnection this._statistics = new Statistics(); + this._headers = [] // bind some handlers to this instance this._onDtmf = this._onDtmf.bind(this); } get account() { return this._account; } get id() { return this._id; } get callId() { return this._callId; } + get headers() { + return this._headers; + } + get statistics() { return this._statistics; } get sharingScreen() { return this._sharingScreen; } get direction() { return this._direction; } get state() { return this._state; } get localIdentity() { return this._localIdentity; } get remoteIdentity() { return this._remoteIdentity; } get remoteMediaDirections() { return this._remoteMediaDirections; } getLocalStreams() { if (this._pc !== null) { if (this._pc.getSenders) { this._pc.getSenders().forEach((e) => { if (e.track != null) { if (e.track.readyState !== "ended") { this._localStreams.addTrack(e.track); } else { this._localStreams.removeTrack(e.track); } } }); return [this._localStreams]; } else { return this._pc.getLocalStreams(); } } else { return []; } } getRemoteStreams() { if (this._pc !== null) { if (this._pc.getReceivers) { this._pc.getReceivers().forEach((e) => { if (e.track.readyState !== "ended") { this._remoteStreams.addTrack(e.track); } }); return [this._remoteStreams]; } else { return this._pc.getRemoteStreams(); } } else { return []; } } getSenders() { if (this._pc !== null) { return this._pc.getSenders(); } else { return []; } } getReceivers() { if (this._pc !== null) { return this._pc.getReceivers(); } else { return []; } } answer(options = {}) { if (this._state !== 'incoming') { throw new Error('Call is not in the incoming state: ' + this._state); } if (!options.localStream) { throw new Error('Missing localStream'); } const pcConfig = options.pcConfig || {iceServers:[]}; const answerOptions = options.answerOptions; // Create the RTCPeerConnection this._initRTCPeerConnection(pcConfig); this._pc.addStream(options.localStream); this.emit('localStreamAdded', options.localStream); this._pc.setRemoteDescription(new RTCSessionDescription({type: 'offer', sdp: this._incomingSdp})) // success .then(() => { utils.createLocalSdp(this._pc, 'answer', answerOptions) .then((sdp) => { DEBUG('Local SDP: %s', sdp); this._sendAnswer(sdp); }) .catch((reason) => { DEBUG(reason); this.terminate(); }); }) // failure .catch((error) => { DEBUG('Error setting remote description: %s', error); this.terminate(); }); } startScreensharing(newTrack) { let oldTrack = this.getLocalStreams()[0].getVideoTracks()[0]; this.replaceTrack(oldTrack, newTrack, true, (value) => { this._sharingScreen = value; }); } stopScreensharing() { let oldTrack = this.getLocalStreams()[0].getVideoTracks()[0]; this.replaceTrack(oldTrack, this._previousTrack); this._sharingScreen = false; } replaceTrack(oldTrack, newTrack, keep=false, cb=null) { let sender; for (sender of this._pc.getSenders()) { if (sender.track === oldTrack) { break; } } sender.replaceTrack(newTrack) .then(() => { if (keep) { this._previousTrack = oldTrack; } else { if (oldTrack) { oldTrack.stop(); } if (newTrack === this._previousTrack) { this._previousTrack = null; } } if (oldTrack) { this._localStreams.removeTrack(oldTrack); } this._localStreams.addTrack(newTrack); if (cb) { cb(true); } }).catch((error)=> { DEBUG('Error replacing track: %s', error); }); } terminate() { if (this._terminated) { return; } DEBUG('Terminating call'); this._sendTerminate(); } sendDtmf(tones, duration=100, interToneGap=70) { DEBUG('sendDtmf()'); if (this._dtmfSender === null) { if (this._pc !== null) { let track = null; try { track = this._pc.getLocalStreams()[0].getAudioTracks()[0]; } catch (e) { // ignore } if (track !== null) { DEBUG('Creating DTMF sender'); if (this._pc.getSenders) { this._dtmfSender = this._pc.getSenders()[0].dtmf; } else { DEBUG("Your browser doesn't support RTCPeerConnection.getSenders(), so " + "falling back to use deprecated createDTMFSender() " + "instead."); this._dtmfSender = this._pc.createDTMFSender(track); } if (this._dtmfSender) { this._dtmfSender.addEventListener('tonechange', this._onDtmf); } } } } if (this._dtmfSender) { DEBUG('Sending DTMF tones'); this._dtmfSender.insertDTMF(tones, duration, interToneGap); } } // Private API _initOutgoing(uri, options={}) { if (uri.indexOf('@') === -1) { throw new Error('Invalid URI'); } if (!options.localStream) { throw new Error('Missing localStream'); } this._id = options.id || uuidv4(); this._direction = 'outgoing'; this._remoteIdentity = new utils.Identity(uri); + this._headers = options.headers; const pcConfig = options.pcConfig || {iceServers:[]}; const offerOptions = options.offerOptions; // Create the RTCPeerConnection this._initRTCPeerConnection(pcConfig); this._pc.addStream(options.localStream); this.emit('localStreamAdded', options.localStream); utils.createLocalSdp(this._pc, 'offer', offerOptions) .then((sdp) => { DEBUG('Local SDP: %s', sdp); this._sendCall(uri, sdp); }) .catch((reason) => { DEBUG(reason); this._localTerminate(reason); }); } - _initIncoming(id, caller, sdp, callId) { + _initIncoming(id, caller, sdp, callId, headers = {}) { this._id = id; this._remoteIdentity = new utils.Identity(caller.uri, caller.display_name); this._incomingSdp = sdp; this._direction = 'incoming'; this._state = 'incoming'; this._callId = callId; this._remoteMediaDirections = Object.assign( {audio: [], video:[]}, utils.getMediaDirections(sdp) ); + this._headers = Object.keys(headers).map(key => {return {name: key, value: headers[key]}}); DEBUG('Remote SDP: %s', sdp); } _handleEvent(message) { DEBUG('Call event: %o', message); switch (message.event) { case 'state': let oldState = this._state; let newState = message.state; this._state = newState; if ((newState === 'accepted' || newState === 'early-media') && this._direction === 'outgoing') { DEBUG('Call accepted or early media'); this.emit('stateChanged', oldState, newState, {}); if (message.sdp !== undefined) { const sdp = utils.mungeSdp(message.sdp); DEBUG('Remote SDP: %s', sdp); this._remoteMediaDirections = Object.assign( {audio: [], video:[]}, utils.getMediaDirections(sdp) ); this._setup_in_progress = true; this._callId = message.call_id; this._pc.setRemoteDescription(new RTCSessionDescription({type: 'answer', sdp: sdp})) // success .then(() => { this._setup_in_progress = false; if (!this._terminated) { if (this._delay_established) { oldState = this._state; this._state = 'established'; DEBUG('Setting delayed established state!'); this.emit('stateChanged', oldState, this._state, {}); this._delay_established = false; } } }) // failure .catch((error) => { DEBUG('Error accepting call or early media: %s', error); this.terminate(); }); } } else if (newState === 'established' && this._direction === 'outgoing') { if (this._setup_in_progress) { this._delay_established = true; } else { this.emit('stateChanged', oldState, newState, {}); } } else if (newState === 'terminated') { this.emit('stateChanged', oldState, newState, {reason: message.reason}); this._terminated = true; this._account._calls.delete(this.id); this._closeRTCPeerConnection(); } else { this.emit('stateChanged', oldState, newState, {}); } break; default: break; } } _initRTCPeerConnection(pcConfig) { if (this._pc !== null) { throw new Error('RTCPeerConnection already initialized'); } this._pc = new RTCPeerConnection(pcConfig); this._pc.addEventListener('addstream', (event) => { DEBUG('Stream added'); this.emit('streamAdded', event.stream); }); this._pc.addEventListener('icecandidate', (event) => { if (event.candidate !== null) { DEBUG('New ICE candidate %o', event.candidate); } else { DEBUG('ICE candidate gathering finished'); } this._sendTrickle(event.candidate); }); this._statistics.addConnection({pc:this._pc, peerId: this._id}); } _sendRequest(req, cb) { this._account._sendRequest(req, cb); } _sendCall(uri, sdp) { const req = { sylkrtc: 'session-create', account: this.account.id, session: this.id, uri: uri, - sdp: sdp + sdp: sdp, + headers: this.headers }; this._sendRequest(req, (error) => { if (error) { DEBUG('Call error: %s', error); this._localTerminate(error); } }); } _sendTerminate() { const req = { sylkrtc: 'session-terminate', session: this.id }; this._sendRequest(req, (error) => { if (error) { DEBUG('Error terminating call: %s', error); this._localTerminate(error); } }); setTimeout(() => { if (!this._terminated) { DEBUG('Timeout terminating call'); this._localTerminate('200 OK'); } this._terminated = true; }, 150); } _sendTrickle(candidate) { const req = { sylkrtc: 'session-trickle', session: this.id, candidates: candidate !== null ? [candidate] : [], }; this._sendRequest(req, (error) => { if (error) { DEBUG('Trickle error: %s', error); this._localTerminate(error); } }); } _sendAnswer(sdp) { const req = { sylkrtc: 'session-answer', session: this.id, sdp: sdp }; this._sendRequest(req, (error) => { if (error) { DEBUG('Answer error: %s', error); this.terminate(); } }); } _closeRTCPeerConnection() { DEBUG('Closing RTCPeerConnection'); if (this._pc !== null) { let tempStream; if (this._pc.getSenders) { let tracks = []; for (let track of this._pc.getSenders()) { if (track.track != null ) { tracks = tracks.concat(track.track); } if (this._previousTrack !== null) { tracks = tracks.concat(this._previousTrack); } } if (tracks.length !== 0) { tempStream = new MediaStream(tracks); utils.closeMediaStream(tempStream); } } else { for (let stream of this._pc.getLocalStreams()) { if (this._previousTrack !== null) { stream = stream.concat(this._previousTrack); } utils.closeMediaStream(stream); } } if (this._pc.getReceivers) { let tracks = []; for (let track of this._pc.getReceivers()) { tracks = tracks.concat(track.track); } tempStream = new MediaStream(tracks); utils.closeMediaStream(tempStream); } else { for (let stream of this._pc.getRemoteStreams()) { utils.closeMediaStream(stream); } } this._statistics.removeConnection({pc:this._pc, peerId: this._id}); this._pc.close(); this._pc = null; if (this._dtmfSender !== null) { this._dtmfSender.removeEventListener('tonechange', this._onDtmf); this._dtmfSender = null; } } } _localTerminate(error) { if (this._terminated) { return; } DEBUG('Local terminate'); this._account._calls.delete(this.id); this._terminated = true; const oldState = this._state; const newState = 'terminated'; const data = { reason: error.toString() }; this._closeRTCPeerConnection(); this.emit('stateChanged', oldState, newState, data); } _onDtmf(event) { DEBUG('Sent DTMF tone %s', event.tone); this.emit('dtmfToneSent', event.tone); } } export { Call }; diff --git a/lib/connection.js b/lib/connection.js index f896976..bdb6c96 100644 --- a/lib/connection.js +++ b/lib/connection.js @@ -1,366 +1,367 @@ '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 + user_agent: USER_AGENT, + incoming_header_prefixes: acc.incomingHeaderPrefixes }; 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); } }); } lookupPublicKey(uri) { const req = { sylkrtc: 'lookup-public-key', uri: uri }; this._sendRequest(req, (error) => { if (error) { DEBUG('lookup public key error: %s', 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 === 'lookup-public-key-event') { this.emit('publicKey', {publicKey: message.public_key, uri: message.uri}); } 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 };