123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582 |
- /**
- * Create peer connection and bind events to be logged.
- * @param role The role (offerer or answerer).
- * @returns {RTCPeerConnection}
- */
- function createPeerConnection(role) {
- // Detect safari
- const uagent = window.navigator.userAgent.toLowerCase();
- // Determine ICE servers
- const iceServers = [
- 'turn:ds-turn-ff.threema.ch:443?transport=udp',
- 'turn:ds-turn-ff.threema.ch:443?transport=tcp',
- 'turns:ds-turn-ff.threema.ch:443',
- ];
- console.debug('Using ICE servers: ' + iceServers);
- const configuration = {iceServers: [{
- urls: iceServers,
- username: 'threema-angular-test',
- credential: 'VaoVnhxKGt2wD20F9bTOgiew6yHQmj4P7y7SE4lrahAjTQC0dpnG32FR4fnrlpKa',
- }]};
- // Create peer connection
- const pc = new RTCPeerConnection(configuration);
- pc.addEventListener('negotiationneeded', async () => {
- console.info(role, 'Negotiation needed');
- });
- pc.addEventListener('signalingstatechange', () => {
- console.debug(role, 'Signaling state:', pc.signalingState);
- });
- pc.addEventListener('iceconnectionstatechange', () => {
- console.debug(role, 'ICE connection state:', pc.iceConnectionState);
- });
- pc.addEventListener('icegatheringstatechange', () => {
- console.debug(role, 'ICE gathering state:', pc.iceGatheringState);
- });
- pc.addEventListener('connectionstatechange', () => {
- console.debug(role, 'Connection state:', pc.connectionState);
- });
- pc.addEventListener('icecandidate', (event) => {
- console.debug(role, 'ICE candidate:', event.candidate);
- });
- pc.addEventListener('icecandidateerror', (event) => {
- console.error(role, 'ICE candidate error:', event);
- });
- pc.addEventListener('datachannel', (event) => {
- console.info(role, 'Incoming data channel:', event.channel.label);
- });
- return pc;
- }
- /**
- * Create a data channel and bind events to be logged.
- * @param pc The peer connection instance.
- * @param role The role (offerer or answerer).
- * @param label The label of the data channel.
- * @param options The options passed to the RTCDataChannel instance.
- * @returns {RTCDataChannel}
- */
- function createDataChannel(pc, role, label, options) {
- // Create data channel and bind events
- const dc = pc.createDataChannel(label, options);
- dc.addEventListener('open', () => {
- console.info(role, label, 'open');
- });
- dc.addEventListener('close', () => {
- console.info(role, label, 'closed');
- });
- dc.addEventListener('error', () => {
- console.error(role, label, 'error:', error);
- });
- dc.addEventListener('bufferedamountlow', () => {
- console.debug(role, label, 'buffered amount low:', dc.bufferedAmount);
- });
- dc.addEventListener('message', (event) => {
- console.debug(role, label, `incoming message:`, event.data);
- });
- return dc;
- }
- /**
- * Connect the peer connection instances to each other.
- * @param offerer The offerer's peer connection instance.
- * @param answerer The answerer's peer connection instance.
- * @returns {Promise<void>} resolves once connected.
- */
- async function connectPeerConnections(offerer, answerer) {
- const pcs = [offerer, answerer];
- // Forward ICE candidates to each other
- for (const [me, other] of [pcs, pcs.slice().reverse()]) {
- me.addEventListener('icecandidate', (event) => {
- if (event.candidate !== null) {
- other.addIceCandidate(event.candidate);
- }
- });
- }
- // Promise for the peer connections being connected
- const [offererConnected, answererConnected] = pcs.map((pc) => {
- return new Promise((resolve, reject) => {
- pc.addEventListener('iceconnectionstatechange', () => {
- switch (pc.iceConnectionState) {
- case 'connected':
- case 'completed':
- resolve(pc.iceConnectionState);
- break;
- case 'closed':
- case 'failed':
- reject(pc.iceConnectionState);
- break;
- }
- });
- });
- });
- // Start the offer/answer dance
- const signalingDone = new Promise((resolve, reject) => {
- offerer.addEventListener('negotiationneeded', async () => {
- try {
- console.debug('Start signaling');
- const offer = await offerer.createOffer();
- await offerer.setLocalDescription(offer);
- await answerer.setRemoteDescription(offer);
- const answer = await answerer.createAnswer();
- await answerer.setLocalDescription(answer);
- await offerer.setRemoteDescription(answer);
- console.debug('Signaling complete');
- resolve();
- } catch (error) {
- reject(error);
- }
- });
- });
- // Wait until all is done
- await Promise.all([
- offererConnected,
- answererConnected,
- signalingDone,
- ]);
- }
- // Here beginneth the Angular stuff
- const app = angular.module('troubleshoot', ['ngSanitize']);
- app.filter('osName', function() {
- return function(id) {
- switch (id) {
- case 'android':
- return 'Android';
- case 'ios':
- return 'iOS';
- default:
- return '?';
- }
- }
- });
- app.component('check', {
- bindings: {
- result: '<',
- textNo: '@',
- },
- template: `
- <div class="status status-no" ng-if="$ctrl.result.state === 'no'">
- <i class="material-icons md-36" aria-label="No">error</i> <span class="text">No</span>
- <p class="small" ng-if="$ctrl.textNo" ng-bind-html="$ctrl.textNo"></p>
- </div>
- <div class="status status-yes" ng-if="$ctrl.result.state === 'yes'">
- <i class="material-icons md-36" aria-label="Yes">check_circle</i> <span class="text">Yes</span>
- </div>
- <div class="status status-unknown" ng-if="$ctrl.result.state === 'unknown'">
- <i class="material-icons md-36" aria-label="Unknown">help</i> <span class="text">Unknown</span>
- </div>
- <div class="status status-test" ng-if="$ctrl.result.state === 'loading'">
- <img src="loading.gif" alt="Loading..." aria-label="Loading">
- </div>
- <div class="logs" ng-if="$ctrl.result.showLogs">
- <p>Results:</p>
- <div class="log-data">
- <p ng-repeat="log in $ctrl.result.logs">{{ log }}</p>
- </div>
- </div>
- `,
- });
- const SIGNALING_DATA_CHANNEL_LABEL = 'saltyrtc';
- const APP_DATA_CHANNEL_LABEL = 'therme';
- app.controller('ChecksController', function($scope, $timeout) {
- // Initialize state
- this.state = 'init'; // Either 'init' or 'check'
- this.os = null; // Either 'android' or 'ios'
- // Initialize results
- // Valid states: yes, no, unknown, loading
- this.resultJs = {
- state: 'unknown',
- showLogs: false,
- };
- this.resultLs = {
- state: 'unknown',
- showLogs: false,
- };
- this.resultDn = {
- state: 'unknown',
- showLogs: false,
- };
- this.resultWs = {
- state: 'unknown',
- showLogs: false,
- logs: [],
- };
- this.resultDc = {
- state: 'unknown',
- showLogs: false,
- logs: [],
- };
- this.resultTurn = {
- state: 'unknown',
- showLogs: false,
- logs: [],
- };
- // Start checks
- this.start = (os) => {
- this.os = os;
- this.state = 'check';
- this.doChecks();
- };
- // Local store can be used
- const localStorageAvailable = () => {
- const test = 'test';
- try {
- localStorage.setItem(test, test);
- localStorage.removeItem(test);
- this.resultLs.state = 'yes';
- } catch(e) {
- this.resultLs.state = 'no';
- }
- };
- // The desktop notification API is available
- const desktopNotificationsAvailable = () => {
- this.resultDn.state = 'Notification' in window ? 'yes' : 'no';
- };
- // A WebSocket connection can be established to the SaltyRTC server
- const canEstablishWebSocket = () => {
- const subprotocol = 'v1.saltyrtc.org';
- const path = 'ffffffffffffffff00000000000000000000000000000000ffffffffffffffff';
- this.resultWs.showLogs = true;
- const ws = new WebSocket('wss://saltyrtc-ff.threema.ch/' + path, subprotocol);
- ws.binaryType = 'arraybuffer';
- ws.addEventListener('open', () => {
- $scope.$apply(() => {
- this.resultWs.logs.push('Connected');
- });
- });
- ws.addEventListener('message', (event) => {
- console.log('Message from server ', event.data);
- const success = () => {
- $scope.$apply(() => {
- this.resultWs.state = 'yes';
- this.resultWs.logs.push('Received server-hello message');
- });
- ws.close(1000);
- };
- const fail = (msg) => {
- $scope.$apply(() => {
- this.resultWs.state = 'no';
- console.error(msg);
- this.resultWs.logs.push(`Invalid server-hello message (${msg})`);
- });
- ws.close(1000);
- };
- // This should be the SaltyRTC server-hello message.
- const bytes = new Uint8Array(event.data);
- console.log('Message bytes:', bytes);
- // Validate length
- if (bytes.length < 81) {
- return fail(`Invalid length: ${bytes.length}`);
- }
- // Split up message
- const nonce = bytes.slice(0, 24);
- const data = bytes.slice(24);
- // Validate nonce
- if (nonce[16] !== 0) {
- return fail('Invalid nonce (source != 0)');
- }
- if (nonce[17] !== 0) {
- return fail('Invalid nonce (destination != 0)');
- }
- if (nonce[18] !== 0 || nonce[19] !== 0) {
- return fail('Invalid nonce (overflow != 0)');
- }
- // Data should start with 0x82 (fixmap with 2 entries) followed by a string
- // with either the value "type" or "key".
- if (data[0] !== 0x82) {
- return fail('Invalid data (does not start with 0x82)');
- }
- if (data[1] === 0xa3 && data[2] === 'k'.charCodeAt(0) && data[3] === 'e'.charCodeAt(0) && data[4] === 'y'.charCodeAt(0)) {
- return success();
- }
- if (data[1] === 0xa4 && data[2] === 't'.charCodeAt(0) && data[3] === 'y'.charCodeAt(0) && data[4] === 'p'.charCodeAt(0) && data[5] === 'e'.charCodeAt(0)) {
- return success();
- }
- return fail('Invalid data (bad map key)');
- });
- ws.addEventListener('error', (event) => {
- console.error('WS error:', event);
- $scope.$apply(() => {
- this.resultWs.state = 'no';
- this.resultWs.logs.push('Error');
- });
- });
- ws.addEventListener('close', () => {
- $scope.$apply(() => {
- this.resultWs.logs.push('Connection closed');
- });
- });
- this.resultWs.logs.push('Connecting');
- };
- // A peer-to-peer connection can be established and a data channel can be
- // used to send data.
- const canEstablishDataChannels = () => {
- this.resultDc.showLogs = true;
- // Check for the RTCPeerConnecton object
- if (window.RTCPeerConnection) {
- this.resultDc.logs.push('RTCPeerConnection available');
- } else {
- this.resultDc.state = 'no';
- this.resultDc.logs.push('RTCPeerConnection unavailable');
- return;
- }
- // Check for the RTCDataChannel object
- if (window.RTCPeerConnection && (new RTCPeerConnection()).createDataChannel) {
- this.resultDc.logs.push('RTCDataChannel available');
- } else {
- this.resultDc.state = 'no';
- this.resultDc.logs.push('RTCDataChannel unavailable');
- return;
- }
- // Create two peer connection instances
- let offerer, answerer;
- try {
- [offerer, answerer] = [
- createPeerConnection('Offerer'),
- createPeerConnection('Answerer'),
- ];
- } catch (error) {
- this.resultDc.state = 'no';
- this.resultDc.logs.push(`Peer connection could not be created (${error.toString()})`);
- return;
- }
- // Async phase begins
- this.resultDc.state = 'loading';
- const done = (success, message) => {
- if (this.resultDc.state === 'loading') {
- this.resultDc.state = success ? 'yes' : 'no';
- }
- this.resultDc.logs.push(message);
- offerer.close();
- answerer.close();
- };
- // Connect the peer connection instances to each other
- let peerConnectionsEstablished;
- try {
- peerConnectionsEstablished = connectPeerConnections(offerer, answerer);
- } catch (error) {
- return done(false, `Peer connections could not be connected (${error.toString()})`);
- }
- peerConnectionsEstablished
- .then(() => {
- $scope.$apply(() => this.resultDc.logs.push('Connected'));
- })
- .catch((error) => {
- $scope.$apply(() => done(false, `Cannot connect (error: ${error.toString()})`));
- });
- // Create data channels for each peer connection instance. We mimic
- // what SaltyRTC and the web client would do here:
- //
- // - create a negotiated data channel with id 0 and send once open, and
- // - create a data channel for the ARP on the offerer's side.
- const canUseDataChannel = (role, dc, resolve, reject) => {
- dc.addEventListener('open', () => {
- $scope.$apply(() => {
- this.resultDc.logs.push(`${role}: Channel '${dc.label}' open`);
- try {
- dc.send('hello!');
- } catch (error) {
- this.resultDc.logs.push(
- `${role}: Channel '${dc.label}' was unable to send (${error.toString()})`);
- reject();
- }
- });
- });
- dc.addEventListener('close', () => {
- $scope.$apply(() => {
- if (this.resultDc.state === 'loading') {
- this.resultDc.logs.push(`${role}: Channel '${dc.label}' closed`);
- reject();
- }
- });
- });
- dc.addEventListener('error', () => {
- $scope.$apply(() => {
- this.resultDc.logs.push(`${role}: Channel '${dc.label}' error (${error.message})`);
- reject();
- });
- });
- dc.addEventListener('message', (event) => {
- $scope.$apply(() => {
- if (event.data === 'hello!') {
- this.resultDc.logs.push(`${role}: Channel '${dc.label}' working`);
- resolve();
- } else {
- this.resultDc.logs.push(
- `${role}: Channel '${dc.label}' received an unexpected message ('${event.data}')`);
- reject();
- }
- });
- });
- };
- try {
- Promise.all([
- new Promise((resolve, reject) => {
- const dc = createDataChannel(
- offerer, 'Offerer', SIGNALING_DATA_CHANNEL_LABEL, {id: 0, negotiated: true});
- canUseDataChannel('Offerer', dc, resolve, reject);
- }),
- new Promise((resolve, reject) => {
- const dc = createDataChannel(
- answerer, 'Answerer', SIGNALING_DATA_CHANNEL_LABEL, {id: 0, negotiated: true});
- canUseDataChannel('Answerer', dc, resolve, reject);
- }),
- // Mimic handover by waiting until the peer connection has
- // been established (and an additional second).
- peerConnectionsEstablished
- .then(() => new Promise((resolve) => setTimeout(resolve, 1000)))
- .then(() => new Promise((resolve, reject) => {
- const dc = createDataChannel(offerer, 'Offerer', APP_DATA_CHANNEL_LABEL);
- canUseDataChannel('Offerer', dc, resolve, reject);
- })),
- new Promise((resolve, reject) => {
- answerer.addEventListener('datachannel', (event) => {
- $scope.$apply(() => {
- const dc = event.channel;
- if (dc.label !== APP_DATA_CHANNEL_LABEL) {
- return done(false, `Unexpected 'datachannel' event (channel: ${dc.label})`);
- } else {
- canUseDataChannel('Answerer', dc, resolve, reject);
- }
- });
- });
- }),
- ])
- .then(() => {
- $scope.$apply(() => done(true, 'Data channels open and working'));
- })
- .catch((error) => {
- $scope.$apply(() => done(false, `Cannot connect (error: ${error.toString()})`));
- });
- } catch (error) {
- return done(false, `Data channels could not be created (${error.toString()})`);
- }
- };
- const haveTurnCandidates = () => {
- this.resultTurn.showLogs = true;
- // Create a peer connection instance
- let pc;
- try {
- pc = createPeerConnection('TURN');
- } catch (error) {
- this.resultTurn.state = 'no';
- this.resultTurn.logs.push(`Peer connection could not be created (${error.toString()})`);
- return;
- }
- // Async phase begins
- this.resultTurn.state = 'loading';
- const done = (success, message) => {
- if (this.resultTurn.state === 'loading') {
- this.resultTurn.state = success ? 'yes' : 'no';
- }
- this.resultTurn.logs.push(message);
- pc.close();
- };
- // Just trigger negotiation...
- try {
- pc.createDataChannel('kick-the-peer-connection-to-life');
- } catch (error) {
- return done(false, `Data channel could not be created (${error.toString()})`);
- }
- // Create timeout
- const timer = $timeout(() => this.resultTurn.state = 'no', 10000);
- // Create and apply local offer (async)
- (async () => {
- try {
- const offer = await pc.createOffer();
- await pc.setLocalDescription(offer);
- } catch (error) {
- $scope.$apply(() => done(false, `Offer could not be created (${error.toString()})`));
- }
- })();
- // Check for TURN ICE candidates
- pc.addEventListener('icecandidate', (event) => {
- $scope.$apply(() => {
- // Check for end-of-candidates indicator
- if (event.candidate === null) {
- $timeout.cancel(timer);
- return done(false, 'Done');
- }
- // Handle ICE candidate
- if (event.candidate.candidate) {
- const candidate = SDPUtils.parseCandidate(event.candidate.candidate);
- let info = `[${candidate.type}] ${candidate.ip}:${candidate.port}`;
- if (candidate.relatedAddress) {
- info += ` via ${candidate.relatedAddress}`;
- }
- info += ` (${candidate.protocol})`;
- // Relay candidate found: Cancel timer
- if (candidate.type === 'relay') {
- $timeout.cancel(timer);
- return done(true, info);
- }
- // Normal candidate: Log and continue
- this.resultTurn.logs.push(info);
- } else {
- this.resultTurn.logs.push(`Invalid candidate (${event.candidate.candidate})`);
- }
- });
- });
- };
- // Run all the checks and update results
- this.doChecks = () => {
- // Check for JS
- this.resultJs.state = 'yes';
- // Check for LocalStorage
- localStorageAvailable();
- // Check for desktop notifications
- desktopNotificationsAvailable();
- // Check for data channel connectivity
- canEstablishDataChannels();
- // Check for WebSocket connectivity
- canEstablishWebSocket();
- // Check for TURN connectivity
- haveTurnCandidates();
- };
- });
|