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(`
+ + +
${choice.description}
+
+ `)); + } + } + + get() { + const formData = new FormData(this.el); + return formData.get(this.name); + } + + set({value}) { + for (let $input of this.el.querySelectorAll('input')) { + $input.checked = $input.value === value; + } + super.set({value}); + } +} + +ChoicePreference.TYPE = 'choice'; diff --git a/src/ui-preferences/ContainerPreference.js b/src/ui-preferences/ContainerPreference.js new file mode 100644 index 0000000..72bb266 --- /dev/null +++ b/src/ui-preferences/ContainerPreference.js @@ -0,0 +1,36 @@ +import PreferenceGroup from './PreferenceGroup'; +import ChoicePreference from './ChoicePreference'; + +/** + * Preferences for containers + * + * This will build all the preferences a container can possess e.g lifetime. + * Other preferences like icon, color and exit rules are conceivable + */ +export default class ContainerPreference extends PreferenceGroup { + + constructor({name, label, description}) { + super({name, label, description, preferences: [], toggleable: false}); + this._preferences.push(new ChoicePreference({ + name: `${name}.lifetime`, + label: 'Lifetime', + description: 'How long this container will live', + choices: [ + { + 'name': 'forever', + 'label': 'Forever', + 'description': 'Container will always be present after creation', + }, + { + 'name': 'untilLastTab', + 'label': 'Until last tab is closed', + 'description': 'Closing the last tab in the container will destroy the container', + }, + ], + defaultValue: 'forever', + })); + } + +} + +ContainerPreference.TYPE = 'container'; diff --git a/src/ui-preferences/ContainerPreferenceGroup.js b/src/ui-preferences/ContainerPreferenceGroup.js new file mode 100644 index 0000000..36bfb35 --- /dev/null +++ b/src/ui-preferences/ContainerPreferenceGroup.js @@ -0,0 +1,32 @@ +import ContextualIdentities from '../ContextualIdentity'; +import PreferenceGroup from './PreferenceGroup'; +import ContainerPreference from './ContainerPreference'; + +/** + * Contains a @see{ContainerPreference} for each existing container + */ +export default class ContainerPreferenceGroup extends PreferenceGroup { + + constructor({name, label, description}) { + super({name, label, description, preferences: [], toggleable: false}); + } + + async fillContainer() { + // Get all existing containers and create ContainerPref + this._preferences = (await ContextualIdentities.get()) + .map((container) => { + return new ContainerPreference({ + name: `${this.name}.${container.cookieStoreId}`, + label: container.name, + }); + }); + return super.fillContainer(); + } + + async updateFromDb() { + return Promise.all(this._preferences.map((preference) => preference.updateFromDb())); + } + +} + +ContainerPreferenceGroup.TYPE = 'containers'; diff --git a/src/ui-preferences/Preference.js b/src/ui-preferences/Preference.js index 0f611f7..6ce4e6a 100644 --- a/src/ui-preferences/Preference.js +++ b/src/ui-preferences/Preference.js @@ -1,19 +1,96 @@ import PreferenceStorage from '../Storage/PreferenceStorage'; +import preferenceContainerTemplate from '!!raw-loader!./templates/Preference.html'; +import {createEl, qs} from './utils'; +/** + * @var $container {HTMLElement} Should contain the label, description and other fields + * It's the element that should be added to the DOM + * @var el {HTMLElement} Is the element that will be used to get and set values. + */ export default class Preference { - constructor(name, uiName, description) { + constructor({name, label, description = '', defaultValue}) { this.name = name; - this.ui_name = uiName; + this.label = label; this.description = description; + this._defaultValue = defaultValue; + this.$container = this._buildContainerEl(); + this._valueDb = null; + this._listeners = {}; this.el = this._buildEl(); } + _buildContainerEl() { + return createEl(preferenceContainerTemplate); + } + _buildEl() { - const template = document.querySelector(`template#${this.constructor.TEMPLATE_PREFIX}${this.constructor.TYPE}`); - return template.content - .cloneNode(true) - .querySelector(this.constructor.EL_QS); + let el = document.createElement('input'); + el.classList.add(Preference.EL_CLASS); + return el; + } + + /** + * Registers the event listeners and decides when to trigger + * an onChange event for the preference + * @private + */ + _createOnChange(event='input') { + const listener = (e) => { + e.stopPropagation(); + this._onChange(this.get()); + }; + this.$container.addEventListener(event, listener); + } + + /** + * + * @param newValue + * @private + */ + _onChange(newValue) { + if (newValue === this._valueDb) { + return; + } + this._triggerEvent('change', newValue); + } + + addListener(event, func) { + const listeners = this._listeners[event] || []; + listeners.push(func); + this._listeners[event] = listeners; + } + + removeListener(func, event) { + for (let eventKey of this._listeners) { + if (event !== undefined && event !== eventKey) { + continue; + } + this._listeners[event] = this._listeners[event].filter(listener => listener === func); + } + } + + _triggerEvent(event, ...args) { + const listeners = this._listeners[event]; + if (!listeners) { + return; + } + for (let listener of listeners) { + listener.apply(this, args); + } + } + + /** + * Should fill the fields in {@see $container} with initial preference attributes and add {@see el} to the container + */ + async fillContainer() { + qs('.pref-container__label', this.$container).innerHTML = this.label; + qs('.pref-container__description', this.$container).innerHTML = this.description; + + // Append the el + const elContainer = qs('.pref-el-container', this.$container); + elContainer.appendChild(this.el); + this._createOnChange(); } @@ -26,18 +103,30 @@ export default class Preference { /** * Update UI with the given value - * @abstract */ // eslint-disable-next-line no-unused-vars - set(value) { - throw 'Not implemented'; + set({value}) { + this._valueDb = this.get(); } /** * Updates the UI with the DB value */ async updateFromDb() { - this.set(await this.retrieve()); + let retrieved = undefined; + try { + retrieved = await this.retrieve(); + if (retrieved) { + this.set(retrieved); + } + } catch (e) { + console.warn(e); + } + + if (retrieved === undefined && this._defaultValue !== undefined) { + // The db-object looks like {key, value} + this.set({value: this._defaultValue}); + } } /** @@ -46,7 +135,12 @@ export default class Preference { * @async */ retrieve() { - return PreferenceStorage.get(this.name); + return PreferenceStorage.get(this.name).then((retrieved) => { + if(retrieved){ + this._valueDb = retrieved.value; + } + return retrieved; + }); } /** @@ -55,14 +149,16 @@ export default class Preference { * @async */ update() { + const newValue = this.get(); return PreferenceStorage.set({ key: this.name, - value: this.get(), + value: newValue, + }).then(() => { + this._valueDb = newValue; }); } } -Preference.TEMPLATE_PREFIX = 'preference-t-'; -Preference.EL_QS = '.pref-t-el'; +Preference.EL_CLASS = 'pref-container__el'; Preference.TYPE = null; // Has to be set by subclass diff --git a/src/ui-preferences/PreferenceGroup.js b/src/ui-preferences/PreferenceGroup.js new file mode 100644 index 0000000..ac97c88 --- /dev/null +++ b/src/ui-preferences/PreferenceGroup.js @@ -0,0 +1,87 @@ +import Preference from './Preference'; +import {createEl, qs} from './utils'; +import template from '!!raw-loader!./templates/PreferenceGroup.html'; + +/** + * Groups preferences together and displays them in a manner to reflect that fact. + */ +export default class PreferenceGroup extends Preference { + + /** + * + * @param name {String} The name to be used as prefix for all keys of the preferences + * e.g windowOptions --> preferences will be windowOptions.optionX + * @param label {String} The title to be shown to the user + * @param description {String} The description to be shown to the user + * @param preferences {Preference[]} + * @param toggleable {boolean} Indicates whether the preferences + * can be toggled on and off together + */ + constructor({name, label, description, preferences, toggleable = false}) { + super({name, label, description}); + for (let preference of preferences) { + if (!preference.name.startsWith(`${name}.`)) { + throw `Preference names must start with ${name}`; + } + } + this._preferences = preferences; + this._toggleable = toggleable; + if(this._toggleable){ + this.$container.classList.add('pref-group_toggable'); + } + } + + _buildContainerEl() { + return createEl(template); + } + + _buildEl() { + return this.$container.querySelector(`.${PreferenceGroup.EL_CLASS}`); + } + + async fillContainer() { + qs('.pref-group__label', this.$container).innerHTML = this.label; + qs('.pref-group__description', this.$container).innerHTML = this.description; + this._createOnChange('change'); + const $preferences = this.$container.querySelector('.preferences'); + return Promise.all(this._preferences.map((preference) => { + preference.addListener('change', (newValue) => { + this._triggerEvent('childChange', preference, newValue); + }); + preference.addListener('childChange', (...args) => { + this._triggerEvent('childChange', ...args); + }); + $preferences.appendChild(preference.$container); + return preference.fillContainer(); + })); + } + + get() { + return this._toggleable ? + this.el.checked + : false; + } + + + set({value}) { + if (this._toggleable) { + this.el.checked = value; + super.set({value}); + } + } + + async updateFromDb() { + super.updateFromDb(); + return Promise.all(this._preferences.map((preference) => preference.updateFromDb())); + } + + update() { + return Promise.all([super.update()].concat( + this._preferences.map((preference) => preference.update()) + )); + + } +} + +PreferenceGroup.TYPE = 'group'; +PreferenceGroup.EL_CLASS = 'pref-group__toggle'; diff --git a/src/ui-preferences/StringPreference.js b/src/ui-preferences/StringPreference.js new file mode 100644 index 0000000..a021410 --- /dev/null +++ b/src/ui-preferences/StringPreference.js @@ -0,0 +1,21 @@ +import Preference from './Preference'; + +export default class StringPreference extends Preference { + + _buildEl() { + let el = super._buildEl(); + el.type = 'text'; + return el; + } + + get() { + return this.el.value; + } + + set({value}) { + this.el.value = value; + super.set({value}); + } +} + +StringPreference.TYPE = 'string'; diff --git a/src/ui-preferences/index.html b/src/ui-preferences/index.html index a8db84d..6b39959 100644 --- a/src/ui-preferences/index.html +++ b/src/ui-preferences/index.html @@ -12,23 +12,7 @@ - - - - diff --git a/src/ui-preferences/index.js b/src/ui-preferences/index.js index 2244a96..9e9fcb6 100644 --- a/src/ui-preferences/index.js +++ b/src/ui-preferences/index.js @@ -1,38 +1,51 @@ import './styles/index.scss'; import './index.html'; +import preferencesJson from './preferences.json'; import BooleanPreference from './BooleanPreference'; +import ChoicePreference from './ChoicePreference'; +import ContainerPreferenceGroup from './ContainerPreferenceGroup'; +import PreferenceGroup from './PreferenceGroup'; +import StringPreference from './StringPreference'; +import {qs} from './utils'; + +function buildPreference(prefConf) { + switch (prefConf.type) { + case BooleanPreference.TYPE: + return new BooleanPreference(prefConf); + case ChoicePreference.TYPE: + return new ChoicePreference(prefConf); + case PreferenceGroup.TYPE: + prefConf.preferences = prefConf.preferences.map((groupPrefConf) => { + return buildPreference(Object.assign({}, groupPrefConf, { + name: `${prefConf.name}.${groupPrefConf.name}`, + })); + }); + return new PreferenceGroup(prefConf); + case StringPreference.TYPE: + return new StringPreference(prefConf); + case ContainerPreferenceGroup.TYPE: + return new ContainerPreferenceGroup(prefConf); + default: + throw new TypeError(`unknown preference type ${prefConf.type}`); + } -function qs(selector, el = document) { - return el.querySelector(selector); } // Build the preferences -const preferences = [ - new BooleanPreference('keepOldTabs', - 'Keep old tabs', - 'After a contained tab has been created, the old won\'t be closed'), -]; +let preferences = preferencesJson.map(buildPreference); const preferencesContainer = qs('.preferences-container'); -const preferenceTemplate = qs('template#preference-template').content; -for (const preference of preferences) { - const prefContainer = preferenceTemplate.cloneNode(true); - // Set some attributes - qs('.pref-container__label', prefContainer).innerHTML = preference.ui_name; - qs('.pref-container__description', prefContainer).innerHTML = preference.description; - - // Append the el - const prefTypeContainer = qs('.pref-type-container', prefContainer); - prefTypeContainer.appendChild(preference.el); - - - preferencesContainer.appendChild(prefContainer); - - preference.updateFromDb(); -} - -const $saveButton = qs('#save-button'); -$saveButton.addEventListener('click', async () => { - await Promise.all(preferences.map(preference => preference.update())); +preferences.map(async (preference) => { + // Save preference on UI change + preference.addListener('change', () => { + preference.update(); + }); + preference.addListener('childChange', (child) => { + child.update(); + }); + preferencesContainer.appendChild(preference.$container); + await preference.fillContainer(); + await preference.updateFromDb(); }); + diff --git a/src/ui-preferences/preferences.json b/src/ui-preferences/preferences.json new file mode 100644 index 0000000..13949fe --- /dev/null +++ b/src/ui-preferences/preferences.json @@ -0,0 +1,55 @@ +[ + { + "type": "bool", + "name": "keepOldTabs", + "label": "Keep old tabs", + "description": "After a contained tab has been created, the old won't be closed" + }, + { + "type": "group", + "name": "defaultContainer", + "label": "Default container", + "description": "Which container unmatched URLs will end up in", + "toggleable": true, + "preferences": [ + { + "type": "string", + "name": "containerName", + "label": "Container name", + "description": "What the name of the default container will be. It's possible to use variables here", + "defaultValue": "Default Container" + }, + { + "type": "choice", + "name": "lifetime", + "label": "Lifetime", + "description": "How long a container will live", + "choices": [ + { + "name": "forever", + "label": "Forever", + "description": "Container will always be present after creation" + }, + { + "name": "untilLastTab", + "label": "Until last tab is closed", + "description": "Closing the last tab in the container will destroy the container" + } + ], + "defaultValue": "forever" + }, + { + "type": "string", + "name": "ruleAddition", + "label": "Rule addition", + "description": "The rule to be added to this container once a domain is matched (can be empty)" + } + ] + }, + { + "name": "containers", + "type": "containers", + "label": "Container preferences", + "description": "Preferences for each existing container" + } +] diff --git a/src/ui-preferences/styles/index.scss b/src/ui-preferences/styles/index.scss index 34b7f2a..a80a116 100644 --- a/src/ui-preferences/styles/index.scss +++ b/src/ui-preferences/styles/index.scss @@ -1,11 +1,55 @@ -.pref-container{ - border-width: 1px; - border-radius: 3px; - border-style: ridge; - margin-bottom: 5px; - margin-top: 5px; +// BEM is used for classes +// https://en.bem.info/methodology/quick-start/ + +body { + display: flex; + flex-direction: row; +} + +.pref-container { + border-width: 1px; + border-radius: 3px; + border-style: dashed; + margin-bottom: 5px; + margin-top: 5px; + padding-left: 2px; + padding-right: 2px; } .pref-container .pref-container__label { - font-weight: bold; + font-weight: bold; +} + +.pref-group { + border-width: 1px; + border-radius: 3px; + border-style: ridge; + margin-bottom: 5px; + margin-top: 5px; + padding-left: 2px; + padding-right: 2px; +} + +.pref-group__toggle { + display: none; +} + +.pref-group__label { + font-weight: bolder; + font-size: large; +} + +.pref-group_toggable .pref-group__toggle { + display: unset; +} + +// Groups that aren't activated yet +.pref-group_toggable .pref-group__toggle ~ .preferences { + opacity: 0.5; + pointer-events: none; +} +// Activated groups +.pref-group_toggable .pref-group__toggle:checked ~ .preferences { + opacity: unset; + pointer-events: unset; } diff --git a/src/ui-preferences/templates/Preference.html b/src/ui-preferences/templates/Preference.html new file mode 100644 index 0000000..285e1d3 --- /dev/null +++ b/src/ui-preferences/templates/Preference.html @@ -0,0 +1,8 @@ +
+
+
+ +
+ +
+
diff --git a/src/ui-preferences/templates/PreferenceGroup.html b/src/ui-preferences/templates/PreferenceGroup.html new file mode 100644 index 0000000..3d48dd0 --- /dev/null +++ b/src/ui-preferences/templates/PreferenceGroup.html @@ -0,0 +1,14 @@ +
+
+
+
+
+ + + +
+ +
+
diff --git a/src/ui-preferences/utils.js b/src/ui-preferences/utils.js new file mode 100644 index 0000000..7b430a7 --- /dev/null +++ b/src/ui-preferences/utils.js @@ -0,0 +1,9 @@ +export function createEl(string){ + const $el = document.createElement('div'); + $el.innerHTML = string; + return $el.firstElementChild; +} + +export function qs(selector, el = document) { + return el.querySelector(selector); +} diff --git a/src/ui/CSVEditor.js b/src/ui/CSVEditor.js index 51e7004..45efc57 100644 --- a/src/ui/CSVEditor.js +++ b/src/ui/CSVEditor.js @@ -1,12 +1,11 @@ +import ContextualIdentities from '../ContextualIdentity'; import State from '../State'; import Storage from '../Storage/HostStorage'; -import {qs} from '../utils'; -import {showLoader, hideLoader} from './loader'; -import {showToast, hideToast} from './toast'; -import {cleanHostInput} from '../utils'; +import {cleanHostInput, qs} from '../utils'; +import {hideLoader, showLoader} from './loader'; +import {hideToast, showToast} from './toast'; const HOST_MAPS_SPLIT_KEY = ','; -const COLORS = ['blue', 'turquoise', 'green', 'yellow', 'orange', 'red', 'pink', 'purple']; const csvEditor = qs('.csv-editor'); const openButton = qs('.ce-open-button'); const closeButton = qs('.ce-close-button'); @@ -32,7 +31,7 @@ class CSVEditor { render() { showLoader(); - if(!this.state.urlMaps || !this.state.identities) { + if (!this.state.urlMaps || !this.state.identities) { return false; } @@ -57,11 +56,7 @@ class CSVEditor { async createMissingContainers(missingContainers, maps) { for (const containerName of missingContainers.keys()) { - const identity = await browser.contextualIdentities.create({ - name: containerName, - color: COLORS[Math.floor(Math.random() * COLORS.length)], - icon: 'circle', - }); + const identity = await ContextualIdentities.create(containerName); for (const host of missingContainers.get(containerName)) { this.addIdentity(identity, host, maps); } diff --git a/src/utils.js b/src/utils.js index 1c38db7..2cb0174 100644 --- a/src/utils.js +++ b/src/utils.js @@ -27,13 +27,13 @@ export const sortMaps = (maps) => maps.sort((map1, map2) => { export const domainMatch = (url, map) => { const url_host = getDomain(url); const map_host = getDomain(map); - if (map_host.slice(0,2) !== '*.') return url_host === map_host; + if (map_host.slice(0, 2) !== '*.') return url_host === map_host; // Check wildcard matches in reverse order (com.example.*) const wild_url = url_host.split('.').reverse(); const wild_map = map_host.slice(2).split('.').reverse(); if (wild_url.length < wild_map.length) return false; - for (let i = 0; i < wild_map.length ; ++i) + for (let i = 0; i < wild_map.length; ++i) if (wild_url[i] !== wild_map[i]) return false; return true; }; @@ -47,7 +47,7 @@ export const pathMatch = (url, map) => { const wild_map = map_path.replace('/*', '').split('/'); if (wild_url.length < wild_map.length) return false; - for (let i = 0; i < wild_map.length ; ++i) + for (let i = 0; i < wild_map.length; ++i) if (wild_url[i] !== wild_map[i]) return false; return true; }; @@ -62,7 +62,7 @@ export const urlKeyFromUrl = (url) => { * * Depending on the prefix in the hostmap it'll choose a match method: * - regex - * - TODO: glob + * - glob * - standard * * @param url {URL} @@ -70,19 +70,54 @@ export const urlKeyFromUrl = (url) => { * @return {*} */ export const matchesSavedMap = (url, map) => { - const savedHost = map.host; - if (savedHost[0] === PREFIX_REGEX) { - return new RegExp(savedHost.substr(1)).test(url); - } else if (savedHost[0] === PREFIX_GLOB) { - // turning glob into regex isn't the worst thing: - // 1. * becomes .* - // 2. ? becomes .? - return new RegExp(savedHost.substr(1).replace(/\*/g, '.*').replace(/\?/g, '.?')).test(url); - } else { - const key = urlKeyFromUrl(url); - const _url = ((key.indexOf('/') === -1) ? key.concat('/') : key).toLowerCase(); - const mapHost = ((map.host.indexOf('/') === -1) ? map.host.concat('/') : map.host).toLowerCase(); - return domainMatch(_url, mapHost) && pathMatch(_url, mapHost); - + const savedHost = map.host; + if (savedHost[0] === PREFIX_REGEX) { + const regex = savedHost.substr(1); + try { + return new RegExp(regex).test(url); + } catch (e) { + console.error('couldn\'t test regex', regex, e); } + } else if (savedHost[0] === PREFIX_GLOB) { + // turning glob into regex isn't the worst thing: + // 1. * becomes .* + // 2. ? becomes .? + return new RegExp(savedHost.substr(1).replace(/\*/g, '.*').replace(/\?/g, '.?')).test(url); + } else { + const key = urlKeyFromUrl(url); + const _url = ((key.indexOf('/') === -1) ? key.concat('/') : key).toLowerCase(); + const mapHost = ((map.host.indexOf('/') === -1) ? map.host.concat('/') : map.host).toLowerCase(); + return domainMatch(_url, mapHost) && pathMatch(_url, mapHost); + + } +}; + + +export const filterByKey = (dict, func) => { + return Object.keys(dict) + .filter(func) + .reduce((acc, curr) => { + acc[curr] = dict[curr]; + return acc; + }, {}); }; + +/** + * Replaces occurrences of {variable} in strings + * + * It handles camelCase, kebab-case and snake_case variable names + * + * @param string {String} + * @param context {Object} + * @throws Error when the variable doesn't exist in the context + * @return {String} + */ +export function formatString(string, context) { + return string.replace(/(\{([\w_-]+)\})/g, (match, _, token) => { + const replacement = context[token]; + if (replacement === undefined) { + throw `Cannot find variable '${token}' in context`; + } + return replacement; + }); +}