diff --git a/.gitignore b/.gitignore index b4fb8bf..a3e1751 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ containerise.iml # build build web-ext-artifacts + +# Other +/profile diff --git a/package-lock.json b/package-lock.json index c1790dd..77b64e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6883,9 +6883,9 @@ "dev": true }, "handlebars": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.1.2.tgz", - "integrity": "sha512-nvfrjqvt9xQ8Z/w0ijewdD/vvWDTOweBUm96NTr66Wfvo1mJenBLwcYmPs3TIBP5ruzYGD7Hx/DaM9RmhroGPw==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.4.2.tgz", + "integrity": "sha512-cIv17+GhL8pHHnRJzGu2wwcthL5sb8uDKBHvZ2Dtu5s1YNt0ljbzKbamnc+gr69y7bzwQiBdr5+hOpRd5pnOdg==", "dev": true, "requires": { "neo-async": "^2.6.0", @@ -11254,6 +11254,16 @@ "unpipe": "1.0.0" } }, + "raw-loader": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-3.1.0.tgz", + "integrity": "sha512-lzUVMuJ06HF4rYveaz9Tv0WRlUMxJ0Y1hgSkkgg+50iEdaI0TthyEDe08KIHb0XsF6rn8WYTqPCaGTZg3sX+qA==", + "dev": true, + "requires": { + "loader-utils": "^1.1.0", + "schema-utils": "^2.0.1" + } + }, "rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", diff --git a/package.json b/package.json index d035598..758060d 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build": "npm audit && npm run lint && npm run test && webpack --config ./webpack.prod", "postbuild": "cp -a static/ build/ && web-ext build -s build/", "web-ext": "web-ext run -s build/ --start-url=about:debugging#addons", + "web-ext-perm": "mkdirp ./profile && web-ext run -s build/ --start-url=about:debugging#addons -p ./profile --keep-profile-changes", "lint": "eslint --ignore-path .gitignore ./", "test": "jest", "test:watch": "jest --watchAll" @@ -28,6 +29,7 @@ "jest": "^24.8.0", "mini-css-extract-plugin": "^0.8.0", "node-sass": "^4.12.0", + "raw-loader": "^3.1.0", "rimraf": "^3.0.0", "sass-loader": "^8.0.0", "style-loader": "^1.0.0", diff --git a/src/ContextualIdentity/__tests__/ContextualIdentity.spec.js b/src/ContextualIdentity/__tests__/ContextualIdentity.spec.js index 619d597..7bc8232 100644 --- a/src/ContextualIdentity/__tests__/ContextualIdentity.spec.js +++ b/src/ContextualIdentity/__tests__/ContextualIdentity.spec.js @@ -35,6 +35,9 @@ describe('ContextualIdentities', () => { addListener: jest.fn(() => {}), }, }, + storage: { + local: {}, + }, }; ContextualIdentities = require('../index').default; @@ -55,7 +58,7 @@ describe('ContextualIdentities', () => { iconUrl: 'resource://usercontext-content/circle.svg', color: 'grey', colorCode: '#999', - cookieStoreId: 'no-container', + cookieStoreId: 'firefox-default', }]); }); }); diff --git a/src/ContextualIdentity/index.js b/src/ContextualIdentity/index.js index f8214b9..652c065 100644 --- a/src/ContextualIdentity/index.js +++ b/src/ContextualIdentity/index.js @@ -1,16 +1,65 @@ +import HostStorage from '../Storage/HostStorage'; +import PreferenceStorage from '../Storage/PreferenceStorage'; + export const NO_CONTAINER = { name: 'No Container', icon: 'circle', iconUrl: 'resource://usercontext-content/circle.svg', color: 'grey', colorCode: '#999', - cookieStoreId: 'no-container', + cookieStoreId: 'firefox-default', }; +export const COLORS = [ + 'blue', + 'green', + 'orange', + 'pink', + 'purple', + 'red', + 'turquoise', + 'yellow', +]; class ContextualIdentities { constructor() { this.contextualIdentities = browser.contextualIdentities; + this.addOnRemoveListener((changeInfo) => { + const cookieStoreId = changeInfo.contextualIdentity.cookieStoreId; + this.cleanPreferences(cookieStoreId); + this.cleanMaps(cookieStoreId); + }); + } + + create(name) { + return this.contextualIdentities.create({ + name: name, + color: COLORS[Math.floor(Math.random() * COLORS.length)], + icon: 'circle', + }); + } + + /** + * Gets rid of a container and all corresponding rules + */ + async remove(cookieStoreId) { + if (cookieStoreId === NO_CONTAINER.cookieStoreId) { + return; + } + return this.contextualIdentities.remove(cookieStoreId); + } + + async cleanMaps(cookieStoreId) { + const hostMaps = await HostStorage.getAll(); + return HostStorage.remove(Object.keys(hostMaps) + .filter(host => hostMaps[host].cookieStoreId === cookieStoreId) + ); + } + async cleanPreferences(cookieStoreId) { + const preferences = await PreferenceStorage.getAll(); + return PreferenceStorage.remove(Object.keys(preferences) + .filter(prefName => prefName.startsWith(`containers.${cookieStoreId}`)) + ); } getAll(details = {}) { @@ -19,7 +68,7 @@ class ContextualIdentities { get(name) { if (name === NO_CONTAINER.name) { - return Promise.resolve(NO_CONTAINER); + return Promise.resolve([NO_CONTAINER]); } return this.contextualIdentities.query({name}); } diff --git a/src/ExtendedURL/__tests__/ExtendURL.spec.js b/src/ExtendedURL/__tests__/ExtendURL.spec.js new file mode 100644 index 0000000..044bfc9 --- /dev/null +++ b/src/ExtendedURL/__tests__/ExtendURL.spec.js @@ -0,0 +1,12 @@ +describe('utils', () => { + + const ExtendedURL = require('../index').default; + + it('should have domain', function () { + const eUrl = new ExtendedURL('https://gist.github.com'); + expect(eUrl.domain).toEqual('github'); + expect(eUrl.tld).toEqual('com'); + }); + + +}); diff --git a/src/ExtendedURL/index.js b/src/ExtendedURL/index.js new file mode 100644 index 0000000..fab9f84 --- /dev/null +++ b/src/ExtendedURL/index.js @@ -0,0 +1,12 @@ +export default class ExtendedURL extends URL { + constructor(url) { + super(url); + const split = this.hostname.split('.'); + this.tld = split[split.length - 1]; + if (split.length > 1) { + this.domain = split[split.length - 2]; + } else { + this.domain = this.tld; + } + } +} diff --git a/src/Storage/PreferenceStorage.js b/src/Storage/PreferenceStorage.js index 0dead33..635963c 100644 --- a/src/Storage/PreferenceStorage.js +++ b/src/Storage/PreferenceStorage.js @@ -6,6 +6,23 @@ class PreferenceStorage extends PrefixStorage { this.PREFIX = 'pref='; } + async getAll(valuesOnly = false) { + let preferences = await super.getAll(); + if (valuesOnly) { + for (let preferenceKey in preferences) { + preferences[preferenceKey] = preferences[preferenceKey].value; + } + } + return preferences; + } + + async get(key, valueOnly = false) { + let preference = await super.get(key); + if (valueOnly && preference !== undefined) { + preference = preference.value; + } + return preference; + } } export default new PreferenceStorage(); diff --git a/src/__tests__/utils.spec.js b/src/__tests__/utils.spec.js new file mode 100644 index 0000000..cd10d81 --- /dev/null +++ b/src/__tests__/utils.spec.js @@ -0,0 +1,66 @@ +describe('utils', () => { + + const utils = require('../utils'); + + describe('formatString', () => { + it('should return same string without variables', function () { + const string = 'Farouq Nadeeb'; + expect(utils.formatString(string, {})) + .toEqual(string); + }); + + it('should replace alphanumeric variables', function () { + const name = 'Farouq'; + const lastName = 'Nadeeb'; + expect(utils.formatString('{name} {lastName}', { + name, lastName, + })).toEqual(`${name} ${lastName}`); + }); + + it('should replace kebab-case variables', function () { + const name = 'Farouq'; + const lastName = 'Nadeeb'; + expect(utils.formatString('{name} {last-name}', { + name, ['last-name']: lastName, + })).toEqual(`${name} ${lastName}`); + }); + + it('should throw on non-existent variables', function () { + const name = 'Farouq'; + const lastName = 'Nadeeb'; + expect(() => { + utils.formatString('{name} {lastName} - born {dob}', { + name, lastName, + }); + }).toThrow('Cannot find variable \'dob\' in context'); + }); + + }); + + describe('filterByKey', () => { + it('should create object with keys that don\'t start with a string', function () { + expect(utils.filterByKey({ + removeThis: 'lol', + removeThat: 'rofl', + removeAnother: 'do eet!', + keepMe: 'kept', + keepThem: 'kept', + }, (key) => !key.startsWith('remove'))) + .toEqual({ + keepMe: 'kept', + keepThem: 'kept', + }); + }); + + it('should fail without a filter function', function () { + expect(() => { + utils.filterByKey({ + a: true, + b: true, + }); + }).toThrow('undefined is not a function'); + }); + + }); + +}); diff --git a/src/containers.js b/src/containers.js index 6b7152a..2eb1fab 100644 --- a/src/containers.js +++ b/src/containers.js @@ -2,6 +2,16 @@ import Storage from './Storage/HostStorage'; import ContextualIdentity, {NO_CONTAINER} from './ContextualIdentity'; import Tabs from './Tabs'; import PreferenceStorage from './Storage/PreferenceStorage'; +import {filterByKey} from './utils'; +import {buildDefaultContainer} from './defaultContainer'; + +const IGNORED_URLS_REGEX = /^(about|moz-extension):/; + +/** + * Keep track of the tabs we're creating + * tabId: url + */ +const creatingTabs = {}; const createTab = (url, newTabIndex, currentTabId, openerTabId, cookieStoreId) => { Tabs.get(currentTabId).then((currentTab) => { @@ -17,6 +27,7 @@ const createTab = (url, newTabIndex, currentTabId, openerTabId, cookieStoreId) = createOptions.openerTabId = openerTabId; } Tabs.create(createOptions).then((createdTab) => { + creatingTabs[createdTab.id] = url; if (!cookieStoreId && openerTabId) { Tabs.update(createdTab.id, { openerTabId: openerTabId, @@ -38,35 +49,65 @@ const createTab = (url, newTabIndex, currentTabId, openerTabId, cookieStoreId) = }; }; -function handle(url, tabId) { - return Promise.all([ + +async function handle(url, tabId) { + const creatingUrl = creatingTabs[tabId]; + if (IGNORED_URLS_REGEX.test(url) || creatingUrl === url) { + return; + } else if (creatingUrl) { + delete creatingTabs[tabId]; + } + + let [hostMap, preferences, identities, currentTab] = await Promise.all([ Storage.get(url), + PreferenceStorage.getAll(true), ContextualIdentity.getAll(), Tabs.get(tabId), - ]).then(([hostMap, identities, currentTab]) => { + ]); - if (currentTab.incognito || !hostMap) { - return {}; - } + if (currentTab.incognito || !hostMap) { + return {}; + } - const hostIdentity = identities.find((identity) => identity.cookieStoreId === hostMap.cookieStoreId); - const tabIdentity = identities.find((identity) => identity.cookieStoreId === currentTab.cookieStoreId); + const hostIdentity = identities.find((identity) => identity.cookieStoreId === hostMap.cookieStoreId); + const tabIdentity = identities.find((identity) => identity.cookieStoreId === currentTab.cookieStoreId); - if (!hostIdentity) { - return {}; + if (!hostIdentity) { + if (preferences.defaultContainer) { + const defaultContainer = await buildDefaultContainer( + filterByKey(preferences, prefKey => prefKey.startsWith('defaultContainer')), + url + ); + const defaultCookieStoreId = defaultContainer.cookieStoreId; + const defaultIsNoContainer = defaultCookieStoreId === NO_CONTAINER.cookieStoreId; + const tabHasContainer = currentTab.cookieStoreId !== NO_CONTAINER.cookieStoreId; + const tabInDifferentContainer = currentTab.cookieStoreId !== defaultCookieStoreId; + const openInNoContainer = defaultIsNoContainer && tabHasContainer; + if ((tabInDifferentContainer && !openInNoContainer) || openInNoContainer) { + console.debug('Opening', url, 'in default container', defaultCookieStoreId, defaultContainer.name); + return createTab( + url, + currentTab.index + 1, currentTab.id, + currentTab.openerTabId, + defaultCookieStoreId); + } } + return {}; - const openerTabId = currentTab.openerTabId; - if (hostIdentity.cookieStoreId === NO_CONTAINER.cookieStoreId && tabIdentity) { - return createTab(url, currentTab.index + 1, currentTab.id, openerTabId); - } + } - if (hostIdentity.cookieStoreId !== currentTab.cookieStoreId && hostIdentity.cookieStoreId !== NO_CONTAINER.cookieStoreId) { - return createTab(url, currentTab.index + 1, currentTab.id, openerTabId, hostIdentity.cookieStoreId); - } + const openerTabId = currentTab.openerTabId; + if (hostIdentity.cookieStoreId === NO_CONTAINER.cookieStoreId && tabIdentity) { + return createTab(url, currentTab.index + 1, currentTab.id, openerTabId); + } + + if (hostIdentity.cookieStoreId !== currentTab.cookieStoreId && hostIdentity.cookieStoreId !== NO_CONTAINER.cookieStoreId) { + return createTab(url, currentTab.index + 1, currentTab.id, openerTabId, hostIdentity.cookieStoreId); + } + + + return {}; - return {}; - }); } export const webRequestListener = (requestDetails) => { @@ -81,6 +122,5 @@ export const tabUpdatedListener = (tabId, changeInfo) => { if (!changeInfo.url) { return; } - console.log(tabId, 'url changed', changeInfo.url); return handle(changeInfo.url, tabId); }; diff --git a/src/defaultContainer.js b/src/defaultContainer.js new file mode 100644 index 0000000..2fe402d --- /dev/null +++ b/src/defaultContainer.js @@ -0,0 +1,59 @@ +import {formatString} from './utils'; +import HostStorage from './Storage/HostStorage'; +import ContextualIdentities, {NO_CONTAINER} from './ContextualIdentity'; +import ExtendedURL from './ExtendedURL'; +import PreferenceStorage from './Storage/PreferenceStorage'; + +export async function buildDefaultContainer(preferences, url) { + url = new ExtendedURL(url); + let name = preferences['defaultContainer.containerName']; + name = formatString(name, { + ms: Date.now(), + domain: url.domain, + fqdn: url.host, + host: url.host, + tld: url.tld, + }); + + // Get cookieStoreId + const containers = await ContextualIdentities.get(name); + let container; + if (containers.length > 0) { + container = containers[0]; + } else { + // Create a default container + container = await ContextualIdentities.create(name); + } + const cookieStoreId = container.cookieStoreId; + + // Add a rule if necessary + const ruleAddition = preferences['defaultContainer.ruleAddition']; + if (ruleAddition) { + try { + const host = formatString(ruleAddition, { + domain: url.domain, + fqdn: url.host, + host: url.host, + tld: url.tld, + }); + await HostStorage.set({ + host: host, + cookieStoreId, + containerName: name, + enabled: true, + }); + } catch (e) { + console.error('Couldn\'t add rule', ruleAddition, e); + } + } + + const lifetime = preferences['defaultContainer.lifetime']; + if(lifetime !== 'forever' && cookieStoreId !== NO_CONTAINER.cookieStoreId){ + await PreferenceStorage.set({ + key: `containers.${cookieStoreId}.lifetime`, + value: lifetime, + }); + } + + return container; +} diff --git a/src/index.js b/src/index.js index 65daf5a..43999ae 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,7 @@ import './manifest.json'; import '../static/icons/icon.png'; import {tabUpdatedListener, webRequestListener} from './containers'; import {messageExternalListener} from './messageExternalListener'; +import {cleanUpTemporaryContainers, onTabCreated, onTabRemoved} from './temporaryContainers'; browser.webRequest.onBeforeRequest.addListener( webRequestListener, @@ -16,3 +17,9 @@ browser.runtime.onMessageExternal.addListener( browser.tabs.onUpdated.addListener( tabUpdatedListener ); + +browser.tabs.onCreated.addListener(onTabCreated); +browser.tabs.onRemoved.addListener(onTabRemoved); + +// Clean up left over containers at startup +cleanUpTemporaryContainers(); diff --git a/src/temporaryContainers.js b/src/temporaryContainers.js new file mode 100644 index 0000000..3a7053a --- /dev/null +++ b/src/temporaryContainers.js @@ -0,0 +1,97 @@ +// Hoisted and adapted from https://gitlab.com/NamingThingsIsHard/firefox/click-to-contain + +import ContextualIdentities from './ContextualIdentity'; +import PreferenceStorage from './Storage/PreferenceStorage'; +import {filterByKey} from './utils'; + +// Prefer tracking tabs and their contexts to +// calling browser.tabs.query with the contextId +// It's lighter and faster +let tabContexts = {}; + +function countTabsInContext(contextId) { + if (!contextId) { + throw 'Must provide contextId'; + } + return Object.keys(tabContexts).filter((tabId) => tabContexts[tabId] === contextId).length; +} + +/** + * Keep track of the tabs in the contexts we own + * @param tab {Tab} + */ +export function onTabCreated(tab) { + tabContexts[tab.id] = tab.cookieStoreId; +} + +/** + * Deletes temporary containers + * + * @param tabId {int|String} + */ +export async function onTabRemoved(tabId) { + let tabContextId = tabContexts[tabId]; + if (!tabContextId) { + return; + } + delete tabContexts[tabId]; + if (countTabsInContext(tabContextId) > 0) { + return; + } + const contextLifetime = await PreferenceStorage.get( + `containers.${tabContextId}.lifetime`, + true + ); + if (contextLifetime === 'untilLastTab') { + console.info('containerise: Removed temporary container ID:', tabContextId); + return ContextualIdentities.remove(tabContextId); + } +} + + +export function cleanUpTemporaryContainers() { + Promise.all([ + browser.contextualIdentities.query({}), + browser.tabs.query({}), + PreferenceStorage.getAll(true), + ]).then(([containers, tabs, preferences]) => { + preferences = filterByKey(preferences, key => key.startsWith('containers.')); + + const cookieStoreIds = {}; + // Containers with open tabs + const activeCookieStoreIds = tabs.reduce((acc, tab) => { + let cookieTabs = acc[tab.cookieStoreId] || []; + cookieTabs.push(tab); + acc[tab.cookieStoreId] = cookieTabs; + return acc; + }, {}); + // Get rid of existing leftover temporary containers + // Leftover means without open tabs + let promises = containers.filter((container) => { + const cookieStoreId = container.cookieStoreId; + cookieStoreIds[cookieStoreId] = true; + return activeCookieStoreIds[cookieStoreId] === undefined // inactive containers + && preferences[`containers.${cookieStoreId}.lifetime`] === 'untilLastTab'; + }).map((container) => { + console.warn('Removing leftover container: ', container.name); + return ContextualIdentities.remove(container.cookieStoreId); + }); + + // Get rid of leftover/orphaned container preferences + const leftoverPreferences = Object.keys(preferences).filter(prefName => { + // eslint-disable-next-line no-unused-vars + const [prefix, cookieStoreId, ...rest] = prefName.split('.'); + return !cookieStoreIds[cookieStoreId]; + }); + + if (leftoverPreferences.length > 0) { + console.warn('Removing leftover preferences', leftoverPreferences); + promises.push(PreferenceStorage.remove(leftoverPreferences).then(() => { + console.warn('Removed leftover preferences'); + }).catch(console.error)); + } + return Promise.all(promises).then(() => { + browser.storage.local.get().then(console.debug); + }); + }); +} diff --git a/src/ui-preferences/BooleanPreference.js b/src/ui-preferences/BooleanPreference.js index fb29e9f..2d54d7e 100644 --- a/src/ui-preferences/BooleanPreference.js +++ b/src/ui-preferences/BooleanPreference.js @@ -2,12 +2,23 @@ import Preference from './Preference'; export default class BooleanPreference extends Preference { + _createOnChange() { + super._createOnChange('change'); + } + + _buildEl() { + let el = super._buildEl(); + el.type = 'checkbox'; + return el; + } + get() { return this.el.checked; } set({value}) { this.el.checked = !!value; + super.set({value}); } } diff --git a/src/ui-preferences/ChoicePreference.js b/src/ui-preferences/ChoicePreference.js new file mode 100644 index 0000000..7a4cd66 --- /dev/null +++ b/src/ui-preferences/ChoicePreference.js @@ -0,0 +1,45 @@ +import Preference from './Preference'; +import {createEl} from './utils'; + +export default class ChoicePreference extends Preference { + + constructor({name, label, description, choices, defaultValue}) { + super({name, label, description, defaultValue}); + this.choices = choices; + // Make sure we have default choice + if(this._defaultValue === undefined && this.choices.length > 0){ + this._defaultValue = this.choices[0].name; + } + this._addChoices(); + } + + _buildEl() { + return document.createElement('form'); + } + + _addChoices() { + for (let choice of this.choices) { + const checkedAttr = this._defaultValue === choice.name ? 'checked' : ''; + this.el.appendChild(createEl(`