Mercurial > addons > firefox-addons > set-aside
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))); + }); +})();