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