/** * This file is part of Threema Web. * * Threema Web is free software: you can redistribute it and/or modify it * under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or (at * your option) any later version. * * This program is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero * General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Threema Web. If not, see . */ /// import * as SDPUtils from 'sdp'; /** * Wrapper around the WebRTC PeerConnection. * * TODO: Convert to regular service? */ export class PeerConnectionHelper { private logTag: string = '[PeerConnectionHelper]'; // Angular services private $log: ng.ILogService; private $q: ng.IQService; private $timeout: ng.ITimeoutService; private $rootScope: ng.IRootScopeService; // WebRTC private pc: RTCPeerConnection; private webrtcTask: saltyrtc.tasks.webrtc.WebRTCTask; // Calculated connection state public connectionState: threema.RTCConnectionState = 'new'; public onConnectionStateChange: (state: threema.RTCConnectionState) => void = null; // Internal callback when connection closes private onConnectionClosed: () => void = null; // Debugging private censorCandidates: boolean; constructor($log: ng.ILogService, $q: ng.IQService, $timeout: ng.ITimeoutService, $rootScope: ng.IRootScopeService, webrtcTask: saltyrtc.tasks.webrtc.WebRTCTask, iceServers: RTCIceServer[], censorCandidates: boolean = true) { this.$log = $log; this.$log.info(this.logTag, 'Initialize WebRTC PeerConnection'); this.$log.debug(this.logTag, 'ICE servers used:', [].concat(...iceServers.map((c) => c.urls)).join(', ')); this.$q = $q; this.$timeout = $timeout; this.$rootScope = $rootScope; this.webrtcTask = webrtcTask; this.censorCandidates = censorCandidates; // Set up peer connection this.pc = new RTCPeerConnection({iceServers: iceServers}); this.pc.onnegotiationneeded = (e: Event) => { this.$log.debug(this.logTag, 'RTCPeerConnection: negotiation needed'); this.initiatorFlow().then( (_) => this.$log.debug(this.logTag, 'Initiator flow done'), ); }; // Handle state changes this.pc.onconnectionstatechange = (e: Event) => { $log.debug(this.logTag, 'Connection state change:', this.pc.connectionState); }; this.pc.onsignalingstatechange = (e: Event) => { $log.debug(this.logTag, 'Signaling state change:', this.pc.signalingState); }; // Set up ICE candidate handling this.setupIceCandidateHandling(); // Log incoming data channels this.pc.ondatachannel = (e: RTCDataChannelEvent) => { $log.debug(this.logTag, 'New data channel was created:', e.channel.label); }; } /** * Return the wrapped RTCPeerConnection instance. */ public get peerConnection(): RTCPeerConnection { return this.pc; } /** * Set up receiving / sending of ICE candidates. */ private setupIceCandidateHandling() { this.$log.debug(this.logTag, 'Setting up ICE candidate handling'); this.pc.onicecandidate = (e: RTCPeerConnectionIceEvent) => { if (e.candidate) { this.$log.debug(this.logTag, 'Gathered local ICE candidate:', this.censorCandidate(e.candidate.candidate)); this.webrtcTask.sendCandidate({ candidate: e.candidate.candidate, sdpMid: e.candidate.sdpMid, sdpMLineIndex: e.candidate.sdpMLineIndex, }); } else { this.$log.debug(this.logTag, 'No more local ICE candidates'); } }; this.pc.onicecandidateerror = (e: RTCPeerConnectionIceErrorEvent) => { this.$log.error(this.logTag, 'ICE candidate error:', e); }; this.pc.oniceconnectionstatechange = (e: Event) => { this.$log.debug(this.logTag, 'ICE connection state change:', this.pc.iceConnectionState); this.$rootScope.$apply(() => { switch (this.pc.iceConnectionState) { case 'new': this.setConnectionState('new'); break; case 'checking': case 'disconnected': this.setConnectionState('connecting'); break; case 'connected': case 'completed': this.setConnectionState('connected'); break; case 'failed': case 'closed': this.setConnectionState('disconnected'); break; default: this.$log.warn(this.logTag, 'Ignored ICE connection state change to', this.pc.iceConnectionState); } }); }; this.pc.onicegatheringstatechange = (e: Event) => { this.$log.debug(this.logTag, 'ICE gathering state change:', this.pc.iceGatheringState); }; this.webrtcTask.on('candidates', (e: saltyrtc.tasks.webrtc.CandidatesEvent) => { for (let candidateInit of e.data) { if (candidateInit) { this.$log.debug(this.logTag, 'Adding remote ICE candidate:', this.censorCandidate(candidateInit.candidate)); } else { this.$log.debug(this.logTag, 'No more remote ICE candidates'); } this.pc.addIceCandidate(candidateInit); } }); } private async initiatorFlow(): Promise { // Send offer let offer: RTCSessionDescriptionInit = await this.pc.createOffer(); await this.pc.setLocalDescription(offer); this.$log.debug(this.logTag, 'Created offer, set local description'); this.webrtcTask.sendOffer(offer); // Receive answer let receiveAnswer: () => Promise = () => { return new Promise((resolve) => { this.webrtcTask.once('answer', (e: saltyrtc.tasks.webrtc.AnswerEvent) => { resolve(e.data); }); }); }; let answer: RTCSessionDescriptionInit = await receiveAnswer(); await this.pc.setRemoteDescription(answer); this.$log.debug(this.logTag, 'Received answer, set remote description'); } /** * Create a new secure data channel. */ public createSecureDataChannel(label: string, onopenHandler?): saltyrtc.tasks.webrtc.SecureDataChannel { const dc: RTCDataChannel = this.pc.createDataChannel(label); dc.binaryType = 'arraybuffer'; const sdc: saltyrtc.tasks.webrtc.SecureDataChannel = this.webrtcTask.wrapDataChannel(dc); if (onopenHandler !== undefined) { sdc.onopen = onopenHandler; } return sdc; } /** * Set the connection state and update listeners. */ private setConnectionState(state: threema.RTCConnectionState) { if (state !== this.connectionState) { this.connectionState = state; if (this.onConnectionStateChange !== null) { this.$timeout(() => this.onConnectionStateChange(state), 0); } if (this.onConnectionClosed !== null && state === 'disconnected') { this.$timeout(() => this.onConnectionClosed(), 0); } } } /** * Close the peer connection. * * Return a promise that resolves once the connection is actually closed. */ public close(): ng.IPromise<{}> { return this.$q((resolve, reject) => { const signalingClosed = this.pc.signalingState as string === 'closed'; // Legacy const connectionClosed = this.pc.connectionState === 'closed'; if (!signalingClosed && !connectionClosed) { // If connection state is not yet "disconnected", register a callback // for the disconnect event. if (this.connectionState !== 'disconnected') { // Disconnect timeout let timeout: ng.IPromise; // Handle connection closed event this.onConnectionClosed = () => { this.$timeout.cancel(timeout); this.onConnectionClosed = null; resolve(); }; // Launch timeout timeout = this.$timeout(() => { this.onConnectionClosed = null; reject('Timeout'); }, 2000); } // Close connection setTimeout(() => { this.pc.close(); }, 0); // If connection state is already "disconnected", resolve immediately. if (this.connectionState === 'disconnected') { resolve(); } } else { resolve(); } }); } /** * Censor an ICE candidate's address and port (unless censoring is disabled). * * Return the censored ICE candidate. */ private censorCandidate(candidateInit: string): string { let candidate = SDPUtils.parseCandidate(candidateInit); if (this.censorCandidates) { if (candidate.type !== 'relay') { candidate.ip = '***'; candidate.port = 1; } candidate.relatedAddress = '***'; candidate.relatedPort = 2; } return SDPUtils.writeCandidate(candidate); } }