diff --git a/README.md b/README.md index 5309d62..f0194d6 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ details](https://docs.google.com/document/d/1WQdHTVXROk7dYkSFluc6_hS44tqZjIrG9I- ## Requirements * node 7+ (for jpm) -* Firefox 52+ (For now; aiming at Firefox 51+) +* Firefox 51+ ## Run it @@ -53,7 +53,7 @@ The only way to run the experiment is using an [unbranded version build](https:/ 1. `git clone git@github.com:mozilla/testpilot-containers.git` 2. `cd testpilot-containers` 3. `npm install` -4. `./node_modules/.bin/jpm run -p /Path/To/Firefox/Profiles/{junk}.addon_dev -b FirefoxDeveloperEdition` (where FirefoxDeveloperEdition might be: ~//obj-x86_64-pc-linux-gnu/dist/bin/firefox) +4. `./node_modules/.bin/jpm run -p /Path/To/Firefox/Profiles/{junk}.addon_dev -b FirefoxBeta` (where FirefoxBeta might be: ~//obj-x86_64-pc-linux-gnu/dist/bin/firefox or ~//firefox) Check out the [Browser Toolbox](https://developer.mozilla.org/en-US/docs/Tools/Browser_Toolbox) for more information about debugging add-on code. diff --git a/index.js b/index.js index e1cd577..5db22ac 100644 --- a/index.js +++ b/index.js @@ -1,208 +1,213 @@ /* global require */ const {ContextualIdentityService} = require('resource://gre/modules/ContextualIdentityService.jsm'); +const { Cc, Ci, Cu, Cr } = require('chrome'); const tabs = require('sdk/tabs'); const webExtension = require('sdk/webextension'); +const { viewFor } = require("sdk/view/core"); +var windowUtils = require('sdk/window/utils'); +var tabsUtils = require('sdk/tabs/utils'); -/* Let's start enabling Containers */ -var prefs = [ - [ "privacy.userContext.enabled", true ], - [ "privacy.userContext.ui.enabled", true ], - [ "privacy.usercontext.about_newtab_segregation.enabled", true ], - [ "privacy.usercontext.longPressBehavior", 1 ] -]; +let ContainerService = +{ + _identitiesState: {}, -const prefService = require("sdk/preferences/service"); -prefs.forEach((pref) => { - prefService.set(pref[0], pref[1]); -}); + init() { + // Enabling preferences -const CONTAINER_STORE = 'firefox-container-'; + let prefs = [ + [ "privacy.userContext.enabled", true ], + [ "privacy.userContext.ui.enabled", true ], + [ "privacy.usercontext.about_newtab_segregation.enabled", true ], + [ "privacy.usercontext.longPressBehavior", 1 ] + ]; -const identitiesState = { -}; + const prefService = require("sdk/preferences/service"); + prefs.forEach((pref) => { + prefService.set(pref[0], pref[1]); + }); -function getCookieStoreIdForContainer(containerId) { - return CONTAINER_STORE + containerId; -} + // Message routing -function convert(identity) { - const cookieStoreId = getCookieStoreIdForContainer(identity.userContextId); - let hiddenTabUrls = []; + // only these methods are allowed. We have a 1:1 mapping between messages + // and methods. These methods must return a promise. + let methods = [ + 'hideTabs', + 'showTabs', + 'sortTabs', + 'openTab', + 'queryIdentities', + 'getIdentity', + ]; - if (cookieStoreId in identitiesState) { - hiddenTabUrls = identitiesState[cookieStoreId].hiddenTabUrls; - } - const result = { - name: ContextualIdentityService.getUserContextLabel(identity.userContextId), - icon: identity.icon, - color: identity.color, - cookieStoreId: cookieStoreId, - hiddenTabUrls: hiddenTabUrls - }; + // Map of identities. + ContextualIdentityService.getIdentities().forEach(identity => { + this._identitiesState[identity.userContextId] = { + hiddenTabUrls: [], + openTabs: 0, + }; + }); - return result; -} - -function isContainerCookieStoreId(storeId) { - return storeId !== null && storeId.startsWith(CONTAINER_STORE); -} - -function getContainerForCookieStoreId(storeId) { - if (!isContainerCookieStoreId(storeId)) { - return null; - } - - const containerId = storeId.substring(CONTAINER_STORE.length); - - if (ContextualIdentityService.getIdentityFromId(containerId)) { - return parseInt(containerId, 10); - } - - return null; -} - -function getContainer(cookieStoreId) { - const containerId = getContainerForCookieStoreId(cookieStoreId); - - if (!containerId) { - return Promise.resolve(null); - } - - const identity = ContextualIdentityService.getIdentityFromId(containerId); - - return Promise.resolve(convert(identity)); -} - -function queryContainers(details) { - const identities = []; - - ContextualIdentityService.getIdentities().forEach(identity=> { - if (details && details.name && - ContextualIdentityService.getUserContextLabel(identity.userContextId) !== details.name) { - return; + // It can happen that this jsm is loaded after the opening a container tab. + for (let tab of tabs) { + let xulTab = viewFor(tab); + let userContextId = parseInt(xulTab.getAttribute('usercontextid') || 0, 10); + if (userContextId) { + ++this._identitiesState[userContextId].openTabs; + } } - const convertedIdentity = convert(identity); + tabs.on("open", tab => { + let xulTab = viewFor(tab); + let userContextId = parseInt(xulTab.getAttribute('usercontextid') || 0, 10); + if (userContextId) { + ++this._identitiesState[userContextId].openTabs; + } + }); - identities.push(convertedIdentity); - if (!(convertedIdentity.cookieStoreId in identitiesState)) { - identitiesState[convertedIdentity.cookieStoreId] = {hiddenTabUrls: []}; + tabs.on("close", tab => { + let xulTab = viewFor(tab); + let userContextId = parseInt(xulTab.getAttribute('usercontextid') || 0, 10); + if (userContextId && this._identitiesState[userContextId].openTabs) { + --this._identitiesState[userContextId].openTabs; + } + }); + + // WebExtension startup + + webExtension.startup().then(api => { + api.browser.runtime.onMessage.addListener((message, sender, sendReply) => { + if ("method" in message && methods.indexOf(message.method) != -1) { + sendReply(this[message.method](message)); + } + }); + }); + }, + + // utility methods + + _convert(identity) { + return { + name: ContextualIdentityService.getUserContextLabel(identity.userContextId), + icon: identity.icon, + color: identity.color, + userContextId: identity.userContextId, + hasHiddenTabs: !!this._identitiesState[identity.userContextId].hiddenTabUrls.length, + hasOpenTabs: !!this._identitiesState[identity.userContextId].openTabs, + }; + }, + + // Tabs management + + hideTabs(args) { + return new Promise((resolve, reject) => { + for (let tab of tabs) { + let xulTab = viewFor(tab); + let userContextId = parseInt(xulTab.getAttribute('usercontextid') || 0, 10); + + if ("userContextId" in args && args.userContextId != userContextId) { + continue; + } + + this._identitiesState[args.userContextId].hiddenTabUrls.push(tab.url); + tab.close(); + } + + resolve(null); + }); + }, + + showTabs(args) { + let promises = []; + + for (let url of this._identitiesState[args.userContextId].hiddenTabUrls) { + promises.push(this.openTab({ userContextId: args.userContextId, url })); } - }); - return Promise.resolve(identities); -} + this._identitiesState[args.userContextId].hiddenTabUrls = []; -function createContainer(details) { - const identity = ContextualIdentityService.create(details.name, - details.icon, - details.color); + return Promise.all(promises); + }, - return Promise.resolve(convert(identity)); -} + sortTabs(args) { + return new Promise((resolve, reject) => { + let windows = windowUtils.windows('navigator:browser', {includePrivate:false}); + for (let window of windows) { + let tabs = tabsUtils.getTabs(window); -function updateContainer(cookieStoreId, details) { - const containerId = getContainerForCookieStoreId(cookieStoreId); + let pos = 0; - if (!containerId) { - return Promise.resolve(null); - } + // Let's collect UCIs/tabs for this window. + let map = new Map; + for (let tab of tabs) { + if (tabsUtils.isPinned(tab)) { + // pinned tabs must be consider as taken positions. + ++pos; + continue; + } - const identity = ContextualIdentityService.getIdentityFromId(containerId); + let userContextId = parseInt(tab.getAttribute('usercontextid') || 0, 10); + if (!map.has(userContextId)) { + map.set(userContextId, []); + } + map.get(userContextId).push(tab); + } - if (!identity) { - return Promise.resolve(null); - } + // Let's sort the map. + let sortMap = new Map([...map.entries()].sort((a, b) => a[0] > b[0])); - if (details.name !== null) { - identity.name = details.name; - } + // Let's move tabs. + for (let [userContextId, tabs] of sortMap) { + for (let tab of tabs) { + window.gBrowser.moveTabTo(tab, pos++); + } + } + } - if (details.color !== null) { - identity.color = details.color; - } + resolve(null); + }); + }, - if (details.icon !== null) { - identity.icon = details.icon; - } + openTab(args) { + return new Promise((resolve, reject) => { + let browserWin = windowUtils.getMostRecentBrowserWindow(); - if (!ContextualIdentityService.update(identity.userContextId, - identity.name, identity.icon, - identity.color)) { - return Promise.resolve(null); - } + // This should not really happen. + if (!browserWin || !browserWin.gBrowser) { + return Promise.resolve(false); + } - return Promise.resolve(convert(identity)); -} + let userContextId = 0; + if ('userContextId' in args) { + userContextId = args.userContextId; + } -function removeContainer(cookieStoreId) { - const containerId = getContainerForCookieStoreId(cookieStoreId); + let tab = browserWin.gBrowser.addTab(args.url || null, + { userContextId: userContextId }) + browserWin.gBrowser.selectedTab = tab; + resolve(true); + }); + }, - if (!containerId) { - return Promise.resolve(null); - } + // Identities management - const identity = ContextualIdentityService.getIdentityFromId(containerId); + queryIdentities(args) { + return new Promise((resolve, reject) => { + let identities = []; - if (!identity) { - return Promise.resolve(null); - } + ContextualIdentityService.getIdentities().forEach(identity => { + let convertedIdentity = this._convert(identity); + identities.push(convertedIdentity); + }); - // We have to create the identity object before removing it. - const convertedIdentity = convert(identity); + resolve(identities); + }); + }, - if (!ContextualIdentityService.remove(identity.userContextId)) { - return Promise.resolve(null); - } - - return Promise.resolve(convertedIdentity); -} - -const contextualIdentities = { - get: getContainer, - query: queryContainers, - create: createContainer, - update: updateContainer, - remove: removeContainer + getIdentity(args) { + let identity = ContextualIdentityService.getIdentityFromId(args.userContextId); + return Promise.resolve(identity ? this._convert(identity) : null); + }, }; -function handleWebExtensionMessage(message, sender, sendReply) { - switch (message.method) { - case 'query': - sendReply(contextualIdentities.query(message.arguments)); - break; - case 'hide': - identitiesState[message.cookieStoreId].hiddenTabUrls = message.tabUrlsToSave; - break; - case 'show': - sendReply(identitiesState[message.cookieStoreId].hiddenTabUrls); - identitiesState[message.cookieStoreId].hiddenTabUrls = []; - break; - case 'get': - sendReply(contextualIdentities.get(message.arguments)); - break; - case 'create': - sendReply(contextualIdentities.create(message.arguments)); - break; - case 'update': - sendReply(contextualIdentities.update(message.arguments)); - break; - case 'remove': - sendReply(contextualIdentities.remove(message.arguments)); - break; - case 'getIdentitiesState': - sendReply(identitiesState); - break; - case 'open-containers-preferences': - tabs.open('about:preferences#containers'); - sendReply({content: 'opened'}); - break; - } -} - -webExtension.startup().then(api=> { - const {browser} = api; - - browser.runtime.onMessage.addListener(handleWebExtensionMessage); -}); +ContainerService.init(); diff --git a/webextension/css/popup.css b/webextension/css/popup.css index 6edbabf..d4d0088 100644 --- a/webextension/css/popup.css +++ b/webextension/css/popup.css @@ -98,21 +98,21 @@ table.unstriped tbody tr { } [data-identity-icon="fingerprint"] { - --identity-icon: url("chrome://browser/content/usercontext.svg#fingerprint"); + --identity-icon: url("/img/usercontext.svg#fingerprint"); } [data-identity-icon="briefcase"] { - --identity-icon: url("chrome://browser/content/usercontext.svg#briefcase"); + --identity-icon: url("/img/usercontext.svg#briefcase"); } [data-identity-icon="dollar"] { - --identity-icon: url("chrome://browser/content/usercontext.svg#dollar"); + --identity-icon: url("/img/usercontext.svg#dollar"); } [data-identity-icon="cart"] { - --identity-icon: url("chrome://browser/content/usercontext.svg#cart"); + --identity-icon: url("/img/usercontext.svg#cart"); } [data-identity-icon="circle"] { - --identity-icon: url("chrome://browser/content/usercontext.svg#circle"); + --identity-icon: url("/img/usercontext.svg#circle"); } diff --git a/webextension/js/popup.js b/webextension/js/popup.js index fb3fbd2..f960115 100644 --- a/webextension/js/popup.js +++ b/webextension/js/popup.js @@ -2,42 +2,67 @@ const CONTAINER_HIDE_SRC = '/img/container-hide.svg'; const CONTAINER_UNHIDE_SRC = '/img/container-unhide.svg'; -function hideContainerTabs(containerId) { - const tabIdsToRemove = []; - const tabUrlsToSave = []; - const hideorshowIcon = document.querySelector(`#${containerId}-hideorshow-icon`); +function showOrHideContainerTabs(userContextId, hasHiddenTabs) { + return new Promise((resolve, reject) => { + const hideorshowIcon = document.querySelector(`#uci-${userContextId}-hideorshow-icon`); - browser.tabs.query({cookieStoreId: containerId}).then(tabs=> { - tabs.forEach(tab=> { - tabIdsToRemove.push(tab.id); - tabUrlsToSave.push(tab.url); - }); browser.runtime.sendMessage({ - method: 'hide', - cookieStoreId: containerId, - tabUrlsToSave: tabUrlsToSave - }).then(()=> { - browser.tabs.remove(tabIdsToRemove); - hideorshowIcon.src = CONTAINER_UNHIDE_SRC; - }); + method: hasHiddenTabs ? 'showTabs' : 'hideTabs', + userContextId: userContextId + }).then(() => { + return browser.runtime.sendMessage({ + method: 'getIdentity', + userContextId: userContextId + }); + }).then((identity) => { + if (!identity.hasHiddenTabs && !identity.hasOpenTabs) { + hideorshowIcon.style.display = "none"; + } else { + hideorshowIcon.style.display = ""; + } + + hideorshowIcon.src = hasHiddenTabs ? CONTAINER_HIDE_SRC : CONTAINER_UNHIDE_SRC; + }).then(resolve); }); } -function showContainerTabs(containerId) { - const hideorshowIcon = document.querySelector(`#${containerId}-hideorshow-icon`); +// In FF 50-51, the icon is the full path, in 52 and following releases, we +// have IDs to be used with a svg file. In this function we map URLs to svg IDs. +function getIconAndColorForIdentity(identity) { + let image, color; - browser.runtime.sendMessage({ - method: 'show', - cookieStoreId: containerId - }).then(hiddenTabUrls=> { - hiddenTabUrls.forEach(url=> { - browser.tabs.create({ - url: url, - cookieStoreId: containerId - }); - }); - }); - hideorshowIcon.src = CONTAINER_HIDE_SRC; + if (identity.icon == "fingerprint" || + identity.icon == "chrome://browser/skin/usercontext/personal.svg") { + image = "fingerprint"; + } else if (identity.icon == "briefcase" || + identity.icon == "chrome://browser/skin/usercontext/work.svg") { + image = "briefcase"; + } else if (identity.icon == "dollar" || + identity.icon == "chrome://browser/skin/usercontext/banking.svg") { + image = "dollar"; + } else if (identity.icon == "cart" || + identity.icon == "chrome://browser/skin/usercontext/shopping.svg") { + image = "cart"; + } else { + image = "circle"; + } + + if (identity.color == "#00a7e0") { + color = "blue"; + } else if (identity.color == "#f89c24") { + color = "orange"; + } else if (identity.color == "#7dc14c") { + color = "green"; + } else if (identity.color == "#ee5195") { + color = "pink"; + } else if (["blue", "turquoise", "green", "yellow", "orange", "red", + "pink", "purple"].indexOf(identity.color) != -1) { + color = identity.color; + } else { + color = ""; + } + + return { image, color }; } if (localStorage.getItem('onboarded2')) { @@ -67,21 +92,24 @@ document.querySelector('#onboarding-done-button').addEventListener('click', ()=> document.querySelector('#container-panel').classList.remove('hide'); }); -browser.runtime.sendMessage({method: 'query'}).then(identities=> { +browser.runtime.sendMessage({method: 'queryIdentities'}).then(identities=> { const identitiesListElement = document.querySelector('.identities-list'); identities.forEach(identity=> { let hideOrShowIconSrc = CONTAINER_HIDE_SRC; - if (identity.hiddenTabUrls.length) { + if (identity.hasHiddenTabs) { hideOrShowIconSrc = CONTAINER_UNHIDE_SRC; } + + let {image, color} = getIconAndColorForIdentity(identity); + const identityRow = ` - +
+ data-identity-icon="${image}" + data-identity-color="${color}">
${identity.name} @@ -94,8 +122,8 @@ browser.runtime.sendMessage({method: 'query'}).then(identities=> { @@ -104,61 +132,54 @@ browser.runtime.sendMessage({method: 'query'}).then(identities=> { `; identitiesListElement.innerHTML += identityRow; + + // No tabs, no icon. + if (!identity.hasHiddenTabs && !identity.hasOpenTabs) { + const hideorshowIcon = document.querySelector(`#uci-${identity.userContextId}-hideorshow-icon`); + hideorshowIcon.style.display = "none"; + } }); const rows = identitiesListElement.querySelectorAll('tr'); rows.forEach(row=> { row.addEventListener('click', e=> { - const containerId = e.target.parentElement.parentElement.dataset.identityCookieStoreId; + const userContextId = e.target.parentElement.parentElement.dataset.identityCookieStoreId; if (e.target.matches('.hideorshow-icon')) { - browser.runtime.sendMessage({method: 'getIdentitiesState'}).then(identitiesState=> { - if (identitiesState[containerId].hiddenTabUrls.length) { - showContainerTabs(containerId); - } else { - hideContainerTabs(containerId); - } + browser.runtime.sendMessage({ + method: 'getIdentity', + userContextId + }).then(identity=> { + showOrHideContainerTabs(userContextId, identity.hasHiddenTabs); }); } else if (e.target.matches('.newtab-icon')) { - browser.tabs.create({cookieStoreId: containerId}); - window.close(); + showOrHideContainerTabs(userContextId, true).then(() => { + browser.runtime.sendMessage({ + method: 'openTab', + userContextId: userContextId}) + .then(() => { + window.close(); + }); + }); } }); }); }); document.querySelector('#edit-containers-link').addEventListener('click', ()=> { - browser.runtime.sendMessage({method: 'open-containers-preferences'}).then(()=> { + browser.runtime.sendMessage({ + method: 'openTab', + url: "about:preferences#containers" + }).then(()=> { window.close(); }); }); -function moveTabs(sortedTabsArray) { - let positionIndex = 0; - - sortedTabsArray.forEach(tabID=> { - browser.tabs.move(tabID, {index: positionIndex}); - positionIndex++; - }); -} - document.querySelector('#sort-containers-link').addEventListener('click', ()=> { - browser.runtime.sendMessage({method: 'query'}).then(identities=> { - identities.unshift({cookieStoreId: 'firefox-default'}); - - browser.tabs.query({}).then(tabsArray=> { - const sortedTabsArray = []; - - identities.forEach(identity=> { - tabsArray.forEach(tab=> { - if (tab.cookieStoreId === identity.cookieStoreId) { - sortedTabsArray.push(tab.id); - } - }); - }); - - moveTabs(sortedTabsArray); - }); + browser.runtime.sendMessage({ + method: 'sortTabs' + }).then(()=> { + window.close(); }); });