peerconnection.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. /**
  2. * This file is part of Threema Web.
  3. *
  4. * Threema Web is free software: you can redistribute it and/or modify it
  5. * under the terms of the GNU Affero General Public License as published by
  6. * the Free Software Foundation, either version 3 of the License, or (at
  7. * your option) any later version.
  8. *
  9. * This program is distributed in the hope that it will be useful, but
  10. * WITHOUT ANY WARRANTY; without even the implied warranty of
  11. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
  12. * General Public License for more details.
  13. *
  14. * You should have received a copy of the GNU Affero General Public License
  15. * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  16. */
  17. /// <reference types="@saltyrtc/task-webrtc" />
  18. import * as SDPUtils from 'sdp';
  19. /**
  20. * Wrapper around the WebRTC PeerConnection.
  21. *
  22. * TODO: Convert to regular service?
  23. */
  24. export class PeerConnectionHelper {
  25. private logTag: string = '[PeerConnectionHelper]';
  26. // Angular services
  27. private $log: ng.ILogService;
  28. private $q: ng.IQService;
  29. private $timeout: ng.ITimeoutService;
  30. private $rootScope: ng.IRootScopeService;
  31. // WebRTC
  32. private pc: RTCPeerConnection;
  33. private webrtcTask: saltyrtc.tasks.webrtc.WebRTCTask;
  34. // Calculated connection state
  35. public connectionState: threema.RTCConnectionState = 'new';
  36. public onConnectionStateChange: (state: threema.RTCConnectionState) => void = null;
  37. // Internal callback when connection closes
  38. private onConnectionClosed: () => void = null;
  39. // Debugging
  40. private censorCandidates: boolean;
  41. constructor($log: ng.ILogService, $q: ng.IQService,
  42. $timeout: ng.ITimeoutService, $rootScope: ng.IRootScopeService,
  43. webrtcTask: saltyrtc.tasks.webrtc.WebRTCTask,
  44. iceServers: RTCIceServer[],
  45. censorCandidates: boolean = true) {
  46. this.$log = $log;
  47. this.$log.info(this.logTag, 'Initialize WebRTC PeerConnection');
  48. this.$log.debug(this.logTag, 'ICE servers used:', [].concat(...iceServers.map((c) => c.urls)).join(', '));
  49. this.$q = $q;
  50. this.$timeout = $timeout;
  51. this.$rootScope = $rootScope;
  52. this.webrtcTask = webrtcTask;
  53. this.censorCandidates = censorCandidates;
  54. // Set up peer connection
  55. this.pc = new RTCPeerConnection({iceServers: iceServers});
  56. this.pc.onnegotiationneeded = (e: Event) => {
  57. this.$log.debug(this.logTag, 'RTCPeerConnection: negotiation needed');
  58. this.initiatorFlow().then(
  59. (_) => this.$log.debug(this.logTag, 'Initiator flow done'),
  60. );
  61. };
  62. // Handle state changes
  63. this.pc.onconnectionstatechange = (e: Event) => {
  64. $log.debug(this.logTag, 'Connection state change:', this.pc.connectionState);
  65. };
  66. this.pc.onsignalingstatechange = (e: Event) => {
  67. $log.debug(this.logTag, 'Signaling state change:', this.pc.signalingState);
  68. };
  69. // Set up ICE candidate handling
  70. this.setupIceCandidateHandling();
  71. // Log incoming data channels
  72. this.pc.ondatachannel = (e: RTCDataChannelEvent) => {
  73. $log.debug(this.logTag, 'New data channel was created:', e.channel.label);
  74. };
  75. }
  76. /**
  77. * Return the wrapped RTCPeerConnection instance.
  78. */
  79. public get peerConnection(): RTCPeerConnection {
  80. return this.pc;
  81. }
  82. /**
  83. * Set up receiving / sending of ICE candidates.
  84. */
  85. private setupIceCandidateHandling() {
  86. this.$log.debug(this.logTag, 'Setting up ICE candidate handling');
  87. this.pc.onicecandidate = (e: RTCPeerConnectionIceEvent) => {
  88. if (e.candidate) {
  89. this.$log.debug(this.logTag, 'Gathered local ICE candidate:',
  90. this.censorCandidate(e.candidate.candidate));
  91. this.webrtcTask.sendCandidate({
  92. candidate: e.candidate.candidate,
  93. sdpMid: e.candidate.sdpMid,
  94. sdpMLineIndex: e.candidate.sdpMLineIndex,
  95. });
  96. } else {
  97. this.$log.debug(this.logTag, 'No more local ICE candidates');
  98. }
  99. };
  100. this.pc.onicecandidateerror = (e: RTCPeerConnectionIceErrorEvent) => {
  101. this.$log.error(this.logTag, 'ICE candidate error:', e);
  102. };
  103. this.pc.oniceconnectionstatechange = (e: Event) => {
  104. this.$log.debug(this.logTag, 'ICE connection state change:', this.pc.iceConnectionState);
  105. this.$rootScope.$apply(() => {
  106. switch (this.pc.iceConnectionState) {
  107. case 'new':
  108. this.setConnectionState('new');
  109. break;
  110. case 'checking':
  111. case 'disconnected':
  112. this.setConnectionState('connecting');
  113. break;
  114. case 'connected':
  115. case 'completed':
  116. this.setConnectionState('connected');
  117. break;
  118. case 'failed':
  119. case 'closed':
  120. this.setConnectionState('disconnected');
  121. break;
  122. default:
  123. this.$log.warn(this.logTag, 'Ignored ICE connection state change to',
  124. this.pc.iceConnectionState);
  125. }
  126. });
  127. };
  128. this.pc.onicegatheringstatechange = (e: Event) => {
  129. this.$log.debug(this.logTag, 'ICE gathering state change:', this.pc.iceGatheringState);
  130. };
  131. this.webrtcTask.on('candidates', (e: saltyrtc.tasks.webrtc.CandidatesEvent) => {
  132. for (let candidateInit of e.data) {
  133. if (candidateInit) {
  134. this.$log.debug(this.logTag, 'Adding remote ICE candidate:',
  135. this.censorCandidate(candidateInit.candidate));
  136. } else {
  137. this.$log.debug(this.logTag, 'No more remote ICE candidates');
  138. }
  139. this.pc.addIceCandidate(candidateInit);
  140. }
  141. });
  142. }
  143. private async initiatorFlow(): Promise<void> {
  144. // Send offer
  145. let offer: RTCSessionDescriptionInit = await this.pc.createOffer();
  146. await this.pc.setLocalDescription(offer);
  147. this.$log.debug(this.logTag, 'Created offer, set local description');
  148. this.webrtcTask.sendOffer(offer);
  149. // Receive answer
  150. let receiveAnswer: () => Promise<saltyrtc.tasks.webrtc.Answer> = () => {
  151. return new Promise((resolve) => {
  152. this.webrtcTask.once('answer', (e: saltyrtc.tasks.webrtc.AnswerEvent) => {
  153. resolve(e.data);
  154. });
  155. });
  156. };
  157. let answer: RTCSessionDescriptionInit = await receiveAnswer();
  158. await this.pc.setRemoteDescription(answer);
  159. this.$log.debug(this.logTag, 'Received answer, set remote description');
  160. }
  161. /**
  162. * Create a new secure data channel.
  163. */
  164. public createSecureDataChannel(label: string, onopenHandler?): saltyrtc.tasks.webrtc.SecureDataChannel {
  165. const dc: RTCDataChannel = this.pc.createDataChannel(label);
  166. dc.binaryType = 'arraybuffer';
  167. const sdc: saltyrtc.tasks.webrtc.SecureDataChannel = this.webrtcTask.wrapDataChannel(dc);
  168. if (onopenHandler !== undefined) {
  169. sdc.onopen = onopenHandler;
  170. }
  171. return sdc;
  172. }
  173. /**
  174. * Set the connection state and update listeners.
  175. */
  176. private setConnectionState(state: threema.RTCConnectionState) {
  177. if (state !== this.connectionState) {
  178. this.connectionState = state;
  179. if (this.onConnectionStateChange !== null) {
  180. this.$timeout(() => this.onConnectionStateChange(state), 0);
  181. }
  182. if (this.onConnectionClosed !== null && state === 'disconnected') {
  183. this.$timeout(() => this.onConnectionClosed(), 0);
  184. }
  185. }
  186. }
  187. /**
  188. * Close the peer connection.
  189. *
  190. * Return a promise that resolves once the connection is actually closed.
  191. */
  192. public close(): ng.IPromise<{}> {
  193. return this.$q((resolve, reject) => {
  194. const signalingClosed = this.pc.signalingState as string === 'closed'; // Legacy
  195. const connectionClosed = this.pc.connectionState === 'closed';
  196. if (!signalingClosed && !connectionClosed) {
  197. // If connection state is not yet "disconnected", register a callback
  198. // for the disconnect event.
  199. if (this.connectionState !== 'disconnected') {
  200. // Disconnect timeout
  201. let timeout: ng.IPromise<any>;
  202. // Handle connection closed event
  203. this.onConnectionClosed = () => {
  204. this.$timeout.cancel(timeout);
  205. this.onConnectionClosed = null;
  206. resolve();
  207. };
  208. // Launch timeout
  209. timeout = this.$timeout(() => {
  210. this.onConnectionClosed = null;
  211. reject('Timeout');
  212. }, 2000);
  213. }
  214. // Close connection
  215. setTimeout(() => {
  216. this.pc.close();
  217. }, 0);
  218. // If connection state is already "disconnected", resolve immediately.
  219. if (this.connectionState === 'disconnected') {
  220. resolve();
  221. }
  222. } else {
  223. resolve();
  224. }
  225. });
  226. }
  227. /**
  228. * Censor an ICE candidate's address and port (unless censoring is disabled).
  229. *
  230. * Return the censored ICE candidate.
  231. */
  232. private censorCandidate(candidateInit: string): string {
  233. let candidate = SDPUtils.parseCandidate(candidateInit);
  234. if (this.censorCandidates) {
  235. if (candidate.type !== 'relay') {
  236. candidate.ip = '***';
  237. candidate.port = 1;
  238. }
  239. candidate.relatedAddress = '***';
  240. candidate.relatedPort = 2;
  241. }
  242. return SDPUtils.writeCandidate(candidate);
  243. }
  244. }