From 6f49f426b518bdbca11c7994246eb088903e6619 Mon Sep 17 00:00:00 2001
From: toasted-nutbread <toasted-nutbread@users.noreply.github.com>
Date: Wed, 8 Jul 2020 19:58:06 -0400
Subject: Generalized frame connections (#654)

* Create FrameClient and FrameEndpoint

* Use new Frame* classes for Popup=>frame connection

* Update api.sendMessageToFrame and api.broadcastTab to include the sender's frameId

* Update FrameClient to store the frame's frameId
---
 ext/mixed/js/frame-client.js   | 173 +++++++++++++++++++++++++++++++++++++++++
 ext/mixed/js/frame-endpoint.js |  65 ++++++++++++++++
 2 files changed, 238 insertions(+)
 create mode 100644 ext/mixed/js/frame-client.js
 create mode 100644 ext/mixed/js/frame-endpoint.js

(limited to 'ext/mixed/js')

diff --git a/ext/mixed/js/frame-client.js b/ext/mixed/js/frame-client.js
new file mode 100644
index 00000000..6ea344e2
--- /dev/null
+++ b/ext/mixed/js/frame-client.js
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2020  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+class FrameClient {
+    constructor() {
+        this._secret = null;
+        this._token = null;
+        this._frameId = null;
+    }
+
+    get frameId() {
+        return this._frameId;
+    }
+
+    async connect(frame, targetOrigin, hostFrameId, setupFrame, timeout=10000) {
+        const {secret, token, frameId} = await this._connectIternal(frame, targetOrigin, hostFrameId, setupFrame, timeout);
+        this._secret = secret;
+        this._token = token;
+        this._frameId = frameId;
+    }
+
+    isConnected() {
+        return (this._secret !== null);
+    }
+
+    createMessage(data) {
+        if (!this.isConnected()) {
+            throw new Error('Not connected');
+        }
+        return {
+            token: this._token,
+            secret: this._secret,
+            data
+        };
+    }
+
+    _connectIternal(frame, targetOrigin, hostFrameId, setupFrame, timeout) {
+        return new Promise((resolve, reject) => {
+            const tokenMap = new Map();
+            let timer = null;
+            let {
+                promise: frameLoadedPromise,
+                resolve: frameLoadedResolve,
+                reject: frameLoadedReject
+            } = deferPromise();
+
+            const postMessage = (action, params) => {
+                const contentWindow = frame.contentWindow;
+                if (contentWindow === null) { throw new Error('Frame missing content window'); }
+
+                let validOrigin = true;
+                try {
+                    validOrigin = (contentWindow.location.origin === targetOrigin);
+                } catch (e) {
+                    // NOP
+                }
+                if (!validOrigin) { throw new Error('Unexpected frame origin'); }
+
+                contentWindow.postMessage({action, params}, targetOrigin);
+            };
+
+            const onMessage = (message) => {
+                onMessageInner(message);
+                return false;
+            };
+
+            const onMessageInner = async (message) => {
+                try {
+                    if (!isObject(message)) { return; }
+                    const {action, params} = message;
+                    if (!isObject(params)) { return; }
+                    await frameLoadedPromise;
+                    if (timer === null) { return; } // Done
+
+                    switch (action) {
+                        case 'frameEndpointReady':
+                            {
+                                const {secret} = params;
+                                const token = yomichan.generateId(16);
+                                tokenMap.set(secret, token);
+                                postMessage('frameEndpointConnect', {secret, token, hostFrameId});
+                            }
+                            break;
+                        case 'frameEndpointConnected':
+                            {
+                                const {secret, token} = params;
+                                const frameId = message.frameId;
+                                const token2 = tokenMap.get(secret);
+                                if (typeof token2 !== 'undefined' && token === token2) {
+                                    cleanup();
+                                    resolve({secret, token, frameId});
+                                }
+                            }
+                            break;
+                    }
+                } catch (e) {
+                    cleanup();
+                    reject(e);
+                }
+            };
+
+            const onLoad = () => {
+                if (frameLoadedResolve === null) {
+                    cleanup();
+                    reject(new Error('Unexpected load event'));
+                    return;
+                }
+
+                if (FrameClient.isFrameAboutBlank(frame)) {
+                    return;
+                }
+
+                frameLoadedResolve();
+                frameLoadedResolve = null;
+                frameLoadedReject = null;
+            };
+
+            const cleanup = () => {
+                if (timer === null) { return; } // Done
+                clearTimeout(timer);
+                timer = null;
+
+                frameLoadedResolve = null;
+                if (frameLoadedReject !== null) {
+                    frameLoadedReject(new Error('Terminated'));
+                    frameLoadedReject = null;
+                }
+
+                chrome.runtime.onMessage.removeListener(onMessage);
+                frame.removeEventListener('load', onLoad);
+            };
+
+            // Start
+            timer = setTimeout(() => {
+                cleanup();
+                reject(new Error('Timeout'));
+            }, timeout);
+
+            chrome.runtime.onMessage.addListener(onMessage);
+            frame.addEventListener('load', onLoad);
+
+            // Prevent unhandled rejections
+            frameLoadedPromise.catch(() => {}); // NOP
+
+            setupFrame(frame);
+        });
+    }
+
+    static isFrameAboutBlank(frame) {
+        try {
+            const contentDocument = frame.contentDocument;
+            if (contentDocument === null) { return false; }
+            const url = contentDocument.location.href;
+            return /^about:blank(?:[#?]|$)/.test(url);
+        } catch (e) {
+            return false;
+        }
+    }
+}
diff --git a/ext/mixed/js/frame-endpoint.js b/ext/mixed/js/frame-endpoint.js
new file mode 100644
index 00000000..1cd25bb5
--- /dev/null
+++ b/ext/mixed/js/frame-endpoint.js
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2020  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * api
+ */
+
+class FrameEndpoint {
+    constructor() {
+        this._secret = yomichan.generateId(16);
+        this._token = null;
+        this._eventListeners = new EventListenerCollection();
+        this._eventListenersSetup = false;
+    }
+
+    signal() {
+        if (!this._eventListenersSetup) {
+            this._eventListeners.addEventListener(window, 'message', this._onMessage.bind(this), false);
+            this._eventListenersSetup = true;
+        }
+        api.broadcastTab('frameEndpointReady', {secret: this._secret});
+    }
+
+    authenticate(message) {
+        return (
+            this._token !== null &&
+            isObject(message) &&
+            this._token === message.token &&
+            this._secret === message.secret
+        );
+    }
+
+    _onMessage(e) {
+        if (this._token !== null) { return; } // Already initialized
+
+        const data = e.data;
+        if (!isObject(data) || data.action !== 'frameEndpointConnect') { return; } // Invalid message
+
+        const params = data.params;
+        if (!isObject(params)) { return; } // Invalid data
+
+        const secret = params.secret;
+        if (secret !== this._secret) { return; } // Invalid authentication
+
+        const {token, hostFrameId} = params;
+        this._token = token;
+
+        this._eventListeners.removeAllEventListeners();
+        api.sendMessageToFrame(hostFrameId, 'frameEndpointConnected', {secret, token});
+    }
+}
-- 
cgit v1.2.3