diff background.js @ 0:d13d59494613

Initial revision
author Guido Berhoerster <guido+set-aside@berhoerster.name>
date Sat, 17 Nov 2018 10:44:16 +0100
parents
children b0827360b8e4
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/background.js	Sat Nov 17 10:44:16 2018 +0100
@@ -0,0 +1,425 @@
+/*
+ * Copyright (C) 2018 Guido Berhoerster <guido+set-aside@berhoerster.name>
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+'use strict';
+
+const SUPPORTED_PROTOCOLS = ['https:', 'http:', 'ftp:'];
+const GROUP_KEY_RE = /^collection:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/;
+const FIREFOX_VERSION_RE = /^(\d+(?:\.\d+)*)(?:([ab]|pre)(\d+))?$/;
+const FIREFOX_RELEASE_TYPES = {
+    'a': 'alpha',
+    'b': 'beta',
+    'pre': 'prerelease',
+    '': ''
+}
+const THUMBNAIL_WIDTH = 224;
+const THUMBNAIL_HEIGHT = 128;
+
+var tabCollectionsProxy;
+
+function generateUuidV4String() {
+    let uuid = new Uint8Array(16);
+    window.crypto.getRandomValues(uuid);
+    uuid[6] = (uuid[6] & 0x0f) | 0x40;
+    uuid[8] = (uuid[8] & 0x3f) | 0x80;
+
+    let result = [];
+    for (let i = 0; i < uuid.length; i++) {
+        if (i == 4 || i == 6 || i == 8 || i == 10) {
+            result.push('-');
+        }
+        result.push(uuid[i].toString(16).padStart(2, '0'));
+    }
+
+    return result.join('');
+}
+
+function parseFirefoxVersion(firefoxVersionString) {
+    let [, versionString, releaseTypeAbbrev = '', releaseNumberString = '0'] =
+            FIREFOX_VERSION_RE.exec(firefoxVersionString);
+
+    let releaseType = FIREFOX_RELEASE_TYPES[releaseTypeAbbrev];
+
+    let releaseNumber = parseInt(releaseNumberString);
+    let [major = 0, minor = 0, patch = 0] = versionString.split('.')
+            .map(x => parseInt(x));
+
+    return {
+        major,
+        minor,
+        patch,
+        releaseType,
+        releaseNumber,
+    };
+}
+
+class Tab {
+    static deserialize(object) {
+        return new Tab(object);
+    }
+
+    constructor({url, title, uuid = generateUuidV4String(), favIconUrl = null,
+            thumbnailUrl = null}) {
+        this.uuid = uuid;
+        this.url = url;
+        this.title = title;
+        this.favIconUrl = favIconUrl;
+        this.thumbnailUrl = thumbnailUrl;
+    }
+
+    serialize() {
+        return Object.assign({}, this);
+    }
+}
+
+class TabCollection {
+    static deserialize(object) {
+        object.tabs = Array.from(object.tabs,
+                ([, tab]) => [tab.uuid, Tab.deserialize(tab)]);
+        return new TabCollection(object);
+    }
+
+    constructor({tabs, uuid = generateUuidV4String(), date = new Date()}) {
+        this.uuid = uuid;
+        this.date = new Date(date);
+        this.tabs = new Map();
+        // allow any type which allows iteration
+        for (let [, tab] of tabs) {
+            this.tabs.set(tab.uuid, tab);
+        }
+    }
+
+    serialize() {
+        let serializedTabs = [];
+        for (let [tabUuid, tab] of this.tabs) {
+            serializedTabs.push([tab.uuid, tab.serialize()]);
+        }
+        return {
+            uuid: this.uuid,
+            date: this.date.toJSON(),
+            tabs: serializedTabs
+        };
+    }
+}
+
+class TabCollectionsStorageProxy {
+    constructor() {
+        this.tabCollections = new Map();
+        this.ports = new Set();
+        this.browserVersion = undefined;
+        this.messageQueue = [];
+        this.isInitialized = false;
+
+        browser.runtime.onConnect.addListener(this.onConnect.bind(this));
+    }
+
+    async init() {
+        let browserInfo = await browser.runtime.getBrowserInfo();
+        this.browserVersion = parseFirefoxVersion(browserInfo.version);
+
+        // get all tab collections and deserialize them in a Map
+        let storageEntries = Object.entries(await browser.storage.sync.get())
+                .filter(([key, value]) => GROUP_KEY_RE.test(key))
+                .map(([key, tabCollection]) =>
+                [tabCollection.uuid, TabCollection.deserialize(tabCollection)]);
+        this.tabCollections = new Map(storageEntries);
+        console.log('tab collections from storage');
+        console.table(this.tabCollections);
+        console.groupEnd();
+        browser.storage.onChanged.addListener(this.onStorageChanged.bind(this));
+
+        this.isInitialized = true;
+
+        while (this.messageQueue.length > 0) {
+            let [message, port] = this.messageQueue.pop();
+            if (this.ports.has(port)) {
+                this.onMessage(message, port);
+            }
+        }
+    }
+
+    async createTabThumbnail(tabId) {
+        let captureUrl = await browser.tabs.captureTab(tabId);
+        let thumbnailUrl = await new Promise((resolve, reject) => {
+            let image = new Image();
+            image.addEventListener('load', ev => {
+                let canvas = document.createElement('canvas');
+                canvas.width = THUMBNAIL_WIDTH;
+                canvas.height = THUMBNAIL_HEIGHT;
+                let dWidth = canvas.width;
+                let dHeight = dWidth * (image.height / image.width);
+
+                let ctx = canvas.getContext('2d');
+                ctx.fillStyle = '#fff';
+                ctx.fillRect(0, 0, canvas.width, canvas.height);
+                ctx.drawImage(image, 0, 0, dWidth, dHeight);
+
+                resolve(canvas.toDataURL('image/jpeg', 0.75));
+            });
+            image.addEventListener('error', e => {
+                reject(e);
+            });
+            image.src = captureUrl;
+        });
+        return thumbnailUrl;
+    }
+
+    async createTabCollection(windowId) {
+        let browserTabs = await browser.tabs.query({
+            windowId,
+            hidden: false,
+            pinned:false
+        });
+
+        // sanity check to prevent saving tabs from incognito windows
+        if (browserTabs.length === 0 || browserTabs[0].incognito) {
+            return;
+        }
+
+        // filter out tabs which cannot be restored
+        browserTabs = browserTabs.filter(browserTab =>
+                SUPPORTED_PROTOCOLS.includes(new URL(browserTab.url).protocol));
+        if (browserTabs.length === 0) {
+            return;
+        }
+
+        let tabs = browserTabs.map(browserTab => {
+            let tab = new Tab({
+                url: browserTab.url,
+                title: browserTab.title,
+                favIconUrl: browserTab.favIconUrl
+            });
+            return [tab.uuid, tab];
+        });
+
+        // create empty tab which becomes the new active tab
+        await browser.tabs.create({active: true});
+
+        // capture tabs, return null for discarded tabs since they can only be
+        // captured after they have been restored, e.g. through user
+        // interaction, and thus might hang the capture process indefinetly
+        let thumbnails = await Promise.all(browserTabs.map(browserTab =>
+                !browserTab.discarded ?
+                this.createTabThumbnail(browserTab.id) : null));
+        for (let [, tab] of tabs) {
+            tab.thumbnailUrl = thumbnails.shift();
+        }
+
+        let tabCollection = new TabCollection({tabs});
+        console.log('created tab collection:', tabCollection);
+
+        // store tab collection
+        console.log('storing tab collection:', tabCollection);
+        await browser.storage.sync.set({
+            [`collection:${tabCollection.uuid}`]: tabCollection.serialize()
+        });
+
+        // remove tabs
+        await browser.tabs.remove(browserTabs.map(browserTab => browserTab.id));
+    }
+
+    async removeTab(tabCollectionUuid, tabUuid) {
+        console.log('removing tab %s from collection %s', tabUuid,
+                tabCollectionUuid);
+        let tabCollection = this.tabCollections.get(tabCollectionUuid);
+        // create shallow clone
+        let newTabCollection = new TabCollection(tabCollection);
+        newTabCollection.tabs.delete(tabUuid);
+        // remove tab collection if there are no more tabs
+        if (newTabCollection.tabs.size === 0) {
+            return this.removeTabCollection(tabCollectionUuid);
+        }
+        await browser.storage.sync.set({
+            [`collection:${tabCollectionUuid}`]: newTabCollection.serialize()
+        });
+    }
+
+    async restoreTab(tabCollectionUuid, tabUuid, windowId) {
+        console.log('restoring tab %s from collection %s in window %d', tabUuid,
+                tabCollectionUuid, windowId);
+        let tab = this.tabCollections.get(tabCollectionUuid).tabs.get(tabUuid);
+        let tabProperties = {
+            active: false,
+            url: tab.url,
+            windowId
+        };
+        if (this.browserVersion.major >= 63) {
+            tabProperties.discarded = true;
+        }
+        await browser.tabs.create(tabProperties);
+        await this.removeTab(tabCollectionUuid, tabUuid);
+    }
+
+    async removeTabCollection(tabCollectionUuid) {
+        console.log('removing tab collection %s', tabCollectionUuid);
+        await browser.storage.sync.remove(`collection:${tabCollectionUuid}`);
+    }
+
+    async restoreTabCollection(tabCollectionUuid, windowId) {
+        console.log('restoring tab collection %s in window %s',
+                tabCollectionUuid, windowId);
+        let tabProperties = {
+            active: false,
+            windowId
+        };
+        if (this.browserVersion.major >= 63) {
+            tabProperties.discarded = true;
+        }
+        let creatingTabs =
+                Array.from(this.tabCollections.get(tabCollectionUuid).tabs,
+                ([, tab]) => browser.tabs.create(Object.assign({
+                    url: tab.url
+                }, tabProperties)));
+        await Promise.all(creatingTabs);
+        await this.removeTabCollection(tabCollectionUuid);
+    }
+
+    onStorageChanged(changes, areaName) {
+        if (areaName !== 'sync') {
+            return;
+        }
+
+        console.group('sync storage area changed:', changes);
+        console.table(Object.entries(changes)[0][1])
+        console.groupEnd();
+
+        let [key, {oldValue, newValue}] = Object.entries(changes)[0];
+        if (!GROUP_KEY_RE.test(key)) {
+            return;
+        }
+
+        let tabCollectionUuid = key.replace('collection:', '');
+        if (typeof oldValue === 'undefined') {
+            // a new collection was created
+            let newTabCollection = TabCollection.deserialize(newValue);
+            this.tabCollections.set(tabCollectionUuid, newTabCollection);
+
+            this.broadcastMessage({
+                type: 'tabCollectionCreated',
+                tabCollection: newTabCollection
+            });
+        } else if (typeof newValue === 'undefined') {
+            // a collection has been removed
+            this.tabCollections.delete(tabCollectionUuid);
+
+            this.broadcastMessage({
+                type: 'tabCollectionRemoved',
+                tabCollectionUuid
+            });
+        } else {
+            // a collection has changed
+            let newTabCollection = TabCollection.deserialize(newValue);
+            this.tabCollections.set(tabCollectionUuid, newTabCollection);
+
+            this.broadcastMessage({
+                type: 'tabCollectionChanged',
+                tabCollection: newTabCollection
+            });
+        }
+    }
+
+    broadcastMessage(message) {
+        for (let port of this.ports) {
+            port.postMessage(message);
+        }
+    }
+
+    onConnect(port) {
+        console.log('port connected:', port)
+        this.ports.add(port);
+        port.onMessage.addListener(this.onMessage.bind(this));
+        port.onDisconnect.addListener(this.onDisconnect.bind(this));
+    }
+
+    onDisconnect(port) {
+        if (port.error) {
+            console.log(`port connection error: ${port.error}\n`);
+        }
+        console.log('port disconnected:', port);
+        this.ports.delete(port);
+    }
+
+    onMessage(message, port) {
+        if (!this.isInitialized) {
+            console.log('queued message', message, 'from port', port);
+            this.messageQueue.push([message, port]);
+            return;
+        }
+
+        console.log('received message', message, 'on port', port);
+        switch (message.type) {
+            case 'getTabCollections':
+                port.postMessage({
+                    type: 'tabCollections',
+                    tabCollections: this.tabCollections
+                });
+                break;
+            case 'removeTab':
+                this.removeTab(message.tabCollectionUuid, message.tabUuid);
+                break;
+            case 'restoreTab':
+                this.restoreTab(message.tabCollectionUuid, message.tabUuid,
+                        message.windowId);
+                break;
+            case 'removeTabCollection':
+                this.removeTabCollection(message.tabCollectionUuid);
+                break;
+            case 'restoreTabCollection':
+                this.restoreTabCollection(message.tabCollectionUuid,
+                        message.windowId);
+                break;
+        }
+    }
+}
+
+// browser action context menu entry for opening the sidebar
+browser.menus.create({
+    contexts: ['browser_action'],
+    onclick: (info, tab) => browser.sidebarAction.open(),
+    title: browser.i18n.getMessage('showTabsMenuItem')
+});
+
+// disable the browser action for new incognito tabs
+browser.tabs.onCreated.addListener(tab => {
+    if (tab.incognito) {
+        // this does not work, it seems that the browser action is re-enabled
+        // on every update
+        browser.browserAction.disable(tab.id);
+    }
+});
+
+(async () => {
+    // disable the browser action for existing incognito tabs
+    let tabs = await browser.tabs.query({});
+    await Promise.all(tabs.filter(tab => tab.incognito)
+            .map(tab => browser.browserAction.disable(tab.id)))
+
+    tabCollectionsProxy = new TabCollectionsStorageProxy();
+    await tabCollectionsProxy.init();
+
+    browser.browserAction.onClicked.addListener(async targetTab => {
+        // prevent browser action from being activated while a collection is
+        // being created
+        let tabs = await browser.tabs.query({windowId: targetTab.windowId});
+        await Promise.all(tabs.map(tab =>
+                browser.browserAction.disable(tab.id)));
+
+        try {
+            await tabCollectionsProxy.createTabCollection(targetTab.windowId);
+        } catch (e) {
+            tabs = await browser.tabs.query({windowId: targetTab.windowId});
+            await Promise.all(tabs.map(tab =>
+                    browser.browserAction.enable(tab.id)));
+            throw e
+        }
+
+        tabs = await browser.tabs.query({windowId: targetTab.windowId});
+        await Promise.all(tabs.map(tab =>
+                browser.browserAction.enable(tab.id)));
+    });
+})();