/** * 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'; import TaskConnectionState = threema.TaskConnectionState; /** * Wrapper around the WebRTC PeerConnection. */ 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: TaskConnectionState = TaskConnectionState.New; public onConnectionStateChange: (state: TaskConnectionState) => 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(TaskConnectionState.New); break; case 'checking': case 'disconnected': this.setConnectionState(TaskConnectionState.Connecting); break; case 'connected': case 'completed': this.setConnectionState(TaskConnectionState.Connected); break; case 'failed': case 'closed': this.setConnectionState(TaskConnectionState.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 (const 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 const 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 const receiveAnswer: () => Promise = () => { return new Promise((resolve) => { this.webrtcTask.once('answer', (e: saltyrtc.tasks.webrtc.AnswerEvent) => { resolve(e.data); }); }); }; const 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): saltyrtc.tasks.webrtc.SecureDataChannel { const dc: RTCDataChannel = this.pc.createDataChannel(label); dc.binaryType = 'arraybuffer'; return this.webrtcTask.wrapDataChannel(dc); } /** * Set the connection state and update listeners. */ private setConnectionState(state: TaskConnectionState) { if (state !== this.connectionState) { this.connectionState = state; if (this.onConnectionStateChange !== null) { this.$timeout(() => this.onConnectionStateChange(state), 0); } } } /** * Unbind all event handler and abruptly close the peer connection. */ public close(): void { this.webrtcTask.off(); this.pc.onnegotiationneeded = null; this.pc.onconnectionstatechange = null; this.pc.onsignalingstatechange = null; this.pc.onicecandidate = null; this.pc.onicecandidateerror = null; this.pc.oniceconnectionstatechange = null; this.pc.onicegatheringstatechange = null; this.pc.ondatachannel = null; this.pc.close(); } /** * Censor an ICE candidate's address and port (unless censoring is disabled). * * Return the censored ICE candidate. */ private censorCandidate(candidateInit: string): string { const 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); } }