Ver código fonte

Improve handling of other active connections

Danilo Bargen 7 anos atrás
pai
commit
49e72c4635

+ 3 - 1
public/i18n/de.json

@@ -34,7 +34,9 @@
         "MANUAL_START_STEP2": "Tippen Sie auf die Sitzung, die Sie wiederherstellen möchten",
         "MANUAL_START_STEP3": "Wählen Sie im Menu \"Sitzung starten\"",
         "MORE_ABOUT_WEB": "Mehr über Threema Web",
-        "LOCAL_STORAGE_MISSING_DETAILS": "Zugriff auf LocalStorage ist nicht möglich. Dieses Problem kann auftreten, wenn in Ihrem Browser Cookies blockiert werden, oder wenn ein Browser-Add-On installiert ist, welches den Zugriff auf LocalStorage blockiert. Bitte erlauben Sie die Nutzung von LocalStorage in Ihrem Browser oder deaktivieren Sie die installierten Browser-Add-Ons."
+        "LOCAL_STORAGE_MISSING_DETAILS": "Zugriff auf LocalStorage ist nicht möglich. Dieses Problem kann auftreten, wenn in Ihrem Browser Cookies blockiert werden, oder wenn ein Browser-Add-On installiert ist, welches den Zugriff auf LocalStorage blockiert. Bitte erlauben Sie die Nutzung von LocalStorage in Ihrem Browser oder deaktivieren Sie die installierten Browser-Add-Ons.",
+        "ALREADY_CONNECTED": "Bereits Verbunden",
+        "ALREADY_CONNECTED_DETAILS": "Sie sind bereits in einem anderen Tab oder Fenster mit Threema Web verbunden!"
     },
     "connecting": {
         "CONNECTION_PROBLEMS": "Verbindungsprobleme",

+ 3 - 1
public/i18n/en.json

@@ -34,7 +34,9 @@
         "MANUAL_START_STEP2": "Tap on the session related to this browser",
         "MANUAL_START_STEP3": "Select \"Start session\" to start the session",
         "MORE_ABOUT_WEB": "More about Threema Web",
-        "LOCAL_STORAGE_MISSING_DETAILS": "Access to LocalStorage not possible. This can occur if your browser is configured to reject cookies, or if you installed a browser add-on that blocks access to LocalStorage. Please allow local storage in your browser settings or disable any add-ons you might have installed."
+        "LOCAL_STORAGE_MISSING_DETAILS": "Access to LocalStorage not possible. This can occur if your browser is configured to reject cookies, or if you installed a browser add-on that blocks access to LocalStorage. Please allow local storage in your browser settings or disable any add-ons you might have installed.",
+        "ALREADY_CONNECTED": "Already Connected",
+        "ALREADY_CONNECTED_DETAILS": "You are already connected to Threema Web in another tab or window!"
     },
     "connecting": {
         "CONNECTION_PROBLEMS": "Connection problems",

+ 10 - 0
src/partials/welcome.html

@@ -106,6 +106,16 @@
             </ol>
         </div>
 
+        <div class="already-connected" ng-if="ctrl.state === 'already_connected'">
+            <h2 class="instructions" translate>welcome.ALREADY_CONNECTED</h2>
+            <i class="illustration material-icons md-dark md-96">phonelink_ring</i>
+            <p translate>welcome.ALREADY_CONNECTED_DETAILS</p>
+            <br>
+            <md-button class="md-raised md-primary" ng-click="ctrl.reload()">
+                <i class="material-icons">refresh</i> <span translate>welcome.RELOAD</span>
+            </md-button>
+        </div>
+
         <div ng-if="ctrl.state === 'closed'">
             <p class="state error">
                 <strong><span translate>common.ERROR</span>:</strong> <span translate>connecting.CONNECTION_CLOSED</span><br>

+ 97 - 36
src/partials/welcome.ts

@@ -15,6 +15,10 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+// tslint:disable:no-reference
+
+/// <reference path="../types/broadcastchannel.d.ts" />
+
 import {BrowserService} from '../services/browser';
 import {ControllerService} from '../services/controller';
 import {TrustedKeyStoreService} from '../services/keystore';
@@ -43,6 +47,8 @@ class WelcomeController {
 
     private static REDIRECT_DELAY = 500;
 
+    private logTag: string = '[WelcomeController]';
+
     // Angular services
     private $scope: ng.IScope;
     private $state: ng.ui.IStateService;
@@ -213,11 +219,14 @@ class WelcomeController {
      * Initiate a new session by scanning a new QR code.
      */
     private scan(): void {
-        this.$log.info('Initialize session by scanning QR code...');
+        this.$log.info(this.logTag, 'Initialize session by scanning QR code...');
 
         // Initialize webclient with new keystore
         this.webClientService.init();
 
+        // Set up the broadcast channel that checks whether we're already connected in another tab
+        this.setupBroadcastChannel(this.webClientService.salty.keyStore.publicKeyHex);
+
         // Initialize QR code params
         this.$scope.$watch(() => this.password, () => {
             const payload = this.webClientService.buildQrCodePayload(this.password.length > 0);
@@ -232,7 +241,7 @@ class WelcomeController {
      * Initiate a new session by unlocking a trusted key.
      */
     private unlock(): void {
-        this.$log.info('Initialize session by unlocking trusted key...');
+        this.$log.info(this.logTag, 'Initialize session by unlocking trusted key...');
     }
 
     /**
@@ -247,48 +256,87 @@ class WelcomeController {
         // Instantiate new keystore
         const keyStore = new saltyrtcClient.KeyStore(decrypted.ownSecretKey);
 
-        // Check if there are other tabs running on the same session
-        if ('BroadcastChannel' in self) {
-            const channel = new BroadcastChannel('session-check');
-            channel.onmessage = (event: MessageEvent) => {
-                const message = JSON.parse(event.data);
-                this.$log.debug(message); // TODO: REMOVE
-                switch (message.type) {
-                    case 'publicKey':
-                        if (message.key === keyStore.publicKeyHex) {
-                            channel.postMessage(JSON.stringify({
-                                type: 'open',
-                                key: keyStore.publicKeyHex,
-                            }));
-                        }
-                        break;
-                    case 'open':
-                        if (message.key === keyStore.publicKeyHex) {
-                            this.$log.error('SESSION ALREADY OPEN');
-                        }
-                        break;
-                    default:
-                        this.$log.warn('Unknown session-check message type:', message.type);
-                        break;
-                }
-            };
-            this.$log.debug('Checking if the session is already open');
-            channel.postMessage(JSON.stringify({
-                type: 'publicKey',
-                key: keyStore.publicKeyHex,
-            }));
-        }
+        // Set up the broadcast channel that checks whether we're already connected in another tab
+        this.setupBroadcastChannel(keyStore.publicKeyHex);
 
         // Initialize push service
         if (decrypted.pushToken !== null) {
             this.pushService.init(decrypted.pushToken);
-            this.$log.debug('Initialize push service');
+            this.$log.debug(this.logTag, 'Initialize push service');
         }
 
         // Reconnect
         this.reconnect(keyStore, decrypted.peerPublicKey);
     }
 
+    /**
+     * Set up a `BroadcastChannel` to check if there are other tabs running on
+     * the same session.
+     *
+     * The `publicKeyHex` parameter is the hex-encoded public key of the keystore
+     * used to establish the SaltyRTC connection.
+     */
+    private setupBroadcastChannel(publicKeyHex: string) {
+        if (!('BroadcastChannel' in this.$window)) {
+            // No BroadcastChannel support in this browser
+            this.$log.warn(this.logTag, 'BroadcastChannel not supported in this browser');
+            return;
+        }
+
+        // Config constants
+        const CHANNEL_NAME = 'session-check';
+        const TYPE_PUBLIC_KEY = 'public-key';
+        const TYPE_ALREADY_OPEN = 'already-open';
+
+        // Set up new BroadcastChannel
+        const channel = new BroadcastChannel(CHANNEL_NAME);
+
+        // Register a message handler
+        channel.onmessage = (event: MessageEvent) => {
+            const message = JSON.parse(event.data);
+            switch (message.type) {
+                case TYPE_PUBLIC_KEY:
+                    // Another tab is trying to connect to a session.
+                    // Is it the same public key as the one we are using?
+                    if (message.key === publicKeyHex
+                            && (this.stateService.connectionBuildupState === 'loading'
+                             || this.stateService.connectionBuildupState === 'done')) {
+                        // Yes it is, notify them that the session is already active
+                        this.$log.debug(
+                            this.logTag,
+                            'Another tab is trying to connect to our session. Respond with a broadcast.',
+                        );
+                        channel.postMessage(JSON.stringify({
+                            type: TYPE_ALREADY_OPEN,
+                            key: publicKeyHex,
+                        }));
+                    }
+                    break;
+                case TYPE_ALREADY_OPEN:
+                    // Another tab notified us that the session we're trying to connect to
+                    // is already active.
+                    if (message.key === publicKeyHex && this.stateService.connectionBuildupState !== 'done') {
+                        this.$log.error(this.logTag, 'Session already connected in another tab or window');
+                        this.$timeout(() => {
+                            this.stateService.updateConnectionBuildupState('already_connected');
+                            this.stateService.state = 'error';
+                        }, 500);
+                    }
+                    break;
+                default:
+                    this.$log.warn(this.logTag, 'Unknown broadcast message type:', message.type);
+                    break;
+            }
+        };
+
+        // Notify other tabs that we're trying to connect
+        this.$log.debug(this.logTag, 'Checking if the session is already open in another tab or window');
+        channel.postMessage(JSON.stringify({
+            type: TYPE_PUBLIC_KEY,
+            key: publicKeyHex,
+        }));
+    }
+
     /**
      * Reconnect using a specific keypair and peer public key.
      */
@@ -343,6 +391,19 @@ class WelcomeController {
         });
     }
 
+    /**
+     * Show the "already connected" dialog.
+     */
+    private showAlreadyConnected(): void {
+        this.$translate.onReady().then(() => {
+            const confirm = this.$mdDialog.alert()
+            .title(this.$translate.instant('welcome.ALREADY_CONNECTED'))
+            .htmlContent(this.$translate.instant('welcome.ALREADY_CONNECTED_DETAILS'))
+            .ok(this.$translate.instant('common.OK'));
+            this.$mdDialog.show(confirm);
+        });
+    }
+
     /**
      * Forget trusted keys.
      */
@@ -405,7 +466,7 @@ class WelcomeController {
         } else if (len <= 586) {
             version = 16;
         } else {
-            this.$log.error('QR Code payload too large: Is your SaltyRTC host string huge?');
+            this.$log.error(this.logTag, 'QR Code payload too large: Is your SaltyRTC host string huge?');
             version = 40;
         }
         return {
@@ -437,7 +498,7 @@ class WelcomeController {
 
             // If an error occurs...
             (error) => {
-                this.$log.error('Error state:', error);
+                this.$log.error(this.logTag, 'Error state:', error);
                 // TODO: should probably show an error message instead
                 this.$timeout(() => this.$state.reload(), WelcomeController.REDIRECT_DELAY);
             },

+ 8 - 0
src/sass/sections/_welcome.scss

@@ -82,6 +82,14 @@
         }
     }
 
+    .already-connected {
+        .illustration {
+            margin-top: 16px;
+            margin-bottom: 28px;
+            margin-left: 12px;
+        }
+    }
+
     .loading {
         margin-top: 48px;
 

+ 3 - 2
src/threema.d.ts

@@ -299,6 +299,7 @@ declare namespace threema {
      * - connecting: Connecting to signaling server
      * - push: When trying to reconnect, waiting for push notification to arrive
      * - manual_start: When trying to reconnect, waiting for manual session start
+     * - already_connected: When the user is already connected in another tab or window
      * - waiting: Waiting for new-responder message from signaling server
      * - peer_handshake: Doing SaltyRTC handshake with the peer
      * - loading: Loading initial data
@@ -306,8 +307,8 @@ declare namespace threema {
      * - closed: Connection is closed
      *
      */
-    type ConnectionBuildupState = 'new' | 'connecting' | 'push' | 'manual_start' | 'waiting'
-        | 'peer_handshake' | 'loading' | 'done' | 'closed';
+    type ConnectionBuildupState = 'new' | 'connecting' | 'push' | 'manual_start' | 'already_connected'
+        | 'waiting' | 'peer_handshake' | 'loading' | 'done' | 'closed';
 
     interface ConnectionBuildupStateChange {
         state: ConnectionBuildupState;

+ 40 - 0
src/types/broadcastchannel.d.ts

@@ -0,0 +1,40 @@
+/**
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+
+interface BroadcastChannel extends EventTarget {
+    readonly name: string;
+    onmessage: (ev: MessageEvent) => any;
+    onmessageerror: (ev: MessageEvent) => any;
+    close(): void;
+    postMessage(message: any): void;
+    addEventListener<K extends keyof BroadcastChannelEventMap>(
+        type: K,
+        listener: (this: BroadcastChannel, ev: BroadcastChannelEventMap[K]) => any,
+        useCapture?: boolean,
+    ): void;
+    addEventListener(type: string, listener: EventListenerOrEventListenerObject, useCapture?: boolean): void;
+}
+
+declare var BroadcastChannel: {
+    prototype: BroadcastChannel;
+    new(name: string): BroadcastChannel;
+};
+
+interface BroadcastChannelEventMap {
+    message: MessageEvent;
+    messageerror: MessageEvent;
+}