view background.js @ 1:b0827360b8e4

Store favicons and thumbnails in local database
author Guido Berhoerster <guido+set-aside@berhoerster.name>
date Mon, 19 Nov 2018 17:26:17 +0100
parents d13d59494613
children 9d699dc7823d
line wrap: on
line source

/*
 * 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 ObjectStoreDB {
    constructor(dbName = 'defaultDatabase', objectStoreName = 'objectStore') {
        this.dbName = dbName;
        this.objectStoreName = objectStoreName;
        this._db = undefined;
        this.openingDB = new Promise((resolve, reject) => {
            let request = indexedDB.open(this.dbName);
            request.addEventListener('error', ev => {
                reject(request.error);
            });
            request.addEventListener('success', ev => {
                resolve(request.result);
            });
            request.addEventListener('upgradeneeded', ev => {
                request.result.createObjectStore(this.objectStoreName);
            });
        });
    }

    async _execTransaction(method, ...methodArguments) {
        if (typeof this._db === 'undefined') {
            this._db = await this.openingDB;
        }
        return new Promise((resolve, reject) => {
            let transaction = this._db.transaction(this.objectStoreName,
                    method.startsWith('get') ? 'readonly' : 'readwrite');
            let objectStore = transaction.objectStore(this.objectStoreName);
            let request = objectStore[method](...methodArguments);
            transaction.addEventListener('complete', ev =>
                    method.startsWith('get') ?
                    resolve(request.result) :
                    resolve());
            transaction.addEventListener('abort', ev =>
                    reject(transaction.error));
            transaction.addEventListener('error', ev =>
                    reject(transaction.error));
        });
    }

    async get(key) {
        return key === null || typeof key === 'undefined' ?
                this._execTransaction('getAll') :
                this._execTransaction('get', key);
    }

    async keys() {
        return this._execTransaction('getAllKeys');
    }

    async set(key, value) {
        return this._execTransaction('put', value, key)
    }

    async delete(key) {
        return this._execTransaction('delete', key)
    }

    async clear(key) {
        return this._execTransaction('clear')
    }
}

class Tab {
    static deserialize(object) {
        return new Tab(object);
    }

    constructor({url, title, uuid = generateUuidV4String(), favIcon = null,
            thumbnail = null}) {
        this.uuid = uuid;
        this.url = url;
        this.title = title;
        this.favIcon = favIcon;
        this.thumbnail = thumbnail;
    }

    serialize() {
        return Object.assign({}, this, {favIcon: null, thumbnail: null});
    }
}

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.objectStoreDB = new ObjectStoreDB('tabCollections');
        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));

        // get favicon and thumbnail data from local database
        let updatingTabData = [];
        for (let tabCollectionUuid of this.tabCollections.keys()) {
            updatingTabData.push(this.updateTabData(tabCollectionUuid));
        }
        await Promise.all(updatingTabData);

        // remove stale data from local database
        for (let tabCollectionUuid of await this.objectStoreDB.keys()) {
            if (!this.tabCollections.has(tabCollectionUuid)) {
                console.log('removing data for stale tab collection',
                    tabCollectionUuid);
                this.objectStoreDB.delete(tabCollectionUuid);
            }
        }

        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 thumbnailBlob = 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);

                canvas.toBlob(resolve, 'image/jpeg', 0.75);
            });
            image.addEventListener('error', e => {
                reject(e);
            });
            image.src = captureUrl;
        });
        return thumbnailBlob;
    }

    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 = await Promise.all(browserTabs.map(async browserTab => {
            // convert favicon data URI to blob
            let favIcon = null;
            if (!browserTab.discarded) {
                try {
                    let response = await fetch(browserTab.favIconUrl);
                    favIcon = await response.blob();
                } catch (e) {
                    if (!(e instanceof AbortError)) {
                        throw e;
                    }
                }
            }

            let tab = new Tab({
                url: browserTab.url,
                title: browserTab.title,
                favIcon
            });
            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.thumbnail = thumbnails.shift();
        }

        let tabCollection = new TabCollection({tabs});
        console.log('created tab collection:', tabCollection);

        // store tab favicons and thumbnails
        let tabCollectionData = {
            uuid: tabCollection.uuid,
            tabs: new Map()
        };
        for (let [uuid, tab] of tabs) {
            tabCollectionData.tabs.set(uuid, {
                favIcon: tab.favIcon,
                thumbnail: tab.thumbnail
            });
        }
        await this.objectStoreDB.set(tabCollectionData.uuid, tabCollectionData);

        // 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}`);
        this.objectStoreDB.delete(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);
    }

    async updateTabData(tabCollectionUuid) {
        let tabCollectionDataObject;
        try {
            tabCollectionDataObject =
                    await this.objectStoreDB.get(tabCollectionUuid);
        } catch (e) {
            console.error(`Failed to get data from database: e.message`);
            return;
        }
        if (typeof tabCollectionDataObject === 'undefined') {
            // does not exist in database
            console.log('no data stored for tab collection', tabCollectionUuid);
            return;
        }

        console.log(`updating tab collection ${tabCollectionUuid} with data`,
                tabCollectionDataObject);
        let tabCollection = this.tabCollections.get(tabCollectionUuid);
        for (let [tabUuid, tab] of tabCollection.tabs) {
            let tabDataObject = tabCollectionDataObject.tabs.get(tabUuid);
            if (typeof tabDataObject === 'undefined') {
                continue;
            }
            tab.favIcon = tabDataObject.favIcon;
            tab.thumbnail = tabDataObject.thumbnail;
        }
    }

    async 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);
            // try to get tab favicons and thumbnails
            await this.updateTabData(tabCollectionUuid);

            this.broadcastMessage({
                type: 'tabCollectionCreated',
                tabCollection: newTabCollection
            });
        } else if (typeof newValue === 'undefined') {
            // a collection has been removed
            this.tabCollections.delete(tabCollectionUuid);
            this.objectStoreDB.delete(tabCollectionUuid);

            this.broadcastMessage({
                type: 'tabCollectionRemoved',
                tabCollectionUuid
            });
        } else {
            // a collection has changed
            let newTabCollection = TabCollection.deserialize(newValue);
            this.tabCollections.set(tabCollectionUuid, newTabCollection);
            // try to get tab favicons and thumbnails
            await this.updateTabData(tabCollectionUuid);

            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)));
    });
})();