From 0ec7e4aee393530431ed421224cc37d234f94135 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Mon, 3 Jul 2017 11:11:48 -0700 Subject: [PATCH 01/10] Use new theme_icons manifest for specifying light and dark icons. Fixes #603 --- webextension/background.js | 11 ++++++++++- webextension/manifest.json | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/webextension/background.js b/webextension/background.js index 2884dfd..8effdd4 100644 --- a/webextension/background.js +++ b/webextension/background.js @@ -1,5 +1,6 @@ const MAJOR_VERSIONS = ["2.3.0", "2.4.0"]; const LOOKUP_KEY = "$ref"; +const THEME_BUILD_DATE = 20170630; const assignManager = { MENU_ASSIGN_ID: "open-in-this-container", @@ -594,10 +595,18 @@ const messageHandler = { const themeManager = { existingTheme: null, - init() { + disabled: false, + async init() { + const browserInfo = await browser.runtime.getBrowserInfo(); + if (Number(browserInfo.buildID.substring(0, 8)) >= THEME_BUILD_DATE) { + this.disabled = true; + } this.check(); }, setPopupIcon(theme) { + if (this.disabled) { + return; + } let icons = { 16: "img/container-site-d-24.png", 32: "img/container-site-d-48.png" diff --git a/webextension/manifest.json b/webextension/manifest.json index 8f7dec0..9d0d04c 100644 --- a/webextension/manifest.json +++ b/webextension/manifest.json @@ -48,6 +48,38 @@ "16": "img/container-site-d-24.png", "32": "img/container-site-d-48.png" }, + "theme_icons": [ + { + "size": 16, + "dark": "img/container-site-d-24.png", + "light": "img/container-site-w-24.png" + }, + { + "size": 24, + "dark": "img/container-site-d-24.png", + "light": "img/container-site-w-24.png" + }, + { + "size": 32, + "dark": "img/container-site-d-48.png", + "light": "img/container-site-w-48.png" + }, + { + "size": 48, + "dark": "img/container-site-d-48.png", + "light": "img/container-site-w-48.png" + }, + { + "size": 96, + "dark": "img/container-site-d-96.png", + "light": "img/container-site-w-96.png" + }, + { + "size": 192, + "dark": "img/container-site-d-192.png", + "light": "img/container-site-w-192.png" + } + ], "default_title": "Containers", "default_popup": "popup.html" }, From 175cdc1a6b166bc8681e3f30407f32686c464776 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 20 Jun 2017 18:48:19 +0100 Subject: [PATCH 02/10] Break my SDK code --- index.js | 1443 +----------------------------------- index.js.bk | 1060 ++++++++++++++++++++++++++ webextension/background.js | 399 +++++++++- webextension/js/popup.js | 14 +- 4 files changed, 1451 insertions(+), 1465 deletions(-) create mode 100644 index.js.bk diff --git a/index.js b/index.js index afdfa61..84fb0ee 100644 --- a/index.js +++ b/index.js @@ -1,1443 +1,2 @@ -/* 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/. */ - -const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; -const DEFAULT_TAB = "about:newtab"; -const LOOKUP_KEY = "$ref"; - -const INCOMPATIBLE_ADDON_IDS = [ - "pulse@mozilla.com", - "snoozetabs@mozilla.com", - "jid1-NeEaf3sAHdKHPA@jetpack" // PageShot -]; - -const IDENTITY_COLORS = [ - { name: "blue", color: "#00a7e0" }, - { name: "turquoise", color: "#01bdad" }, - { name: "green", color: "#7dc14c" }, - { name: "yellow", color: "#ffcb00" }, - { name: "orange", color: "#f89c24" }, - { name: "red", color: "#d92215" }, - { name: "pink", color: "#ee5195" }, - { name: "purple", color: "#7a2f7a" }, -]; - -const IDENTITY_ICONS = [ - { name: "fingerprint", image: "chrome://browser/skin/usercontext/personal.svg" }, - { name: "briefcase", image: "chrome://browser/skin/usercontext/work.svg" }, - { name: "dollar", image: "chrome://browser/skin/usercontext/banking.svg" }, - { name: "cart", image: "chrome://browser/skin/usercontext/shopping.svg" }, - // All of these do not exist in gecko - { name: "gift", image: "gift" }, - { name: "vacation", image: "vacation" }, - { name: "food", image: "food" }, - { name: "fruit", image: "fruit" }, - { name: "pet", image: "pet" }, - { name: "tree", image: "tree" }, - { name: "chill", image: "chill" }, - { name: "circle", image: "circle" }, -]; - -const IDENTITY_COLORS_STANDARD = [ - "blue", "orange", "green", "pink", -]; - -const IDENTITY_ICONS_STANDARD = [ - "fingerprint", "briefcase", "dollar", "cart", -]; - -const PREFS = [ - [ "privacy.userContext.enabled", true ], - [ "privacy.userContext.longPressBehavior", 2 ], - [ "privacy.userContext.ui.enabled", false ], - [ "privacy.usercontext.about_newtab_segregation.enabled", true ], -]; - -const { AddonManager } = require("resource://gre/modules/AddonManager.jsm"); -const { attachTo, detachFrom } = require("sdk/content/mod"); -const { Cu } = require("chrome"); -const { ContextualIdentityService } = require("resource://gre/modules/ContextualIdentityService.jsm"); -const { getFavicon } = require("sdk/places/favicon"); -const { LightweightThemeManager } = Cu.import("resource://gre/modules/LightweightThemeManager.jsm", {}); -const Metrics = require("./testpilot-metrics"); -const { modelFor } = require("sdk/model/core"); -const prefService = require("sdk/preferences/service"); -const self = require("sdk/self"); -const { Services } = require("resource://gre/modules/Services.jsm"); -const ss = require("sdk/simple-storage"); -const { study } = require("./study"); -const { Style } = require("sdk/stylesheet/style"); -const tabs = require("sdk/tabs"); -const tabsUtils = require("sdk/tabs/utils"); -const uuid = require("sdk/util/uuid"); -const { viewFor } = require("sdk/view/core"); const webExtension = require("sdk/webextension"); -const windows = require("sdk/windows"); -const windowUtils = require("sdk/window/utils"); - -Cu.import("resource:///modules/CustomizableUI.jsm"); -Cu.import("resource:///modules/CustomizableWidgets.jsm"); -Cu.import("resource:///modules/sessionstore/SessionStore.jsm"); -Cu.import("resource://gre/modules/Services.jsm"); - -// ContextualIdentityProxy - -const ContextualIdentityProxy = { - getIdentities() { - let response; - if ("getPublicIdentities" in ContextualIdentityService) { - response = ContextualIdentityService.getPublicIdentities(); - } else { - response = ContextualIdentityService.getIdentities(); - } - - return response.map((identity) => { - return this._convert(identity); - }); - }, - - getIdentityFromId(userContextId) { - let response; - if ("getPublicIdentityFromId" in ContextualIdentityService) { - response = ContextualIdentityService.getPublicIdentityFromId(userContextId); - } else { - response = ContextualIdentityService.getIdentityFromId(userContextId); - } - if (response) { - return this._convert(response); - } - return response; - }, - - _convert(identity) { - return { - name: ContextualIdentityService.getUserContextLabel(identity.userContextId), - icon: identity.icon, - color: identity.color, - userContextId: identity.userContextId, - }; - }, -}; - -// ---------------------------------------------------------------------------- -// ContainerService - -const ContainerService = { - _identitiesState: {}, - _windowMap: new Map(), - _containerWasEnabled: false, - _onBackgroundConnectCallback: null, - - async init(installation, reason) { - // If we are just been installed, we must store some information for the - // uninstallation. This object contains also a version number, in case we - // need to implement a migration in the future. - // In 1.1.1 and less we deleted savedConfiguration on upgrade so we need to rebuild - if (!("savedConfiguration" in ss.storage) || - !("prefs" in ss.storage.savedConfiguration) || - (installation && reason !== "upgrade")) { - let preInstalledIdentities = []; // eslint-disable-line prefer-const - ContextualIdentityProxy.getIdentities().forEach(identity => { - preInstalledIdentities.push(identity.userContextId); - }); - - const object = { - version: 1, - prefs: {}, - metricsUUID: uuid.uuid().toString(), - preInstalledIdentities: preInstalledIdentities - }; - - PREFS.forEach(pref => { - object.prefs[pref[0]] = prefService.get(pref[0]); - }); - - ss.storage.savedConfiguration = object; - - if (prefService.get("privacy.userContext.enabled") !== true) { - // Maybe rename the Banking container. - const identity = ContextualIdentityProxy.getIdentityFromId(3); - if (identity && identity.l10nID === "userContextBanking.label") { - ContextualIdentityService.update(identity.userContextId, - "Finance", - identity.icon, - identity.color); - } - - // Let's create the default containers in case there are none. - if (ss.storage.savedConfiguration.preInstalledIdentities.length === 0) { - // Note: we have to create them in this way because there is no way to - // reuse the same ID and the localized strings. - ContextualIdentityService.create("Personal", "fingerprint", "blue"); - ContextualIdentityService.create("Work", "briefcase", "orange"); - ContextualIdentityService.create("Finance", "dollar", "green"); - ContextualIdentityService.create("Shopping", "cart", "pink"); - } - } - } - - // TOCHECK should this run on all code - ContextualIdentityProxy.getIdentities().forEach(identity => { - const newIcon = this._fromIconToName(identity.icon); - const newColor = this._fromColorToName(identity.color); - if (newIcon !== identity.icon || newColor !== identity.color) { - ContextualIdentityService.update(identity.userContextId, - ContextualIdentityService.getUserContextLabel(identity.userContextId), - newIcon, - newColor); - } - }); - - // Let's see if containers were enabled before this addon. - this._containerWasEnabled = - ss.storage.savedConfiguration.prefs["privacy.userContext.enabled"]; - - // Enabling preferences - - PREFS.forEach((pref) => { - prefService.set(pref[0], pref[1]); - }); - - this._metricsUUID = ss.storage.savedConfiguration.metricsUUID; - - // Disabling the customizable container panel. - CustomizableUI.destroyWidget("containers-panelmenu"); - - // Message routing - - // only these methods are allowed. We have a 1:1 mapping between messages - // and methods. These methods must return a promise. - const methods = [ - "hideTabs", - "showTabs", - "sortTabs", - "getTabs", - "showTab", - "moveTabsToWindow", - "queryIdentitiesState", - "getIdentity", - "getPreference", - "sendTelemetryPayload", - "getTheme", - "getShieldStudyVariation", - "refreshNeeded", - "forgetIdentityAndRefresh", - "checkIncompatibleAddons" - ]; - - // Map of identities. - ContextualIdentityProxy.getIdentities().forEach(identity => { - this._remapTabsIfMissing(identity.userContextId); - }); - - // Let's restore the hidden tabs from the previous session. - if (prefService.get("browser.startup.page") === 3 && - "identitiesData" in ss.storage) { - ContextualIdentityProxy.getIdentities().forEach(identity => { - if (identity.userContextId in ss.storage.identitiesData && - "hiddenTabs" in ss.storage.identitiesData[identity.userContextId]) { - this._identitiesState[identity.userContextId].hiddenTabs = - ss.storage.identitiesData[identity.userContextId].hiddenTabs; - } - }); - } - - tabs.on("open", tab => { - this._restyleTab(tab); - this._remapTab(tab); - }); - - tabs.on("close", tab => { - this._remapTab(tab); - }); - - tabs.on("activate", tab => { - this._restyleActiveTab(tab).catch(() => {}); - this._configureActiveWindows(); - this._remapTab(tab); - }); - - // Modify CSS and other stuff for each window. - - this._configureWindows().catch(() => {}); - - windows.browserWindows.on("open", window => { - this._configureWindow(viewFor(window)).catch(() => {}); - }); - - windows.browserWindows.on("close", window => { - this.closeWindow(viewFor(window)); - }); - - // WebExtension startup - - try { - const api = await webExtension.startup(); - api.browser.runtime.onMessage.addListener((message, sender, sendReply) => { - if ("method" in message && methods.indexOf(message.method) !== -1) { - sendReply(this[message.method](message)); - } - }); - - this.registerBackgroundConnection(api); - } catch (e) { - throw new Error("WebExtension startup failed. Unable to continue."); - } - - this._sendEvent = new Metrics({ - type: "sdk", - id: self.id, - version: self.version - }).sendEvent; - - // Begin-Of-Hack - ContextualIdentityService.workaroundForCookieManager = function(method, userContextId) { - let identity = method.call(ContextualIdentityService, userContextId); - if (!identity && userContextId) { - identity = { - userContextId, - icon: "", - color: "", - name: "Pending to be deleted", - public: true, - }; - } - - return identity; - }; - - if (!this._oldGetIdentityFromId) { - this._oldGetIdentityFromId = ContextualIdentityService.getIdentityFromId; - } - ContextualIdentityService.getIdentityFromId = function(userContextId) { - return this.workaroundForCookieManager(ContainerService._oldGetIdentityFromId, userContextId); - }; - - if ("getPublicIdentityFromId" in ContextualIdentityService) { - if (!this._oldGetPublicIdentityFromId) { - this._oldGetPublicIdentityFromId = ContextualIdentityService.getPublicIdentityFromId; - } - ContextualIdentityService.getPublicIdentityFromId = function(userContextId) { - return this.workaroundForCookieManager(ContainerService._oldGetPublicIdentityFromId, userContextId); - }; - } - // End-Of-Hack - - Services.obs.addObserver(this, "lightweight-theme-changed", false); - - if (self.id === "@shield-study-containers") { - study.startup(reason); - this.shieldStudyVariation = study.variation; - } - }, - - registerBackgroundConnection(api) { - // This is only used for theme notifications and new tab - api.browser.runtime.onConnect.addListener((port) => { - this._onBackgroundConnectCallback = (message, topic) => { - port.postMessage({ - type: topic, - message - }); - }; - }); - }, - - triggerBackgroundCallback(message, topic) { - if (this._onBackgroundConnectCallback) { - this._onBackgroundConnectCallback(message, topic); - } - }, - - async observe(subject, topic) { - if (topic === "lightweight-theme-changed") { - try { - const theme = await this.getTheme(); - this.triggerBackgroundCallback(theme, topic); - } catch (e) { - throw new Error("Unable to get theme"); - } - } - }, - - getTheme() { - const defaultTheme = "firefox-compact-light@mozilla.org"; - return new Promise(function (resolve) { - let theme = defaultTheme; - if (LightweightThemeManager.currentTheme && LightweightThemeManager.currentTheme.id) { - theme = LightweightThemeManager.currentTheme.id; - } - resolve(theme); - }); - }, - - getShieldStudyVariation() { - return this.shieldStudyVariation; - }, - - // utility methods - - _containerTabCount(userContextId) { - // Returns the total of open and hidden tabs with this userContextId - let containerTabsCount = 0; - containerTabsCount += this._identitiesState[userContextId].openTabs; - containerTabsCount += this._identitiesState[userContextId].hiddenTabs.length; - return containerTabsCount; - }, - - _totalContainerTabsCount() { - // Returns the number of total open tabs across ALL containers - let totalContainerTabsCount = 0; - for (const userContextId in this._identitiesState) { - totalContainerTabsCount += this._identitiesState[userContextId].openTabs; - } - return totalContainerTabsCount; - }, - - _totalNonContainerTabsCount() { - // Returns the number of open tabs NOT IN a container - let totalNonContainerTabsCount = 0; - for (const tab of tabs) { - if (this._getUserContextIdFromTab(tab) === 0) { - ++totalNonContainerTabsCount; - } - } - return totalNonContainerTabsCount; - }, - - _containersCounts() { - let containersCounts = { // eslint-disable-line prefer-const - "shown": 0, - "hidden": 0, - "total": 0 - }; - for (const userContextId in this._identitiesState) { - if (this._identitiesState[userContextId].openTabs > 0) { - ++containersCounts.shown; - ++containersCounts.total; - continue; - } else if (this._identitiesState[userContextId].hiddenTabs.length > 0) { - ++containersCounts.hidden; - ++containersCounts.total; - continue; - } - } - return containersCounts; - }, - - // 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. - - // Helper methods for converting colors to names and names to colors. - - _fromNameToColor(name) { - return this._fromNameOrColor(name, "color"); - }, - - _fromColorToName(color) { - return this._fromNameOrColor(color, "name"); - }, - - _fromNameOrColor(what, attribute) { - for (let color of IDENTITY_COLORS) { // eslint-disable-line prefer-const - if (what === color.color || what === color.name) { - return color[attribute]; - } - } - return ""; - }, - - // Helper methods for converting icons to names and names to icons. - - _fromIconToName(icon) { - return this._fromNameOrIcon(icon, "name", "circle"); - }, - - _fromNameOrIcon(what, attribute, defaultValue) { - for (let icon of IDENTITY_ICONS) { // eslint-disable-line prefer-const - if (what === icon.image || what === icon.name) { - return icon[attribute]; - } - } - return defaultValue; - }, - - // Tab Helpers - - _getUserContextIdFromTab(tab) { - return parseInt(viewFor(tab).getAttribute("usercontextid") || 0, 10); - }, - - async _createTabObject(tab) { - let url; - try { - url = await getFavicon(tab.url); - } catch (e) { - url = ""; - } - return { - title: tab.title, - url: tab.url, - favicon: url, - id: tab.id, - active: true, - pinned: tabsUtils.isPinned(viewFor(tab)) - }; - }, - - _matchTabsByContainer(userContextId) { - const matchedTabs = []; - for (const tab of tabs) { - if (userContextId === this._getUserContextIdFromTab(tab)) { - matchedTabs.push(tab); - } - } - return matchedTabs; - }, - - _createIdentityState() { - return { - hiddenTabs: [], - openTabs: 0 - }; - }, - - _remapTabsIfMissing(userContextId) { - // We already know this userContextId. - if (userContextId in this._identitiesState) { - return; - } - - this._identitiesState[userContextId] = this._createIdentityState(); - this._remapTabsFromUserContextId(userContextId); - }, - - _remapTabsFromUserContextId(userContextId) { - this._identitiesState[userContextId].openTabs = this._matchTabsByContainer(userContextId).length; - }, - - _remapTab(tab) { - const userContextId = this._getUserContextIdFromTab(tab); - if (userContextId) { - this._remapTabsFromUserContextId(userContextId); - } - }, - - _isKnownContainer(userContextId) { - return userContextId in this._identitiesState; - }, - - async _closeTabs(tabsToClose) { - // We create a new tab only if the current operation closes all the - // existing ones. - if (tabs.length === tabsToClose.length) { - await this.openTab({}); - } - - for (const tab of tabsToClose) { - // after .close() window is null. Let's take it now. - const window = viewFor(tab.window); - - tab.close(); - - // forget about this tab. 0 is the index of the forgotten tab and 0 - // means the last one. - try { - SessionStore.forgetClosedTab(window, 0); - } catch (e) {} // eslint-disable-line no-empty - } - }, - - _recentBrowserWindow() { - const browserWin = windowUtils.getMostRecentBrowserWindow(); - - // This should not really happen. - if (!browserWin || !browserWin.gBrowser) { - return Promise.resolve(null); - } - - return Promise.resolve(browserWin); - }, - - _syncTabs() { - // Let's store all what we have. - ss.storage.identitiesData = this._identitiesState; - }, - - sendTelemetryPayload(args = {}) { - // when pings come from popup, delete "method" prop - delete args.method; - let payload = { // eslint-disable-line prefer-const - "uuid": this._metricsUUID - }; - Object.assign(payload, args); - - /* This is to masage the data whilst it is still active in the SDK side */ - const containersCounts = this._containersCounts(); - Object.keys(payload).forEach((keyName) => { - let value = payload[keyName]; - if (value === LOOKUP_KEY) { - switch (keyName) { - case "clickedContainerTabCount": - value = this._containerTabCount(payload.userContextId); - break; - case "shownContainersCount": - value = containersCounts.shown; - break; - case "hiddenContainersCount": - value = containersCounts.hidden; - break; - case "totalContainersCount": - value = containersCounts.total; - break; - } - } - payload[keyName] = value; - }); - - this._sendEvent(payload); - }, - - checkIncompatibleAddons() { - return new Promise(resolve => { - AddonManager.getAddonsByIDs(INCOMPATIBLE_ADDON_IDS, (addons) => { - addons = addons.filter((a) => a && a.isActive); - const incompatibleAddons = addons.length !== 0; - if (incompatibleAddons) { - this.sendTelemetryPayload({ - "event": "incompatible-addons-detected" - }); - } - resolve(incompatibleAddons); - }); - }); - }, - - // Tabs management - - async hideTabs(args) { - if (!("userContextId" in args)) { - return new Error("hideTabs must be called with userContextId argument."); - } - - this._remapTabsIfMissing(args.userContextId); - if (!this._isKnownContainer(args.userContextId)) { - return null; - } - - this.sendTelemetryPayload({ - "event": "hide-tabs", - "userContextId": args.userContextId, - "clickedContainerTabCount": LOOKUP_KEY, - "shownContainersCount": LOOKUP_KEY, - "hiddenContainersCount": LOOKUP_KEY, - "totalContainersCount": LOOKUP_KEY - }); - - const tabsToClose = []; - - const tabObjects = await Promise.all(this._matchTabsByContainer(args.userContextId).map((tab) => { - tabsToClose.push(tab); - return this._createTabObject(tab); - })); - - tabObjects.forEach((object) => { - // This tab is going to be closed. Let's mark this tabObject as - // non-active. - object.active = false; - - this._identitiesState[args.userContextId].hiddenTabs.push(object); - }); - - await this._closeTabs(tabsToClose); - - return this._syncTabs(); - }, - - async showTabs(args) { - if (!("userContextId" in args)) { - return Promise.reject("showTabs must be called with userContextId argument."); - } - - this._remapTabsIfMissing(args.userContextId); - if (!this._isKnownContainer(args.userContextId)) { - return Promise.resolve(null); - } - - this.sendTelemetryPayload({ - "event": "show-tabs", - "userContextId": args.userContextId, - "clickedContainerTabCount": LOOKUP_KEY, - "shownContainersCount": LOOKUP_KEY, - "hiddenContainersCount": LOOKUP_KEY, - "totalContainersCount": LOOKUP_KEY - }); - - const promises = []; - - const hiddenTabs = this._identitiesState[args.userContextId].hiddenTabs; - this._identitiesState[args.userContextId].hiddenTabs = []; - - for (let object of hiddenTabs) { // eslint-disable-line prefer-const - promises.push(this.openTab({ - userContextId: args.userContextId, - url: object.url, - nofocus: args.nofocus || false, - pinned: object.pinned, - })); - } - - this._identitiesState[args.userContextId].hiddenTabs = []; - - await Promise.all(promises); - return this._syncTabs(); - }, - - sortTabs() { - const containersCounts = this._containersCounts(); - this.sendTelemetryPayload({ - "event": "sort-tabs", - "shownContainersCount": containersCounts.shown, - "totalContainerTabsCount": this._totalContainerTabsCount(), - "totalNonContainerTabsCount": this._totalNonContainerTabsCount() - }); - return new Promise(resolve => { - for (let window of windows.browserWindows) { // eslint-disable-line prefer-const - // First the pinned tabs, then the normal ones. - this._sortTabsInternal(window, true); - this._sortTabsInternal(window, false); - } - resolve(null); - }); - }, - - _sortTabsInternal(window, pinnedTabs) { - // From model to XUL window. - const xulWindow = viewFor(window); - - const tabs = tabsUtils.getTabs(xulWindow); - let pos = 0; - - // Let's collect UCIs/tabs for this window. - const map = new Map; - for (const tab of tabs) { - if (pinnedTabs && !tabsUtils.isPinned(tab)) { - // We don't have, or we already handled all the pinned tabs. - break; - } - - if (!pinnedTabs && tabsUtils.isPinned(tab)) { - // pinned tabs must be consider as taken positions. - ++pos; - continue; - } - - const userContextId = this._getUserContextIdFromTab(tab); - if (!map.has(userContextId)) { - map.set(userContextId, []); - } - map.get(userContextId).push(tab); - } - - // Let's sort the map. - const sortMap = new Map([...map.entries()].sort((a, b) => a[0] > b[0])); - - // Let's move tabs. - sortMap.forEach(tabs => { - for (const tab of tabs) { - xulWindow.gBrowser.moveTabTo(tab, pos++); - } - }); - }, - - async getTabs(args) { - if (!("userContextId" in args)) { - return new Error("getTabs must be called with userContextId argument."); - } - - this._remapTabsIfMissing(args.userContextId); - if (!this._isKnownContainer(args.userContextId)) { - return []; - } - - const promises = []; - this._matchTabsByContainer(args.userContextId).forEach((tab) => { - promises.push(this._createTabObject(tab)); - }); - - const list = await Promise.all(promises); - return list.concat(this._identitiesState[args.userContextId].hiddenTabs); - }, - - showTab(args) { - return new Promise((resolve, reject) => { - if (!("tabId" in args)) { - reject("showTab must be called with tabId argument."); - return; - } - - for (const tab of tabs) { - if (tab.id === args.tabId) { - tab.window.activate(); - tab.activate(); - break; - } - } - - resolve(null); - }); - }, - - moveTabsToWindow(args) { - return new Promise((resolve, reject) => { - if (!("userContextId" in args)) { - reject("moveTabsToWindow must be called with userContextId argument."); - return; - } - - this._remapTabsIfMissing(args.userContextId); - if (!this._isKnownContainer(args.userContextId)) { - return Promise.resolve(null); - } - - this.sendTelemetryPayload({ - "event": "move-tabs-to-window", - "userContextId": args.userContextId, - "clickedContainerTabCount": this._containerTabCount(args.userContextId), - }); - - const list = this._matchTabsByContainer(args.userContextId); - - // Nothing to do - if (list.length === 0 && - this._identitiesState[args.userContextId].hiddenTabs.length === 0) { - resolve(null); - return; - } - - windows.browserWindows.open({ - url: "about:blank", - onOpen: window => { - const newBrowserWindow = viewFor(window); - let pos = 0; - - // Let's move the tab to the new window. - for (let tab of list) { // eslint-disable-line prefer-const - newBrowserWindow.gBrowser.adoptTab(viewFor(tab), pos++, false); - } - - // Let's show the hidden tabs. - for (let object of this._identitiesState[args.userContextId].hiddenTabs) { // eslint-disable-line prefer-const - newBrowserWindow.gBrowser.addTab(object.url || DEFAULT_TAB, { userContextId: args.userContextId }); - } - - this._identitiesState[args.userContextId].hiddenTabs = []; - - // Let's close all the normal tab in the new window. In theory it - // should be only the first tab, but maybe there are addons doing - // crazy stuff. - for (let tab of window.tabs) { // eslint-disable-line prefer-const - const userContextId = this._getUserContextIdFromTab(tab); - if (args.userContextId !== userContextId) { - newBrowserWindow.gBrowser.removeTab(viewFor(tab)); - } - } - resolve(null); - }, - }); - }); - }, - - openTab(args) { - return this.triggerBackgroundCallback(args, "open-tab"); - }, - - // Identities management - queryIdentitiesState() { - return new Promise(resolve => { - const identities = {}; - - ContextualIdentityProxy.getIdentities().forEach(identity => { - this._remapTabsIfMissing(identity.userContextId); - const convertedIdentity = { - hasHiddenTabs: !!this._identitiesState[identity.userContextId].hiddenTabs.length, - hasOpenTabs: !!this._identitiesState[identity.userContextId].openTabs - }; - - identities[identity.userContextId] = convertedIdentity; - }); - - resolve(identities); - }); - }, - - queryIdentities() { - return new Promise(resolve => { - const identities = ContextualIdentityProxy.getIdentities(); - identities.forEach(identity => { - this._remapTabsIfMissing(identity.userContextId); - }); - - resolve(identities); - }); - }, - - // Preferences - - getPreference(args) { - if (!("pref" in args)) { - return Promise.reject("getPreference must be called with pref argument."); - } - - return Promise.resolve(prefService.get(args.pref)); - }, - - // Styling the window - - _configureWindows() { - const promises = []; - for (let window of windows.browserWindows) { // eslint-disable-line prefer-const - promises.push(this._configureWindow(viewFor(window))); - } - return Promise.all(promises); - }, - - _configureWindow(window) { - return this._getOrCreateContainerWindow(window).configure(); - }, - - _configureActiveWindows() { - const promises = []; - for (let window of windows.browserWindows) { // eslint-disable-line prefer-const - promises.push(this._configureActiveWindow(viewFor(window))); - } - return Promise.all(promises); - }, - - _configureActiveWindow(window) { - return this._getOrCreateContainerWindow(window).configureActive(); - }, - - closeWindow(window) { - this._windowMap.delete(window); - }, - - _getOrCreateContainerWindow(window) { - if (!(this._windowMap.has(window))) { - this._windowMap.set(window, new ContainerWindow(window)); - } - - return this._windowMap.get(window); - }, - - refreshNeeded() { - return this._configureWindows(); - }, - - _restyleActiveTab(tab) { - if (!tab) { - return Promise.resolve(null); - } - - const userContextId = ContainerService._getUserContextIdFromTab(tab); - const identity = ContextualIdentityProxy.getIdentityFromId(userContextId); - const hbox = viewFor(tab.window).document.getElementById("userContext-icons"); - - if (!identity) { - hbox.setAttribute("data-identity-color", ""); - return Promise.resolve(null); - } - - hbox.setAttribute("data-identity-color", identity.color); - - const label = viewFor(tab.window).document.getElementById("userContext-label"); - label.setAttribute("value", identity.name); - label.style.color = ContainerService._fromNameToColor(identity.color); - - const indicator = viewFor(tab.window).document.getElementById("userContext-indicator"); - indicator.setAttribute("data-identity-icon", identity.icon); - indicator.style.listStyleImage = ""; - - return this._restyleTab(tab); - }, - - _restyleTab(tab) { - if (!tab) { - return Promise.resolve(null); - } - const userContextId = ContainerService._getUserContextIdFromTab(tab); - const identity = ContextualIdentityProxy.getIdentityFromId(userContextId); - if (!identity) { - return Promise.resolve(null); - } - return Promise.resolve(viewFor(tab).setAttribute("data-identity-color", identity.color)); - }, - - // Uninstallation - uninstall(reason) { - const data = ss.storage.savedConfiguration; - if (!data) { - throw new DOMError("ERROR - No saved configuration!!"); - } - - if (data.version !== 1) { - throw new DOMError("ERROR - Unknown version!!"); - } - - if (reason !== "upgrade") { - PREFS.forEach(pref => { - if (pref[0] in data.prefs) { - prefService.set(pref[0], data.prefs[pref[0]]); - } - }); - } - - // Note: We cannot go back renaming the Finance identity back to Banking: - // the locale system doesn't work with renamed containers. - - // Restore the customizable container panel. - const widget = CustomizableWidgets.find(widget => widget.id === "containers-panelmenu"); - if (widget) { - CustomizableUI.createWidget(widget); - } - - for (let window of windows.browserWindows) { // eslint-disable-line prefer-const - // Let's close all the container tabs. - // Note: We cannot use _closeTabs() because at this point tab.window is - // null. - if (!this._containerWasEnabled && reason !== "upgrade") { - for (let tab of window.tabs) { // eslint-disable-line prefer-const - if (this._getUserContextIdFromTab(tab)) { - tab.close(); - try { - SessionStore.forgetClosedTab(viewFor(window), 0); - } catch(e) {} // eslint-disable-line no-empty - } - } - } - - this._getOrCreateContainerWindow(viewFor(window)).shutdown(); - } - - // all the configuration must go away now. - this._windowMap = new Map(); - - if (reason !== "upgrade") { - // Let's forget all the previous closed tabs. - this._forgetIdentity(); - - const preInstalledIdentities = data.preInstalledIdentities; - ContextualIdentityProxy.getIdentities().forEach(identity => { - if (!preInstalledIdentities.includes(identity.userContextId)) { - ContextualIdentityService.remove(identity.userContextId); - } else { - // Let's cleanup all the cookies for this container. - Services.obs.notifyObservers(null, "clear-origin-attributes-data", - JSON.stringify({ userContextId: identity.userContextId })); - } - }); - - // Let's delete the configuration. - delete ss.storage.savedConfiguration; - } - - // Begin-Of-Hack - if (this._oldGetIdentityFromId) { - ContextualIdentityService.getIdentityFromId = this._oldGetIdentityFromId; - } - - if (this._oldGetPublicIdentityFromId) { - ContextualIdentityService.getPublicIdentityFromId = this._oldGetPublicIdentityFromId; - } - // End-Of-Hack - }, - - forgetIdentityAndRefresh(args) { - this._forgetIdentity(args.userContextId); - return this.refreshNeeded(); - }, - - _forgetIdentity(userContextId = 0) { - for (let window of windows.browserWindows) { // eslint-disable-line prefer-const - window = viewFor(window); - const closedTabData = JSON.parse(SessionStore.getClosedTabData(window)); - for (let i = closedTabData.length - 1; i >= 0; --i) { - if (!closedTabData[i].state.userContextId) { - continue; - } - - if (userContextId === 0 || - closedTabData[i].state.userContextId === userContextId) { - try { - SessionStore.forgetClosedTab(window, i); - } catch(e) {} // eslint-disable-line no-empty - } - } - } - }, -}; - -// ---------------------------------------------------------------------------- -// ContainerWindow - -// This object is used to configure a single window. -function ContainerWindow(window) { - this._init(window); -} - -ContainerWindow.prototype = { - _window: null, - _style: null, - _panelElement: null, - _timeoutStore: new Map(), - _elementCache: new Map(), - _tooltipCache: new Map(), - _tabsElement: null, - - _init(window) { - this._window = window; - this._tabsElement = this._window.document.getElementById("tabbrowser-tabs"); - this._style = Style({ uri: self.data.url("usercontext.css") }); - this._plusButton = this._window.document.getAnonymousElementByAttribute(this._tabsElement, "anonid", "tabs-newtab-button"); - this._overflowPlusButton = this._window.document.getElementById("new-tab-button"); - - // Only hack the normal plus button as the alltabs is done elsewhere - this.attachMenuEvent("plus-button", this._plusButton); - - attachTo(this._style, this._window); - }, - - attachMenuEvent(source, button) { - const popup = button.querySelector(".new-tab-popup"); - popup.addEventListener("popupshown", () => { - ContainerService.sendTelemetryPayload({ - "event": "show-plus-button-menu", - "eventSource": source - }); - popup.querySelector("menuseparator").remove(); - const popupMenuItems = [...popup.querySelectorAll("menuitem")]; - popupMenuItems.forEach((item) => { - const userContextId = item.getAttribute("data-usercontextid"); - if (!userContextId) { - item.remove(); - } - item.setAttribute("command", ""); - item.addEventListener("command", (e) => { - e.stopPropagation(); - e.preventDefault(); - ContainerService.openTab({ - userContextId: userContextId, - source: source - }); - }); - }); - }); - }, - - configure() { - return Promise.all([ - this._configureActiveTab(), - this._configureFileMenu(), - this._configureAllTabsMenu(), - this._configureTabStyle(), - this.configureActive(), - ]); - }, - - configureActive() { - return this._configureContextMenu(); - }, - - _configureTabStyle() { - const promises = []; - for (let tab of modelFor(this._window).tabs) { // eslint-disable-line prefer-const - promises.push(ContainerService._restyleTab(tab)); - } - return Promise.all(promises); - }, - - _configureActiveTab() { - const tab = modelFor(this._window).tabs.activeTab; - return ContainerService._restyleActiveTab(tab); - }, - - _configureFileMenu() { - return this._configureMenu("menu_newUserContext", null, e => { - const userContextId = parseInt(e.target.getAttribute("data-usercontextid"), 10); - ContainerService.openTab({ - userContextId: userContextId, - source: "file-menu" - }); - }); - }, - - _configureAllTabsMenu() { - return this._configureMenu("alltabs_containersTab", null, e => { - const userContextId = parseInt(e.target.getAttribute("data-usercontextid"), 10); - ContainerService.showTabs({ - userContextId, - nofocus: true, - window: this._window, - }).then(() => { - return ContainerService.openTab({ - userContextId, - source: "alltabs-menu" - }); - }).catch(() => {}); - }); - }, - - _configureContextMenu() { - return Promise.all([ - this._configureMenu("context-openlinkinusercontext-menu", - () => { - // This userContextId is what we want to exclude. - const tab = modelFor(this._window).tabs.activeTab; - return ContainerService._getUserContextIdFromTab(tab); - }, - e => { - // This is a super internal method. Hopefully it will be stable in the - // next FF releases. - this._window.gContextMenu.openLinkInTab(e); - - const userContextId = parseInt(e.target.getAttribute("data-usercontextid"), 10); - ContainerService.showTabs({ - userContextId, - nofocus: true, - window: this._window, - }); - } - ), - this._configureContextMenuOpenLink(), - ]); - }, - - _configureContextMenuOpenLink() { - return new Promise(resolve => { - const self = this; - this._window.gSetUserContextIdAndClick = function(event) { - const tab = modelFor(self._window).tabs.activeTab; - const userContextId = ContainerService._getUserContextIdFromTab(tab); - event.target.setAttribute("data-usercontextid", userContextId); - self._window.gContextMenu.openLinkInTab(event); - }; - - let item = this._window.document.getElementById("context-openlinkincontainertab"); - item.setAttribute("oncommand", "gSetUserContextIdAndClick(event)"); - - item = this._window.document.getElementById("context-openlinkintab"); - item.setAttribute("oncommand", "gSetUserContextIdAndClick(event)"); - - resolve(); - }); - }, - - // Generic menu configuration. - _configureMenu(menuId, excludedContainerCb, clickCb) { - const menu = this._window.document.getElementById(menuId); - if (!this._disableElement(menu)) { - // Delete stale menu that isn't native elements - while (menu.firstChild) { - menu.removeChild(menu.firstChild); - } - } - - const menupopup = this._window.document.createElementNS(XUL_NS, "menupopup"); - menu.appendChild(menupopup); - - menupopup.addEventListener("command", clickCb); - return this._createMenu(menupopup, excludedContainerCb); - }, - - _createMenu(target, excludedContainerCb) { - while (target.hasChildNodes()) { - target.removeChild(target.firstChild); - } - - return new Promise((resolve, reject) => { - ContainerService.queryIdentities().then(identities => { - const fragment = this._window.document.createDocumentFragment(); - - const excludedUserContextId = excludedContainerCb ? excludedContainerCb() : 0; - if (excludedUserContextId) { - const bundle = this._window.document.getElementById("bundle_browser"); - - const menuitem = this._window.document.createElementNS(XUL_NS, "menuitem"); - menuitem.setAttribute("data-usercontextid", "0"); - menuitem.setAttribute("label", bundle.getString("userContextNone.label")); - menuitem.setAttribute("accesskey", bundle.getString("userContextNone.accesskey")); - - fragment.appendChild(menuitem); - - const menuseparator = this._window.document.createElementNS(XUL_NS, "menuseparator"); - fragment.appendChild(menuseparator); - } - - identities.forEach(identity => { - if (identity.userContextId === excludedUserContextId) { - return; - } - - const menuitem = this._window.document.createElementNS(XUL_NS, "menuitem"); - menuitem.setAttribute("label", identity.name); - menuitem.classList.add("menuitem-iconic"); - menuitem.setAttribute("data-usercontextid", identity.userContextId); - menuitem.setAttribute("data-identity-color", identity.color); - menuitem.setAttribute("data-identity-icon", identity.icon); - fragment.appendChild(menuitem); - }); - - target.appendChild(fragment); - resolve(); - }).catch(() => {reject();}); - }); - }, - - // This timer is used to hide the panel auto-magically if it's not used in - // the following X seconds. This is need to avoid the leaking of the panel - // when the mouse goes out of of the 'plus' button. - _createTimeout(key, callback, timeoutTime) { - this._cleanTimeout(key); - this._timeoutStore.set(key, this._window.setTimeout(() => { - callback(); - this._timeoutStore.delete(key); - }, timeoutTime)); - }, - - _cleanAllTimeouts() { - for (let key of this._timeoutStore.keys()) { // eslint-disable-line prefer-const - this._cleanTimeout(key); - } - }, - - _cleanTimeout(key) { - if (this._timeoutStore.has(key)) { - this._window.clearTimeout(this._timeoutStore.get(key)); - this._timeoutStore.delete(key); - } - }, - - shutdown() { - // CSS must be removed. - detachFrom(this._style, this._window); - - this._shutdownFileMenu(); - this._shutdownAllTabsMenu(); - this._shutdownContextMenu(); - - this._shutdownContainers(); - }, - - _shutDownPlusButtonMenuElement(buttonElement) { - if (buttonElement) { - this._shutdownElement(buttonElement); - buttonElement.setAttribute("tooltip", this._tooltipCache.get(buttonElement)); - - buttonElement.removeEventListener("mouseover", this); - buttonElement.removeEventListener("click", this); - buttonElement.removeEventListener("mouseout", this); - } - }, - - _shutdownFileMenu() { - this._shutdownMenu("menu_newUserContext"); - }, - - _shutdownAllTabsMenu() { - this._shutdownMenu("alltabs_containersTab"); - }, - - _shutdownContextMenu() { - this._shutdownMenu("context-openlinkinusercontext-menu"); - }, - - _shutdownMenu(menuId) { - const menu = this._window.document.getElementById(menuId); - this._shutdownElement(menu); - }, - - _shutdownElement(element) { - // Let's remove our elements. - while (element.firstChild) { - element.firstChild.remove(); - } - - const elementCache = this._elementCache.get(element); - if (elementCache) { - for (let e of elementCache) { // eslint-disable-line prefer-const - element.appendChild(e); - } - } - }, - - _disableElement(element) { - // Nothing to disable. - if (!element || this._elementCache.has(element)) { - return false; - } - const cacheArray = []; - - // Let's store the previous elements so that we can repopulate it in case - // the addon is uninstalled. - while (element.firstChild) { - cacheArray.push(element.removeChild(element.firstChild)); - } - - this._elementCache.set(element, cacheArray); - - return true; - }, - - _shutdownContainers() { - ContextualIdentityProxy.getIdentities().forEach(identity => { - if (IDENTITY_ICONS_STANDARD.indexOf(identity.icon) !== -1 && - IDENTITY_COLORS_STANDARD.indexOf(identity.color) !== -1) { - return; - } - - if (IDENTITY_ICONS_STANDARD.indexOf(identity.icon) === -1) { - if (identity.userContextId <= IDENTITY_ICONS_STANDARD.length) { - identity.icon = IDENTITY_ICONS_STANDARD[identity.userContextId - 1]; - } else { - identity.icon = IDENTITY_ICONS_STANDARD[0]; - } - } - - if (IDENTITY_COLORS_STANDARD.indexOf(identity.color) === -1) { - if (identity.userContextId <= IDENTITY_COLORS_STANDARD.length) { - identity.color = IDENTITY_COLORS_STANDARD[identity.userContextId - 1]; - } else { - identity.color = IDENTITY_COLORS_STANDARD[0]; - } - } - - ContextualIdentityService.update(identity.userContextId, - identity.name, - identity.icon, - identity.color); - }); - } -}; - -// uninstall/install events --------------------------------------------------- - -exports.main = function (options) { - const installation = options.loadReason === "install" || - options.loadReason === "downgrade" || - options.loadReason === "enable" || - options.loadReason === "upgrade"; - - // Let's start :) - ContainerService.init(installation, options.loadReason); -}; - -exports.onUnload = function (reason) { - if (reason === "disable" || - reason === "downgrade" || - reason === "uninstall" || - reason === "upgrade") { - ContainerService.uninstall(reason); - } -}; +webExtension.startup(); diff --git a/index.js.bk b/index.js.bk new file mode 100644 index 0000000..f38c596 --- /dev/null +++ b/index.js.bk @@ -0,0 +1,1060 @@ +/* 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/. */ + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +const INCOMPATIBLE_ADDON_IDS = [ + "pulse@mozilla.com", + "snoozetabs@mozilla.com", + "jid1-NeEaf3sAHdKHPA@jetpack" // PageShot +]; + +const IDENTITY_COLORS = [ + { name: "blue", color: "#00a7e0" }, + { name: "turquoise", color: "#01bdad" }, + { name: "green", color: "#7dc14c" }, + { name: "yellow", color: "#ffcb00" }, + { name: "orange", color: "#f89c24" }, + { name: "red", color: "#d92215" }, + { name: "pink", color: "#ee5195" }, + { name: "purple", color: "#7a2f7a" }, +]; + +const IDENTITY_ICONS = [ + { name: "fingerprint", image: "chrome://browser/skin/usercontext/personal.svg" }, + { name: "briefcase", image: "chrome://browser/skin/usercontext/work.svg" }, + { name: "dollar", image: "chrome://browser/skin/usercontext/banking.svg" }, + { name: "cart", image: "chrome://browser/skin/usercontext/shopping.svg" }, + // All of these do not exist in gecko + { name: "gift", image: "gift" }, + { name: "vacation", image: "vacation" }, + { name: "food", image: "food" }, + { name: "fruit", image: "fruit" }, + { name: "pet", image: "pet" }, + { name: "tree", image: "tree" }, + { name: "chill", image: "chill" }, + { name: "circle", image: "circle" }, +]; + +const IDENTITY_COLORS_STANDARD = [ + "blue", "orange", "green", "pink", +]; + +const IDENTITY_ICONS_STANDARD = [ + "fingerprint", "briefcase", "dollar", "cart", +]; + +const PREFS = [ + [ "privacy.userContext.enabled", true ], + [ "privacy.userContext.longPressBehavior", 2 ], + [ "privacy.userContext.ui.enabled", false ], + [ "privacy.usercontext.about_newtab_segregation.enabled", true ], +]; + +const { AddonManager } = require("resource://gre/modules/AddonManager.jsm"); +const { attachTo, detachFrom } = require("sdk/content/mod"); +const { Cu } = require("chrome"); +const { ContextualIdentityService } = require("resource://gre/modules/ContextualIdentityService.jsm"); +const { LightweightThemeManager } = Cu.import("resource://gre/modules/LightweightThemeManager.jsm", {}); +const Metrics = require("./testpilot-metrics"); +const { modelFor } = require("sdk/model/core"); +const prefService = require("sdk/preferences/service"); +const self = require("sdk/self"); +const { Services } = require("resource://gre/modules/Services.jsm"); +const ss = require("sdk/simple-storage"); +const { study } = require("./study"); +const { Style } = require("sdk/stylesheet/style"); +const tabs = require("sdk/tabs"); +const uuid = require("sdk/util/uuid"); +const { viewFor } = require("sdk/view/core"); +const webExtension = require("sdk/webextension"); +const windows = require("sdk/windows"); +const windowUtils = require("sdk/window/utils"); + +Cu.import("resource:///modules/CustomizableUI.jsm"); +Cu.import("resource:///modules/CustomizableWidgets.jsm"); +Cu.import("resource:///modules/sessionstore/SessionStore.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + + +// ContextualIdentityProxy +const ContextualIdentityProxy = { + getIdentities() { + let response; + if ("getPublicIdentities" in ContextualIdentityService) { + response = ContextualIdentityService.getPublicIdentities(); + } else { + response = ContextualIdentityService.getIdentities(); + } + + return response.map((identity) => { + return this._convert(identity); + }); + }, + + getIdentityFromId(userContextId) { + let response; + if ("getPublicIdentityFromId" in ContextualIdentityService) { + response = ContextualIdentityService.getPublicIdentityFromId(userContextId); + } else { + response = ContextualIdentityService.getIdentityFromId(userContextId); + } + if (response) { + return this._convert(response); + } + return response; + }, + + _convert(identity) { + return { + name: ContextualIdentityService.getUserContextLabel(identity.userContextId), + icon: identity.icon, + color: identity.color, + userContextId: identity.userContextId, + }; + }, +}; + +// ---------------------------------------------------------------------------- +// ContainerService + +const ContainerService = { + _windowMap: new Map(), + _containerWasEnabled: false, + _onBackgroundConnectCallback: null, + + async init(installation, reason) { + // If we are just been installed, we must store some information for the + // uninstallation. This object contains also a version number, in case we + // need to implement a migration in the future. + // In 1.1.1 and less we deleted savedConfiguration on upgrade so we need to rebuild + if (!("savedConfiguration" in ss.storage) || + !("prefs" in ss.storage.savedConfiguration) || + (installation && reason !== "upgrade")) { + let preInstalledIdentities = []; // eslint-disable-line prefer-const + ContextualIdentityProxy.getIdentities().forEach(identity => { + preInstalledIdentities.push(identity.userContextId); + }); + + const object = { + version: 1, + prefs: {}, + metricsUUID: uuid.uuid().toString(), + preInstalledIdentities: preInstalledIdentities + }; + + PREFS.forEach(pref => { + object.prefs[pref[0]] = prefService.get(pref[0]); + }); + + ss.storage.savedConfiguration = object; + + if (prefService.get("privacy.userContext.enabled") !== true) { + // Maybe rename the Banking container. + const identity = ContextualIdentityProxy.getIdentityFromId(3); + if (identity && identity.l10nID === "userContextBanking.label") { + ContextualIdentityService.update(identity.userContextId, + "Finance", + identity.icon, + identity.color); + } + + // Let's create the default containers in case there are none. + if (ss.storage.savedConfiguration.preInstalledIdentities.length === 0) { + // Note: we have to create them in this way because there is no way to + // reuse the same ID and the localized strings. + ContextualIdentityService.create("Personal", "fingerprint", "blue"); + ContextualIdentityService.create("Work", "briefcase", "orange"); + ContextualIdentityService.create("Finance", "dollar", "green"); + ContextualIdentityService.create("Shopping", "cart", "pink"); + } + } + } + + // TOCHECK should this run on all code + ContextualIdentityProxy.getIdentities().forEach(identity => { + const newIcon = this._fromIconToName(identity.icon); + const newColor = this._fromColorToName(identity.color); + if (newIcon !== identity.icon || newColor !== identity.color) { + ContextualIdentityService.update(identity.userContextId, + ContextualIdentityService.getUserContextLabel(identity.userContextId), + newIcon, + newColor); + } + }); + + // Let's see if containers were enabled before this addon. + this._containerWasEnabled = + ss.storage.savedConfiguration.prefs["privacy.userContext.enabled"]; + + // Enabling preferences + + PREFS.forEach((pref) => { + prefService.set(pref[0], pref[1]); + }); + + this._metricsUUID = ss.storage.savedConfiguration.metricsUUID; + + // Disabling the customizable container panel. + CustomizableUI.destroyWidget("containers-panelmenu"); + + // Message routing + + // only these methods are allowed. We have a 1:1 mapping between messages + // and methods. These methods must return a promise. + const methods = [ + "showTabs", + "sortTabs", + "getIdentity", + "getPreference", + "sendTelemetryPayload", + "getTheme", + "getShieldStudyVariation", + "refreshNeeded", + "forgetIdentityAndRefresh", + "checkIncompatibleAddons" + ]; + + // Map of identities. + ContextualIdentityProxy.getIdentities().forEach(identity => { + this._remapTabsIfMissing(identity.userContextId); + }); + + // Let's restore the hidden tabs from the previous session. + if (prefService.get("browser.startup.page") === 3 && + "identitiesData" in ss.storage) { + // TODO move Restore State? + /* + ContextualIdentityProxy.getIdentities().forEach(identity => { + if (identity.userContextId in ss.storage.identitiesData && + "hiddenTabs" in ss.storage.identitiesData[identity.userContextId]) { + this._identitiesState[identity.userContextId].hiddenTabs = + ss.storage.identitiesData[identity.userContextId].hiddenTabs; + } + }); +*/ + } + + tabs.on("open", tab => { + this._restyleTab(tab); + this._remapTab(tab); + }); + + tabs.on("close", tab => { + this._remapTab(tab); + }); + + tabs.on("activate", tab => { + this._restyleActiveTab(tab).catch(() => {}); + this._configureActiveWindows(); + this._remapTab(tab); + }); + + // Modify CSS and other stuff for each window. + + this._configureWindows().catch(() => {}); + + windows.browserWindows.on("open", window => { + this._configureWindow(viewFor(window)).catch(() => {}); + }); + + windows.browserWindows.on("close", window => { + this.closeWindow(viewFor(window)); + }); + + // WebExtension startup + + try { + const api = await webExtension.startup(); + api.browser.runtime.onMessage.addListener((message, sender, sendReply) => { + if ("method" in message && methods.indexOf(message.method) !== -1) { + sendReply(this[message.method](message)); + } + }); + + this.registerBackgroundConnection(api); + } catch (e) { + throw new Error("WebExtension startup failed. Unable to continue."); + } + + this._sendEvent = new Metrics({ + type: "sdk", + id: self.id, + version: self.version + }).sendEvent; + + // Begin-Of-Hack + ContextualIdentityService.workaroundForCookieManager = function(method, userContextId) { + let identity = method.call(ContextualIdentityService, userContextId); + if (!identity && userContextId) { + identity = { + userContextId, + icon: "", + color: "", + name: "Pending to be deleted", + public: true, + }; + } + + return identity; + }; + + if (!this._oldGetIdentityFromId) { + this._oldGetIdentityFromId = ContextualIdentityService.getIdentityFromId; + } + ContextualIdentityService.getIdentityFromId = function(userContextId) { + return this.workaroundForCookieManager(ContainerService._oldGetIdentityFromId, userContextId); + }; + + if ("getPublicIdentityFromId" in ContextualIdentityService) { + if (!this._oldGetPublicIdentityFromId) { + this._oldGetPublicIdentityFromId = ContextualIdentityService.getPublicIdentityFromId; + } + ContextualIdentityService.getPublicIdentityFromId = function(userContextId) { + return this.workaroundForCookieManager(ContainerService._oldGetPublicIdentityFromId, userContextId); + }; + } + // End-Of-Hack + + Services.obs.addObserver(this, "lightweight-theme-changed", false); + + if (self.id === "@shield-study-containers") { + study.startup(reason); + this.shieldStudyVariation = study.variation; + } + }, + + registerBackgroundConnection(api) { + // This is only used for theme notifications and new tab + api.browser.runtime.onConnect.addListener((port) => { + this._onBackgroundConnectCallback = (message, topic) => { + port.postMessage({ + type: topic, + message + }); + }; + }); + }, + + triggerBackgroundCallback(message, topic) { + if (this._onBackgroundConnectCallback) { + this._onBackgroundConnectCallback(message, topic); + } + }, + + async observe(subject, topic) { + if (topic === "lightweight-theme-changed") { + try { + const theme = await this.getTheme(); + this.triggerBackgroundCallback(theme, topic); + } catch (e) { + throw new Error("Unable to get theme"); + } + } + }, + + getTheme() { + const defaultTheme = "firefox-compact-light@mozilla.org"; + return new Promise(function (resolve) { + let theme = defaultTheme; + if (LightweightThemeManager.currentTheme && LightweightThemeManager.currentTheme.id) { + theme = LightweightThemeManager.currentTheme.id; + } + resolve(theme); + }); + }, + + getShieldStudyVariation() { + return this.shieldStudyVariation; + }, + + // 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. + + // Helper methods for converting colors to names and names to colors. + + _fromNameToColor(name) { + return this._fromNameOrColor(name, "color"); + }, + + _fromColorToName(color) { + return this._fromNameOrColor(color, "name"); + }, + + _fromNameOrColor(what, attribute) { + for (let color of IDENTITY_COLORS) { // eslint-disable-line prefer-const + if (what === color.color || what === color.name) { + return color[attribute]; + } + } + return ""; + }, + + // Helper methods for converting icons to names and names to icons. + + _fromIconToName(icon) { + return this._fromNameOrIcon(icon, "name", "circle"); + }, + + _fromNameOrIcon(what, attribute, defaultValue) { + for (let icon of IDENTITY_ICONS) { // eslint-disable-line prefer-const + if (what === icon.image || what === icon.name) { + return icon[attribute]; + } + } + return defaultValue; + }, + + // Tab Helpers + + _getUserContextIdFromTab(tab) { + return parseInt(viewFor(tab).getAttribute("usercontextid") || 0, 10); + }, + + _matchTabsByContainer(userContextId) { + const matchedTabs = []; + for (const tab of tabs) { + if (userContextId === this._getUserContextIdFromTab(tab)) { + matchedTabs.push(tab); + } + } + return matchedTabs; + }, + + async _closeTabs(tabsToClose) { + // We create a new tab only if the current operation closes all the + // existing ones. + if (tabs.length === tabsToClose.length) { + await this.openTab({}); + } + + for (const tab of tabsToClose) { + // after .close() window is null. Let's take it now. + const window = viewFor(tab.window); + + tab.close(); + + // forget about this tab. 0 is the index of the forgotten tab and 0 + // means the last one. + try { + SessionStore.forgetClosedTab(window, 0); + } catch (e) {} // eslint-disable-line no-empty + } + }, + + _recentBrowserWindow() { + const browserWin = windowUtils.getMostRecentBrowserWindow(); + + // This should not really happen. + if (!browserWin || !browserWin.gBrowser) { + return Promise.resolve(null); + } + + return Promise.resolve(browserWin); + }, + + + sendTelemetryPayload(args = {}) { + // when pings come from popup, delete "method" prop + delete args.method; + let payload = { // eslint-disable-line prefer-const + "uuid": this._metricsUUID + }; + Object.assign(payload, args); + + this._sendEvent(payload); + }, + + checkIncompatibleAddons() { + return new Promise(resolve => { + AddonManager.getAddonsByIDs(INCOMPATIBLE_ADDON_IDS, (addons) => { + addons = addons.filter((a) => a && a.isActive); + const incompatibleAddons = addons.length !== 0; + if (incompatibleAddons) { + this.sendTelemetryPayload({ + "event": "incompatible-addons-detected" + }); + } + resolve(incompatibleAddons); + }); + }); + }, + + // Tabs management + + openTab(args) { + return this.triggerBackgroundCallback(args, "open-tab"); + }, + + // Identities management + + queryIdentities() { + return new Promise(resolve => { + const identities = ContextualIdentityProxy.getIdentities(); + identities.forEach(identity => { + this._remapTabsIfMissing(identity.userContextId); + }); + + resolve(identities); + }); + }, + + // Preferences + + getPreference(args) { + if (!("pref" in args)) { + return Promise.reject("getPreference must be called with pref argument."); + } + + return Promise.resolve(prefService.get(args.pref)); + }, + + // Styling the window + + _configureWindows() { + const promises = []; + for (let window of windows.browserWindows) { // eslint-disable-line prefer-const + promises.push(this._configureWindow(viewFor(window))); + } + return Promise.all(promises); + }, + + _configureWindow(window) { + return this._getOrCreateContainerWindow(window).configure(); + }, + + _configureActiveWindows() { + const promises = []; + for (let window of windows.browserWindows) { // eslint-disable-line prefer-const + promises.push(this._configureActiveWindow(viewFor(window))); + } + return Promise.all(promises); + }, + + _configureActiveWindow(window) { + return this._getOrCreateContainerWindow(window).configureActive(); + }, + + closeWindow(window) { + this._windowMap.delete(window); + }, + + _getOrCreateContainerWindow(window) { + if (!(this._windowMap.has(window))) { + this._windowMap.set(window, new ContainerWindow(window)); + } + + return this._windowMap.get(window); + }, + + refreshNeeded() { + return this._configureWindows(); + }, + + _restyleActiveTab(tab) { + if (!tab) { + return Promise.resolve(null); + } + + const userContextId = ContainerService._getUserContextIdFromTab(tab); + const identity = ContextualIdentityProxy.getIdentityFromId(userContextId); + const hbox = viewFor(tab.window).document.getElementById("userContext-icons"); + + if (!identity) { + hbox.setAttribute("data-identity-color", ""); + return Promise.resolve(null); + } + + hbox.setAttribute("data-identity-color", identity.color); + + const label = viewFor(tab.window).document.getElementById("userContext-label"); + label.setAttribute("value", identity.name); + label.style.color = ContainerService._fromNameToColor(identity.color); + + const indicator = viewFor(tab.window).document.getElementById("userContext-indicator"); + indicator.setAttribute("data-identity-icon", identity.icon); + indicator.style.listStyleImage = ""; + + return this._restyleTab(tab); + }, + + _restyleTab(tab) { + if (!tab) { + return Promise.resolve(null); + } + const userContextId = ContainerService._getUserContextIdFromTab(tab); + const identity = ContextualIdentityProxy.getIdentityFromId(userContextId); + if (!identity) { + return Promise.resolve(null); + } + return Promise.resolve(viewFor(tab).setAttribute("data-identity-color", identity.color)); + }, + + // Uninstallation + uninstall(reason) { + const data = ss.storage.savedConfiguration; + if (!data) { + throw new DOMError("ERROR - No saved configuration!!"); + } + + if (data.version !== 1) { + throw new DOMError("ERROR - Unknown version!!"); + } + + if (reason !== "upgrade") { + PREFS.forEach(pref => { + if (pref[0] in data.prefs) { + prefService.set(pref[0], data.prefs[pref[0]]); + } + }); + } + + // Note: We cannot go back renaming the Finance identity back to Banking: + // the locale system doesn't work with renamed containers. + + // Restore the customizable container panel. + const widget = CustomizableWidgets.find(widget => widget.id === "containers-panelmenu"); + if (widget) { + CustomizableUI.createWidget(widget); + } + + for (let window of windows.browserWindows) { // eslint-disable-line prefer-const + // Let's close all the container tabs. + // Note: We cannot use _closeTabs() because at this point tab.window is + // null. + if (!this._containerWasEnabled && reason !== "upgrade") { + for (let tab of window.tabs) { // eslint-disable-line prefer-const + if (this._getUserContextIdFromTab(tab)) { + tab.close(); + try { + SessionStore.forgetClosedTab(viewFor(window), 0); + } catch(e) {} // eslint-disable-line no-empty + } + } + } + + this._getOrCreateContainerWindow(viewFor(window)).shutdown(); + } + + // all the configuration must go away now. + this._windowMap = new Map(); + + if (reason !== "upgrade") { + // Let's forget all the previous closed tabs. + this._forgetIdentity(); + + const preInstalledIdentities = data.preInstalledIdentities; + ContextualIdentityProxy.getIdentities().forEach(identity => { + if (!preInstalledIdentities.includes(identity.userContextId)) { + ContextualIdentityService.remove(identity.userContextId); + } else { + // Let's cleanup all the cookies for this container. + Services.obs.notifyObservers(null, "clear-origin-attributes-data", + JSON.stringify({ userContextId: identity.userContextId })); + } + }); + + // Let's delete the configuration. + delete ss.storage.savedConfiguration; + } + + // Begin-Of-Hack + if (this._oldGetIdentityFromId) { + ContextualIdentityService.getIdentityFromId = this._oldGetIdentityFromId; + } + + if (this._oldGetPublicIdentityFromId) { + ContextualIdentityService.getPublicIdentityFromId = this._oldGetPublicIdentityFromId; + } + // End-Of-Hack + }, + + forgetIdentityAndRefresh(args) { + this._forgetIdentity(args.userContextId); + return this.refreshNeeded(); + }, + + _forgetIdentity(userContextId = 0) { + for (let window of windows.browserWindows) { // eslint-disable-line prefer-const + window = viewFor(window); + const closedTabData = JSON.parse(SessionStore.getClosedTabData(window)); + for (let i = closedTabData.length - 1; i >= 0; --i) { + if (!closedTabData[i].state.userContextId) { + continue; + } + + if (userContextId === 0 || + closedTabData[i].state.userContextId === userContextId) { + try { + SessionStore.forgetClosedTab(window, i); + } catch(e) {} // eslint-disable-line no-empty + } + } + } + }, +}; + +// ---------------------------------------------------------------------------- +// ContainerWindow + +// This object is used to configure a single window. +function ContainerWindow(window) { + this._init(window); +} + +ContainerWindow.prototype = { + _window: null, + _style: null, + _panelElement: null, + _timeoutStore: new Map(), + _elementCache: new Map(), + _tooltipCache: new Map(), + _tabsElement: null, + + _init(window) { + this._window = window; + this._tabsElement = this._window.document.getElementById("tabbrowser-tabs"); + this._style = Style({ uri: self.data.url("usercontext.css") }); + this._plusButton = this._window.document.getAnonymousElementByAttribute(this._tabsElement, "anonid", "tabs-newtab-button"); + this._overflowPlusButton = this._window.document.getElementById("new-tab-button"); + + // Only hack the normal plus button as the alltabs is done elsewhere + this.attachMenuEvent("plus-button", this._plusButton); + + attachTo(this._style, this._window); + }, + + attachMenuEvent(source, button) { + const popup = button.querySelector(".new-tab-popup"); + popup.addEventListener("popupshown", () => { + ContainerService.sendTelemetryPayload({ + "event": "show-plus-button-menu", + "eventSource": source + }); + popup.querySelector("menuseparator").remove(); + const popupMenuItems = [...popup.querySelectorAll("menuitem")]; + popupMenuItems.forEach((item) => { + const userContextId = item.getAttribute("data-usercontextid"); + if (!userContextId) { + item.remove(); + } + item.setAttribute("command", ""); + item.addEventListener("command", (e) => { + e.stopPropagation(); + e.preventDefault(); + ContainerService.openTab({ + userContextId: userContextId, + source: source + }); + }); + }); + }); + }, + + configure() { + return Promise.all([ + this._configureActiveTab(), + this._configureFileMenu(), + this._configureAllTabsMenu(), + this._configureTabStyle(), + this.configureActive(), + ]); + }, + + configureActive() { + return this._configureContextMenu(); + }, + + _configureTabStyle() { + const promises = []; + for (let tab of modelFor(this._window).tabs) { // eslint-disable-line prefer-const + promises.push(ContainerService._restyleTab(tab)); + } + return Promise.all(promises); + }, + + _configureActiveTab() { + const tab = modelFor(this._window).tabs.activeTab; + return ContainerService._restyleActiveTab(tab); + }, + + _configureFileMenu() { + return this._configureMenu("menu_newUserContext", null, e => { + const userContextId = parseInt(e.target.getAttribute("data-usercontextid"), 10); + ContainerService.openTab({ + userContextId: userContextId, + source: "file-menu" + }); + }); + }, + + _configureAllTabsMenu() { + return this._configureMenu("alltabs_containersTab", null, e => { + const userContextId = parseInt(e.target.getAttribute("data-usercontextid"), 10); + ContainerService.showTabs({ + userContextId, + nofocus: true, + window: this._window, + }).then(() => { + return ContainerService.openTab({ + userContextId, + source: "alltabs-menu" + }); + }).catch(() => {}); + }); + }, + + _configureContextMenu() { + return Promise.all([ + this._configureMenu("context-openlinkinusercontext-menu", + () => { + // This userContextId is what we want to exclude. + const tab = modelFor(this._window).tabs.activeTab; + return ContainerService._getUserContextIdFromTab(tab); + }, + e => { + // This is a super internal method. Hopefully it will be stable in the + // next FF releases. + this._window.gContextMenu.openLinkInTab(e); + + const userContextId = parseInt(e.target.getAttribute("data-usercontextid"), 10); + ContainerService.showTabs({ + userContextId, + nofocus: true, + window: this._window, + }); + } + ), + this._configureContextMenuOpenLink(), + ]); + }, + + _configureContextMenuOpenLink() { + return new Promise(resolve => { + const self = this; + this._window.gSetUserContextIdAndClick = function(event) { + const tab = modelFor(self._window).tabs.activeTab; + const userContextId = ContainerService._getUserContextIdFromTab(tab); + event.target.setAttribute("data-usercontextid", userContextId); + self._window.gContextMenu.openLinkInTab(event); + }; + + let item = this._window.document.getElementById("context-openlinkincontainertab"); + item.setAttribute("oncommand", "gSetUserContextIdAndClick(event)"); + + item = this._window.document.getElementById("context-openlinkintab"); + item.setAttribute("oncommand", "gSetUserContextIdAndClick(event)"); + + resolve(); + }); + }, + + // Generic menu configuration. + _configureMenu(menuId, excludedContainerCb, clickCb) { + const menu = this._window.document.getElementById(menuId); + if (!this._disableElement(menu)) { + // Delete stale menu that isn't native elements + while (menu.firstChild) { + menu.removeChild(menu.firstChild); + } + } + + const menupopup = this._window.document.createElementNS(XUL_NS, "menupopup"); + menu.appendChild(menupopup); + + menupopup.addEventListener("command", clickCb); + return this._createMenu(menupopup, excludedContainerCb); + }, + + _createMenu(target, excludedContainerCb) { + while (target.hasChildNodes()) { + target.removeChild(target.firstChild); + } + + return new Promise((resolve, reject) => { + ContainerService.queryIdentities().then(identities => { + const fragment = this._window.document.createDocumentFragment(); + + const excludedUserContextId = excludedContainerCb ? excludedContainerCb() : 0; + if (excludedUserContextId) { + const bundle = this._window.document.getElementById("bundle_browser"); + + const menuitem = this._window.document.createElementNS(XUL_NS, "menuitem"); + menuitem.setAttribute("data-usercontextid", "0"); + menuitem.setAttribute("label", bundle.getString("userContextNone.label")); + menuitem.setAttribute("accesskey", bundle.getString("userContextNone.accesskey")); + + fragment.appendChild(menuitem); + + const menuseparator = this._window.document.createElementNS(XUL_NS, "menuseparator"); + fragment.appendChild(menuseparator); + } + + identities.forEach(identity => { + if (identity.userContextId === excludedUserContextId) { + return; + } + + const menuitem = this._window.document.createElementNS(XUL_NS, "menuitem"); + menuitem.setAttribute("label", identity.name); + menuitem.classList.add("menuitem-iconic"); + menuitem.setAttribute("data-usercontextid", identity.userContextId); + menuitem.setAttribute("data-identity-color", identity.color); + menuitem.setAttribute("data-identity-icon", identity.icon); + fragment.appendChild(menuitem); + }); + + target.appendChild(fragment); + resolve(); + }).catch(() => {reject();}); + }); + }, + + // This timer is used to hide the panel auto-magically if it's not used in + // the following X seconds. This is need to avoid the leaking of the panel + // when the mouse goes out of of the 'plus' button. + _createTimeout(key, callback, timeoutTime) { + this._cleanTimeout(key); + this._timeoutStore.set(key, this._window.setTimeout(() => { + callback(); + this._timeoutStore.delete(key); + }, timeoutTime)); + }, + + _cleanAllTimeouts() { + for (let key of this._timeoutStore.keys()) { // eslint-disable-line prefer-const + this._cleanTimeout(key); + } + }, + + _cleanTimeout(key) { + if (this._timeoutStore.has(key)) { + this._window.clearTimeout(this._timeoutStore.get(key)); + this._timeoutStore.delete(key); + } + }, + + shutdown() { + // CSS must be removed. + detachFrom(this._style, this._window); + + this._shutdownFileMenu(); + this._shutdownAllTabsMenu(); + this._shutdownContextMenu(); + + this._shutdownContainers(); + }, + + _shutDownPlusButtonMenuElement(buttonElement) { + if (buttonElement) { + this._shutdownElement(buttonElement); + buttonElement.setAttribute("tooltip", this._tooltipCache.get(buttonElement)); + + buttonElement.removeEventListener("mouseover", this); + buttonElement.removeEventListener("click", this); + buttonElement.removeEventListener("mouseout", this); + } + }, + + _shutdownFileMenu() { + this._shutdownMenu("menu_newUserContext"); + }, + + _shutdownAllTabsMenu() { + this._shutdownMenu("alltabs_containersTab"); + }, + + _shutdownContextMenu() { + this._shutdownMenu("context-openlinkinusercontext-menu"); + }, + + _shutdownMenu(menuId) { + const menu = this._window.document.getElementById(menuId); + this._shutdownElement(menu); + }, + + _shutdownElement(element) { + // Let's remove our elements. + while (element.firstChild) { + element.firstChild.remove(); + } + + const elementCache = this._elementCache.get(element); + if (elementCache) { + for (let e of elementCache) { // eslint-disable-line prefer-const + element.appendChild(e); + } + } + }, + + _disableElement(element) { + // Nothing to disable. + if (!element || this._elementCache.has(element)) { + return false; + } + const cacheArray = []; + + // Let's store the previous elements so that we can repopulate it in case + // the addon is uninstalled. + while (element.firstChild) { + cacheArray.push(element.removeChild(element.firstChild)); + } + + this._elementCache.set(element, cacheArray); + + return true; + }, + + _shutdownContainers() { + ContextualIdentityProxy.getIdentities().forEach(identity => { + if (IDENTITY_ICONS_STANDARD.indexOf(identity.icon) !== -1 && + IDENTITY_COLORS_STANDARD.indexOf(identity.color) !== -1) { + return; + } + + if (IDENTITY_ICONS_STANDARD.indexOf(identity.icon) === -1) { + if (identity.userContextId <= IDENTITY_ICONS_STANDARD.length) { + identity.icon = IDENTITY_ICONS_STANDARD[identity.userContextId - 1]; + } else { + identity.icon = IDENTITY_ICONS_STANDARD[0]; + } + } + + if (IDENTITY_COLORS_STANDARD.indexOf(identity.color) === -1) { + if (identity.userContextId <= IDENTITY_COLORS_STANDARD.length) { + identity.color = IDENTITY_COLORS_STANDARD[identity.userContextId - 1]; + } else { + identity.color = IDENTITY_COLORS_STANDARD[0]; + } + } + + ContextualIdentityService.update(identity.userContextId, + identity.name, + identity.icon, + identity.color); + }); + } +}; + +// uninstall/install events --------------------------------------------------- + +exports.main = function (options) { + const installation = options.loadReason === "install" || + options.loadReason === "downgrade" || + options.loadReason === "enable" || + options.loadReason === "upgrade"; + + // Let's start :) + ContainerService.init(installation, options.loadReason); +}; + +exports.onUnload = function (reason) { + if (reason === "disable" || + reason === "downgrade" || + reason === "uninstall" || + reason === "upgrade") { + ContainerService.uninstall(reason); + } +}; diff --git a/webextension/background.js b/webextension/background.js index 8effdd4..b160e6c 100644 --- a/webextension/background.js +++ b/webextension/background.js @@ -1,6 +1,6 @@ const MAJOR_VERSIONS = ["2.3.0", "2.4.0"]; -const LOOKUP_KEY = "$ref"; const THEME_BUILD_DATE = 20170630; +const DEFAULT_TAB = "about:newtab"; const assignManager = { MENU_ASSIGN_ID: "open-in-this-container", @@ -188,12 +188,7 @@ const assignManager = { if (!("cookieStoreId" in tab)) { return false; } - const cookieStore = tab.cookieStoreId; - const container = cookieStore.replace("firefox-container-", ""); - if (container !== cookieStore) { - return container; - } - return false; + return backgroundLogic.getUserContextIdFromCookieStoreId(tab.cookieStoreId); }, isTabPermittedAssign(tab) { @@ -336,6 +331,126 @@ const assignManager = { } }; +const identityState = { + storageArea: { + area: browser.storage.local, + + getContainerStoreKey(cookieStoreId) { + const storagePrefix = "identitiesState@@_"; + return `${storagePrefix}${cookieStoreId}`; + }, + + get(cookieStoreId) { + const storeKey = this.getContainerStoreKey(cookieStoreId); + return new Promise((resolve, reject) => { + this.area.get([storeKey]).then((storageResponse) => { + if (storageResponse && storeKey in storageResponse) { + resolve(storageResponse[storeKey]); + } + resolve(null); + }).catch((e) => { + reject(e); + }); + }); + }, + + set(cookieStoreId, data) { + const storeKey = this.getContainerStoreKey(cookieStoreId); + return this.area.set({ + [storeKey]: data + }); + }, + + remove(cookieStoreId) { + const storeKey = this.getContainerStoreKey(cookieStoreId); + return this.area.remove([storeKey]); + } + }, + + _createTabObject(tab) { + return Object.assign({}, tab); + }, + + async containersCounts() { + let containersCounts = { // eslint-disable-line prefer-const + "shown": 0, + "hidden": 0, + "total": 0 + }; + const containers = await browser.contextualIdentities.query({}); + for (const container in containers) { + const containerState = await this.storageArea.get(container.cookieStoreId); + if (containerState.openTabs > 0) { + ++containersCounts.shown; + ++containersCounts.total; + continue; + } else if (containerState.hiddenTabs.length > 0) { + ++containersCounts.hidden; + ++containersCounts.total; + continue; + } + } + return containersCounts; + }, + + containerTabCount(userContextId) { + // Returns the total of open and hidden tabs with this userContextId + let containerTabsCount = 0; + containerTabsCount += this._identitiesState[userContextId].openTabs; + containerTabsCount += this._identitiesState[userContextId].hiddenTabs.length; + return containerTabsCount; + }, + + totalContainerTabsCount() { + // Returns the number of total open tabs across ALL containers + let totalContainerTabsCount = 0; + for (const userContextId in this._identitiesState) { + totalContainerTabsCount += this._identitiesState[userContextId].openTabs; + } + return totalContainerTabsCount; + }, + + async totalNonContainerTabsCount() { + // Returns the number of open tabs NOT IN a container + let totalNonContainerTabsCount = 0; + const tabs = await browser.tabs.query({}); + for (const tab of tabs) { + if (this._getUserContextIdFromTab(tab) === 0) { + ++totalNonContainerTabsCount; + } + } + return totalNonContainerTabsCount; + }, + + remapTabsIfMissing(userContextId) { + // We already know this userContextId. + if (userContextId in this._identitiesState) { + return; + } + + this._identitiesState[userContextId] = this._createIdentityState(); + this.remapTabsFromUserContextId(userContextId); + }, + + remapTabsFromUserContextId(userContextId) { + this._identitiesState[userContextId].openTabs = this._matchTabsByContainer(userContextId).length; + }, +/*TODO check if used + remapTab(tab) { + const userContextId = this._getUserContextIdFromTab(tab); + if (userContextId) { + this.remapTabsFromUserContextId(userContextId); + } + }, +*/ + + _createIdentityState() { + return { + hiddenTabs: [], + openTabs: 0 + }; + }, +}; const backgroundLogic = { NEW_TAB_PAGES: new Set([ @@ -345,16 +460,24 @@ const backgroundLogic = { "about:blank" ]), + getUserContextIdFromCookieStoreId(cookieStoreId) { + if (!cookieStoreId) { + return false; + } + const container = cookieStoreId.replace("firefox-container-", ""); + if (container !== cookieStoreId) { + return container; + } + return false; + }, + deleteContainer(userContextId) { this.sendTelemetryPayload({ event: "delete-container", userContextId }); - const removeTabsPromise = this._containerTabs(userContextId).then((tabs) => { - const tabIds = tabs.map((tab) => tab.id); - return browser.tabs.remove(tabIds); - }); + const removeTabsPromise = this._closeTabs(userContextId); return new Promise((resolve) => { removeTabsPromise.then(() => { @@ -401,13 +524,14 @@ const backgroundLogic = { const active = ("nofocus" in options) ? options.nofocus : true; const source = ("source" in options) ? options.source : null; + const cookieStoreId = backgroundLogic.cookieStoreId(options.userContextId); // Only send telemetry for tabs opened by UI - i.e., not via showTabs if (source && userContextId) { this.sendTelemetryPayload({ "event": "open-tab", "eventSource": source, "userContextId": userContextId, - "clickedContainerTabCount": LOOKUP_KEY + "clickedContainerTabCount": identityState.containerTabCount(cookieStoreId) }); } // Autofocus url bar will happen in 54: https://bugzilla.mozilla.org/show_bug.cgi?id=1295072 @@ -418,18 +542,259 @@ const backgroundLogic = { } // Unhide all hidden tabs - browser.runtime.sendMessage({ - method: "showTabs", - userContextId: options.userContextId + this.showTabs({ + cookieStoreId }); return browser.tabs.create({ url, active, pinned: options.pinned || false, - cookieStoreId: backgroundLogic.cookieStoreId(options.userContextId) + cookieStoreId }); }, + async getTabs(options) { + if (!("cookieStoreId" in options)) { + return new Error("getTabs must be called with cookieStoreId argument."); + } + + const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(options.cookieStoreId); + this._remapTabsIfMissing(userContextId); + if (!this._isKnownContainer(userContextId)) { + return []; + } + + const list = []; + await this._containerTabs(userContextId).then((tabs) => { + tabs.forEach((tab) => { + list.push(this._createTabObject(tab)); + }); + }); + + const containerState = await identityState.storageArea.get(options.cookieStoreId); + return list.concat(containerState.hiddenTabs); + }, + + async moveTabsToWindow(options) { + if (!("cookieStoreId" in options)) { + return new Error("moveTabsToWindow must be called with cookieStoreId argument."); + } + + const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(options.cookieStoreId); + identityState.remapTabsIfMissing(userContextId); + if (!identityState.isKnownContainer(userContextId)) { + return null; + } + + this.sendTelemetryPayload({ + "event": "move-tabs-to-window", + "userContextId": userContextId, + "clickedContainerTabCount": this._containerTabCount(userContextId), + }); + + const list = this._matchTabsByContainer(userContextId); + + // Nothing to do + if (list.length === 0 && + this._identitiesState[userContextId].hiddenTabs.length === 0) { + return; + } +//TODO check list returns ids + const window = await browser.windows.create({ + tabId: list.shift() + }); + browser.tabs.move(list, { + windowId: window.id, + index: -1 + }); + + const containerState = await identityState.storageArea.get(options.cookieStoreId); + // Let's show the hidden tabs. + for (let object of containerState.hiddenTabs) { // eslint-disable-line prefer-const + browser.tabs.create(object.url || DEFAULT_TAB, { + cookieStoreId: options.cookieStoreId + }); + } + + containerState.hiddenTabs = []; + + // Let's close all the normal tab in the new window. In theory it + // should be only the first tab, but maybe there are addons doing + // crazy stuff. + const tabs = browser.tabs.query({windowId: window.id}); + for (let tab of tabs) { // eslint-disable-line prefer-const + if (tabs.cookieStoreId !== options.cookieStoreId) { + browser.tabs.remove(tab.id); + } + } + }, + + _closeTabs(userContextId) { + return this._containerTabs(userContextId).then((tabs) => { + const tabIds = tabs.map((tab) => tab.id); + return browser.tabs.remove(tabIds); + }); + }, + + async queryIdentitiesState() { + const identities = await browser.contextualIdentities.query({}); + const identitiesMap = await Promise.all(identities.map(async function (identity) { + identityState.remapTabsIfMissing(identity.userContextId); + const containerState = await identityState.get(identity.cookieStoreId); + return { + hasHiddenTabs: !!containerState.hiddenTabs.length, + hasOpenTabs: !!containerState.openTabs + }; + })); + + return identitiesMap; + }, + + _isKnownContainer(userContextId) { + return userContextId in this._identitiesState; + }, + + + + async sortTabs() { + const containersCounts = identityState.containersCounts(); + this.sendTelemetryPayload({ + "event": "sort-tabs", + "shownContainersCount": containersCounts.shown, + "totalContainerTabsCount": identityState.totalContainerTabsCount(), + "totalNonContainerTabsCount": await identityState.totalNonContainerTabsCount() + }); + return new Promise(resolve => { +//TODO fix this +/* + for (let window of windows.browserWindows) { // eslint-disable-line prefer-const + // First the pinned tabs, then the normal ones. + this._sortTabsInternal(window, true); + this._sortTabsInternal(window, false); + } +*/ + resolve(null); + }); + }, + + _sortTabsInternal(window, pinnedTabs) { + // From model to XUL window. + const xulWindow = viewFor(window); + + const tabs = browser.tabs.query({windowId: browser.windows.WINDOW_ID_CURRENT}); + let pos = 0; + + // Let's collect UCIs/tabs for this window. + const map = new Map; + for (const tab of tabs) { + if (pinnedTabs && !tabsUtils.isPinned(tab)) { + // We don't have, or we already handled all the pinned tabs. + break; + } + + if (!pinnedTabs && tabsUtils.isPinned(tab)) { + // pinned tabs must be consider as taken positions. + ++pos; + continue; + } + + const userContextId = this._getUserContextIdFromTab(tab); + if (!map.has(userContextId)) { + map.set(userContextId, []); + } + map.get(userContextId).push(tab); + } + + // Let's sort the map. + const sortMap = new Map([...map.entries()].sort((a, b) => a[0] > b[0])); + + // Let's move tabs. + sortMap.forEach(tabs => { + for (const tab of tabs) { + xulWindow.gBrowser.moveTabTo(tab, pos++); + } + }); + }, + + async hideTabs(options) { + if (!("cookieStoreId" in options)) { + return new Error("hideTabs must be called with cookieStoreId option."); + } + + const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(options.cookieStoreId); + this._remapTabsIfMissing(userContextId); + if (!this._isKnownContainer(userContextId)) { + return null; + } + + const containersCounts = identityState.containersCounts(); + this.sendTelemetryPayload({ + "event": "hide-tabs", + "userContextId": userContextId, + "clickedContainerTabCount": this._containerTabCount(options.userContextId), + "shownContainersCount": containersCounts.shown, + "hiddenContainersCount": containersCounts.hidden, + "totalContainersCount": containersCounts.total + }); + + const containerState = await identityState.storageArea.get(options.cookieStoreId); + + await this._containerTabs(userContextId).then((tabs) => { + tabs.forEach((tab) => { + const tabObject = this._createTabObject(tab); + // This tab is going to be closed. Let's mark this tabObject as + // non-active. + tabObject.active = false; + containerState.hiddenTabs.push(tabObject); + }); + }); + + await this._closeTabs(userContextId); + + return identityState.storageArea.set(options.cookieStoreId, containerState); + }, + + async showTabs(options) { + if (!("cookieStoreId" in options)) { + return Promise.reject("showTabs must be called with cookieStoreId argument."); + } + + const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(options.cookieStoreId); + identityState.remapTabsIfMissing(options.cookieStoreId); + if (!identityState.isKnownContainer(userContextId)) { + return null; + } + + const containersCounts = identityState.containersCounts(); + this.sendTelemetryPayload({ + "event": "show-tabs", + "userContextId": userContextId, + "clickedContainerTabCount": identityState.containerTabCount(options.userContextId), + "shownContainersCount": containersCounts.shown, + "hiddenContainersCount": containersCounts.hidden, + "totalContainersCount": containersCounts.total + }); + + const promises = []; + + const containerState = await identityState.storageArea.get(options.cookieStoreId); + + for (let object of containerState.hiddenTabs) { // eslint-disable-line prefer-const + promises.push(this.openTab({ + userContextId: userContextId, + url: object.url, + nofocus: options.nofocus || false, + pinned: object.pinned, + })); + } + + containerState.hiddenTabs = []; + + await Promise.all(promises); + return await identityState.storageArea.set(options.cookieStoreId, containerState); + }, + + sendTelemetryPayload(message = {}) { if (!message.event) { throw new Error("Missing event name for telemetry"); @@ -702,6 +1067,8 @@ const tabPageCounter = { } }; +console.log("aaa, startting"); + assignManager.init(); themeManager.init(); // Lets do this last as theme manager did a check before connecting before diff --git a/webextension/js/popup.js b/webextension/js/popup.js index 1006dab..c8f77bc 100644 --- a/webextension/js/popup.js +++ b/webextension/js/popup.js @@ -265,6 +265,11 @@ const Logic = { return Logic.userContextId(identity.cookieStoreId); }, + currentCookieStoreId() { + const identity = Logic.currentIdentity(); + return identity.cookieStoreId; + }, + sendTelemetryPayload(message = {}) { if (!message.event) { throw new Error("Missing event name for telemetry"); @@ -661,7 +666,7 @@ Logic.registerPanel(P_CONTAINER_INFO, { const identity = Logic.currentIdentity(); browser.runtime.sendMessage({ method: identity.hasHiddenTabs ? "showTabs" : "hideTabs", - userContextId: Logic.currentUserContextId() + cookieStoreId: Logic.currentCookieStoreId() }).then(() => { window.close(); }).catch(() => { @@ -753,12 +758,7 @@ Logic.registerPanel(P_CONTAINER_INFO, { if (tab.active) { tr.classList.add("clickable"); Logic.addEnterHandler(tr, () => { - browser.runtime.sendMessage({ - method: "showTab", - tabId: tab.id, - }).then(() => { - window.close(); - }).catch(() => { + browser.tabs.update(tab.id, {selected: true}).then(() => { window.close(); }); }); From 12a6bb3b9bfa773c91b97f8bc82d8b9dae7c2950 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 11 Jul 2017 13:40:01 +0100 Subject: [PATCH 03/10] Break web extensions code --- webextension/background.js | 1122 ----------------- webextension/js/.eslintrc.js | 14 + webextension/js/background/assignManager.js | 330 +++++ webextension/js/background/backgroundLogic.js | 345 +++++ webextension/js/background/badge.js | 18 + webextension/js/background/identityState.js | 159 +++ webextension/js/background/index.html | 25 + webextension/js/background/init.js | 27 + webextension/js/background/messageHandler.js | 177 +++ webextension/js/background/tabPageCounter.js | 64 + webextension/js/background/themeManager.js | 51 + webextension/js/popup.js | 185 +-- webextension/manifest.json | 2 +- 13 files changed, 1304 insertions(+), 1215 deletions(-) delete mode 100644 webextension/background.js create mode 100644 webextension/js/.eslintrc.js create mode 100644 webextension/js/background/assignManager.js create mode 100644 webextension/js/background/backgroundLogic.js create mode 100644 webextension/js/background/badge.js create mode 100644 webextension/js/background/identityState.js create mode 100644 webextension/js/background/index.html create mode 100644 webextension/js/background/init.js create mode 100644 webextension/js/background/messageHandler.js create mode 100644 webextension/js/background/tabPageCounter.js create mode 100644 webextension/js/background/themeManager.js diff --git a/webextension/background.js b/webextension/background.js deleted file mode 100644 index b160e6c..0000000 --- a/webextension/background.js +++ /dev/null @@ -1,1122 +0,0 @@ -const MAJOR_VERSIONS = ["2.3.0", "2.4.0"]; -const THEME_BUILD_DATE = 20170630; -const DEFAULT_TAB = "about:newtab"; - -const assignManager = { - MENU_ASSIGN_ID: "open-in-this-container", - MENU_REMOVE_ID: "remove-open-in-this-container", - storageArea: { - area: browser.storage.local, - exemptedTabs: {}, - - getSiteStoreKey(pageUrl) { - const url = new window.URL(pageUrl); - const storagePrefix = "siteContainerMap@@_"; - return `${storagePrefix}${url.hostname}`; - }, - - setExempted(pageUrl, tabId) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); - if (!(siteStoreKey in this.exemptedTabs)) { - this.exemptedTabs[siteStoreKey] = []; - } - this.exemptedTabs[siteStoreKey].push(tabId); - }, - - removeExempted(pageUrl) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); - this.exemptedTabs[siteStoreKey] = []; - }, - - isExempted(pageUrl, tabId) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); - if (!(siteStoreKey in this.exemptedTabs)) { - return false; - } - return this.exemptedTabs[siteStoreKey].includes(tabId); - }, - - get(pageUrl) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); - return new Promise((resolve, reject) => { - this.area.get([siteStoreKey]).then((storageResponse) => { - if (storageResponse && siteStoreKey in storageResponse) { - resolve(storageResponse[siteStoreKey]); - } - resolve(null); - }).catch((e) => { - reject(e); - }); - }); - }, - - set(pageUrl, data, exemptedTabIds) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); - if (exemptedTabIds) { - exemptedTabIds.forEach((tabId) => { - this.setExempted(pageUrl, tabId); - }); - } - return this.area.set({ - [siteStoreKey]: data - }); - }, - - remove(pageUrl) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); - // When we remove an assignment we should clear all the exemptions - this.removeExempted(pageUrl); - return this.area.remove([siteStoreKey]); - }, - - async deleteContainer(userContextId) { - const sitesByContainer = await this.getByContainer(userContextId); - this.area.remove(Object.keys(sitesByContainer)); - }, - - async getByContainer(userContextId) { - const sites = {}; - const siteConfigs = await this.area.get(); - Object.keys(siteConfigs).forEach((key) => { - // For some reason this is stored as string... lets check them both as that - if (String(siteConfigs[key].userContextId) === String(userContextId)) { - const site = siteConfigs[key]; - // In hindsight we should have stored this - // TODO file a follow up to clean the storage onLoad - site.hostname = key.replace(/^siteContainerMap@@_/, ""); - sites[key] = site; - } - }); - return sites; - } - }, - - _neverAsk(m) { - const pageUrl = m.pageUrl; - if (m.neverAsk === true) { - // If we have existing data and for some reason it hasn't been deleted etc lets update it - this.storageArea.get(pageUrl).then((siteSettings) => { - if (siteSettings) { - siteSettings.neverAsk = true; - this.storageArea.set(pageUrl, siteSettings); - } - }).catch((e) => { - throw e; - }); - } - }, - - // We return here so the confirm page can load the tab when exempted - async _exemptTab(m) { - const pageUrl = m.pageUrl; - this.storageArea.setExempted(pageUrl, m.tabId); - return true; - }, - - init() { - browser.contextMenus.onClicked.addListener((info, tab) => { - this._onClickedHandler(info, tab); - }); - - // Before a request is handled by the browser we decide if we should route through a different container - browser.webRequest.onBeforeRequest.addListener((options) => { - if (options.frameId !== 0 || options.tabId === -1) { - return {}; - } - this.removeContextMenu(); - return Promise.all([ - browser.tabs.get(options.tabId), - this.storageArea.get(options.url) - ]).then(([tab, siteSettings]) => { - const userContextId = this.getUserContextIdFromCookieStore(tab); - if (!siteSettings - || userContextId === siteSettings.userContextId - || tab.incognito - || this.storageArea.isExempted(options.url, tab.id)) { - return {}; - } - - this.reloadPageInContainer(options.url, userContextId, siteSettings.userContextId, tab.index + 1, siteSettings.neverAsk); - this.calculateContextMenu(tab); - - /* Removal of existing tabs: - We aim to open the new assigned container tab / warning prompt in it's own tab: - - As the history won't span from one container to another it seems most sane to not try and reopen a tab on history.back() - - When users open a new tab themselves we want to make sure we don't end up with three tabs as per: https://github.com/mozilla/testpilot-containers/issues/421 - If we are coming from an internal url that are used for the new tab page (NEW_TAB_PAGES), we can safely close as user is unlikely losing history - Detecting redirects on "new tab" opening actions is pretty hard as we don't get tab history: - - Redirects happen from Short URLs and tracking links that act as a gateway - - Extensions don't provide a way to history crawl for tabs, we could inject content scripts to do this - however they don't run on about:blank so this would likely be just as hacky. - We capture the time the tab was created and close if it was within the timeout to try to capture pages which haven't had user interaction or history. - */ - if (backgroundLogic.NEW_TAB_PAGES.has(tab.url) - || (messageHandler.lastCreatedTab - && messageHandler.lastCreatedTab.id === tab.id)) { - browser.tabs.remove(tab.id); - } - return { - cancel: true, - }; - }).catch((e) => { - throw e; - }); - },{urls: [""], types: ["main_frame"]}, ["blocking"]); - }, - - async _onClickedHandler(info, tab) { - const userContextId = this.getUserContextIdFromCookieStore(tab); - // Mapping ${URL(info.pageUrl).hostname} to ${userContextId} - if (userContextId) { - // let actionName; - let remove; - if (info.menuItemId === this.MENU_ASSIGN_ID) { - remove = false; - } else { - remove = true; - } - await this._setOrRemoveAssignment(tab.id, info.pageUrl, userContextId, remove); - } - }, - - - deleteContainer(userContextId) { - this.storageArea.deleteContainer(userContextId); - }, - - getUserContextIdFromCookieStore(tab) { - if (!("cookieStoreId" in tab)) { - return false; - } - return backgroundLogic.getUserContextIdFromCookieStoreId(tab.cookieStoreId); - }, - - isTabPermittedAssign(tab) { - // Ensure we are not an important about url - // Ensure we are not in incognito mode - const url = new URL(tab.url); - if (url.protocol === "about:" - || url.protocol === "moz-extension:" - || tab.incognito) { - return false; - } - return true; - }, - - async _setOrRemoveAssignment(tabId, pageUrl, userContextId, remove) { - let actionName; - - // https://github.com/mozilla/testpilot-containers/issues/626 - // Context menu has stored context IDs as strings, so we need to coerce - // the value to a string for accurate checking - userContextId = String(userContextId); - - if (!remove) { - const tabs = await browser.tabs.query({}); - const assignmentStoreKey = this.storageArea.getSiteStoreKey(pageUrl); - const exemptedTabIds = tabs.filter((tab) => { - const tabStoreKey = this.storageArea.getSiteStoreKey(tab.url); - /* Auto exempt all tabs that exist for this hostname that are not in the same container */ - if (tabStoreKey === assignmentStoreKey && - this.getUserContextIdFromCookieStore(tab) !== userContextId) { - return true; - } - return false; - }).map((tab) => { - return tab.id; - }); - - await this.storageArea.set(pageUrl, { - userContextId, - neverAsk: false - }, exemptedTabIds); - actionName = "added"; - } else { - await this.storageArea.remove(pageUrl); - actionName = "removed"; - } - browser.tabs.sendMessage(tabId, { - text: `Successfully ${actionName} site to always open in this container` - }); - backgroundLogic.sendTelemetryPayload({ - event: `${actionName}-container-assignment`, - userContextId: userContextId, - }); - const tab = await browser.tabs.get(tabId); - this.calculateContextMenu(tab); - }, - - async _getAssignment(tab) { - const cookieStore = this.getUserContextIdFromCookieStore(tab); - // Ensure we have a cookieStore to assign to - if (cookieStore - && this.isTabPermittedAssign(tab)) { - return await this.storageArea.get(tab.url); - } - return false; - }, - - _getByContainer(userContextId) { - return this.storageArea.getByContainer(userContextId); - }, - - removeContextMenu() { - // There is a focus issue in this menu where if you change window with a context menu click - // you get the wrong menu display because of async - // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1215376#c16 - // We also can't change for always private mode - // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1352102 - browser.contextMenus.remove(this.MENU_ASSIGN_ID); - browser.contextMenus.remove(this.MENU_REMOVE_ID); - }, - - async calculateContextMenu(tab) { - this.removeContextMenu(); - const siteSettings = await this._getAssignment(tab); - // Return early and not add an item if we have false - // False represents assignment is not permitted - if (siteSettings === false) { - return false; - } - // ✓ This is to mitigate https://bugzilla.mozilla.org/show_bug.cgi?id=1351418 - let prefix = " "; // Alignment of non breaking space, unknown why this requires so many spaces to align with the tick - let menuId = this.MENU_ASSIGN_ID; - const tabUserContextId = this.getUserContextIdFromCookieStore(tab); - if (siteSettings && - Number(siteSettings.userContextId) === Number(tabUserContextId)) { - prefix = "✓"; - menuId = this.MENU_REMOVE_ID; - } - browser.contextMenus.create({ - id: menuId, - title: `${prefix} Always Open in This Container`, - checked: true, - contexts: ["all"], - }); - }, - - reloadPageInContainer(url, currentUserContextId, userContextId, index, neverAsk = false) { - const cookieStoreId = backgroundLogic.cookieStoreId(userContextId); - const loadPage = browser.extension.getURL("confirm-page.html"); - // False represents assignment is not permitted - // If the user has explicitly checked "Never Ask Again" on the warning page we will send them straight there - if (neverAsk) { - browser.tabs.create({url, cookieStoreId, index}); - backgroundLogic.sendTelemetryPayload({ - event: "auto-reload-page-in-container", - userContextId: userContextId, - }); - } else { - backgroundLogic.sendTelemetryPayload({ - event: "prompt-to-reload-page-in-container", - userContextId: userContextId, - }); - let confirmUrl = `${loadPage}?url=${encodeURIComponent(url)}&cookieStoreId=${cookieStoreId}`; - let currentCookieStoreId; - if (currentUserContextId) { - currentCookieStoreId = backgroundLogic.cookieStoreId(currentUserContextId); - confirmUrl += `¤tCookieStoreId=${currentCookieStoreId}`; - } - browser.tabs.create({ - url: confirmUrl, - cookieStoreId: currentCookieStoreId, - index - }).then(() => { - // We don't want to sync this URL ever nor clutter the users history - browser.history.deleteUrl({url: confirmUrl}); - }).catch((e) => { - throw e; - }); - } - } -}; - -const identityState = { - storageArea: { - area: browser.storage.local, - - getContainerStoreKey(cookieStoreId) { - const storagePrefix = "identitiesState@@_"; - return `${storagePrefix}${cookieStoreId}`; - }, - - get(cookieStoreId) { - const storeKey = this.getContainerStoreKey(cookieStoreId); - return new Promise((resolve, reject) => { - this.area.get([storeKey]).then((storageResponse) => { - if (storageResponse && storeKey in storageResponse) { - resolve(storageResponse[storeKey]); - } - resolve(null); - }).catch((e) => { - reject(e); - }); - }); - }, - - set(cookieStoreId, data) { - const storeKey = this.getContainerStoreKey(cookieStoreId); - return this.area.set({ - [storeKey]: data - }); - }, - - remove(cookieStoreId) { - const storeKey = this.getContainerStoreKey(cookieStoreId); - return this.area.remove([storeKey]); - } - }, - - _createTabObject(tab) { - return Object.assign({}, tab); - }, - - async containersCounts() { - let containersCounts = { // eslint-disable-line prefer-const - "shown": 0, - "hidden": 0, - "total": 0 - }; - const containers = await browser.contextualIdentities.query({}); - for (const container in containers) { - const containerState = await this.storageArea.get(container.cookieStoreId); - if (containerState.openTabs > 0) { - ++containersCounts.shown; - ++containersCounts.total; - continue; - } else if (containerState.hiddenTabs.length > 0) { - ++containersCounts.hidden; - ++containersCounts.total; - continue; - } - } - return containersCounts; - }, - - containerTabCount(userContextId) { - // Returns the total of open and hidden tabs with this userContextId - let containerTabsCount = 0; - containerTabsCount += this._identitiesState[userContextId].openTabs; - containerTabsCount += this._identitiesState[userContextId].hiddenTabs.length; - return containerTabsCount; - }, - - totalContainerTabsCount() { - // Returns the number of total open tabs across ALL containers - let totalContainerTabsCount = 0; - for (const userContextId in this._identitiesState) { - totalContainerTabsCount += this._identitiesState[userContextId].openTabs; - } - return totalContainerTabsCount; - }, - - async totalNonContainerTabsCount() { - // Returns the number of open tabs NOT IN a container - let totalNonContainerTabsCount = 0; - const tabs = await browser.tabs.query({}); - for (const tab of tabs) { - if (this._getUserContextIdFromTab(tab) === 0) { - ++totalNonContainerTabsCount; - } - } - return totalNonContainerTabsCount; - }, - - remapTabsIfMissing(userContextId) { - // We already know this userContextId. - if (userContextId in this._identitiesState) { - return; - } - - this._identitiesState[userContextId] = this._createIdentityState(); - this.remapTabsFromUserContextId(userContextId); - }, - - remapTabsFromUserContextId(userContextId) { - this._identitiesState[userContextId].openTabs = this._matchTabsByContainer(userContextId).length; - }, -/*TODO check if used - remapTab(tab) { - const userContextId = this._getUserContextIdFromTab(tab); - if (userContextId) { - this.remapTabsFromUserContextId(userContextId); - } - }, -*/ - - _createIdentityState() { - return { - hiddenTabs: [], - openTabs: 0 - }; - }, -}; - -const backgroundLogic = { - NEW_TAB_PAGES: new Set([ - "about:startpage", - "about:newtab", - "about:home", - "about:blank" - ]), - - getUserContextIdFromCookieStoreId(cookieStoreId) { - if (!cookieStoreId) { - return false; - } - const container = cookieStoreId.replace("firefox-container-", ""); - if (container !== cookieStoreId) { - return container; - } - return false; - }, - - deleteContainer(userContextId) { - this.sendTelemetryPayload({ - event: "delete-container", - userContextId - }); - - const removeTabsPromise = this._closeTabs(userContextId); - - return new Promise((resolve) => { - removeTabsPromise.then(() => { - const removed = browser.contextualIdentities.remove(this.cookieStoreId(userContextId)); - removed.then(() => { - assignManager.deleteContainer(userContextId); - browser.runtime.sendMessage({ - method: "forgetIdentityAndRefresh" - }).then(() => { - resolve({done: true, userContextId}); - }).catch((e) => {throw e;}); - }).catch((e) => {throw e;}); - }).catch((e) => {throw e;}); - }); - }, - - createOrUpdateContainer(options) { - let donePromise; - if (options.userContextId !== "new") { - donePromise = browser.contextualIdentities.update( - this.cookieStoreId(options.userContextId), - options.params - ); - this.sendTelemetryPayload({ - event: "edit-container", - userContextId: options.userContextId - }); - } else { - donePromise = browser.contextualIdentities.create(options.params); - this.sendTelemetryPayload({ - event: "add-container" - }); - } - return donePromise.then(() => { - browser.runtime.sendMessage({ - method: "refreshNeeded" - }); - }); - }, - - openTab(options) { - let url = options.url || undefined; - const userContextId = ("userContextId" in options) ? options.userContextId : 0; - const active = ("nofocus" in options) ? options.nofocus : true; - const source = ("source" in options) ? options.source : null; - - const cookieStoreId = backgroundLogic.cookieStoreId(options.userContextId); - // Only send telemetry for tabs opened by UI - i.e., not via showTabs - if (source && userContextId) { - this.sendTelemetryPayload({ - "event": "open-tab", - "eventSource": source, - "userContextId": userContextId, - "clickedContainerTabCount": identityState.containerTabCount(cookieStoreId) - }); - } - // Autofocus url bar will happen in 54: https://bugzilla.mozilla.org/show_bug.cgi?id=1295072 - - // We can't open new tab pages, so open a blank tab. Used in tab un-hide - if (this.NEW_TAB_PAGES.has(url)) { - url = undefined; - } - - // Unhide all hidden tabs - this.showTabs({ - cookieStoreId - }); - return browser.tabs.create({ - url, - active, - pinned: options.pinned || false, - cookieStoreId - }); - }, - - async getTabs(options) { - if (!("cookieStoreId" in options)) { - return new Error("getTabs must be called with cookieStoreId argument."); - } - - const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(options.cookieStoreId); - this._remapTabsIfMissing(userContextId); - if (!this._isKnownContainer(userContextId)) { - return []; - } - - const list = []; - await this._containerTabs(userContextId).then((tabs) => { - tabs.forEach((tab) => { - list.push(this._createTabObject(tab)); - }); - }); - - const containerState = await identityState.storageArea.get(options.cookieStoreId); - return list.concat(containerState.hiddenTabs); - }, - - async moveTabsToWindow(options) { - if (!("cookieStoreId" in options)) { - return new Error("moveTabsToWindow must be called with cookieStoreId argument."); - } - - const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(options.cookieStoreId); - identityState.remapTabsIfMissing(userContextId); - if (!identityState.isKnownContainer(userContextId)) { - return null; - } - - this.sendTelemetryPayload({ - "event": "move-tabs-to-window", - "userContextId": userContextId, - "clickedContainerTabCount": this._containerTabCount(userContextId), - }); - - const list = this._matchTabsByContainer(userContextId); - - // Nothing to do - if (list.length === 0 && - this._identitiesState[userContextId].hiddenTabs.length === 0) { - return; - } -//TODO check list returns ids - const window = await browser.windows.create({ - tabId: list.shift() - }); - browser.tabs.move(list, { - windowId: window.id, - index: -1 - }); - - const containerState = await identityState.storageArea.get(options.cookieStoreId); - // Let's show the hidden tabs. - for (let object of containerState.hiddenTabs) { // eslint-disable-line prefer-const - browser.tabs.create(object.url || DEFAULT_TAB, { - cookieStoreId: options.cookieStoreId - }); - } - - containerState.hiddenTabs = []; - - // Let's close all the normal tab in the new window. In theory it - // should be only the first tab, but maybe there are addons doing - // crazy stuff. - const tabs = browser.tabs.query({windowId: window.id}); - for (let tab of tabs) { // eslint-disable-line prefer-const - if (tabs.cookieStoreId !== options.cookieStoreId) { - browser.tabs.remove(tab.id); - } - } - }, - - _closeTabs(userContextId) { - return this._containerTabs(userContextId).then((tabs) => { - const tabIds = tabs.map((tab) => tab.id); - return browser.tabs.remove(tabIds); - }); - }, - - async queryIdentitiesState() { - const identities = await browser.contextualIdentities.query({}); - const identitiesMap = await Promise.all(identities.map(async function (identity) { - identityState.remapTabsIfMissing(identity.userContextId); - const containerState = await identityState.get(identity.cookieStoreId); - return { - hasHiddenTabs: !!containerState.hiddenTabs.length, - hasOpenTabs: !!containerState.openTabs - }; - })); - - return identitiesMap; - }, - - _isKnownContainer(userContextId) { - return userContextId in this._identitiesState; - }, - - - - async sortTabs() { - const containersCounts = identityState.containersCounts(); - this.sendTelemetryPayload({ - "event": "sort-tabs", - "shownContainersCount": containersCounts.shown, - "totalContainerTabsCount": identityState.totalContainerTabsCount(), - "totalNonContainerTabsCount": await identityState.totalNonContainerTabsCount() - }); - return new Promise(resolve => { -//TODO fix this -/* - for (let window of windows.browserWindows) { // eslint-disable-line prefer-const - // First the pinned tabs, then the normal ones. - this._sortTabsInternal(window, true); - this._sortTabsInternal(window, false); - } -*/ - resolve(null); - }); - }, - - _sortTabsInternal(window, pinnedTabs) { - // From model to XUL window. - const xulWindow = viewFor(window); - - const tabs = browser.tabs.query({windowId: browser.windows.WINDOW_ID_CURRENT}); - let pos = 0; - - // Let's collect UCIs/tabs for this window. - const map = new Map; - for (const tab of tabs) { - if (pinnedTabs && !tabsUtils.isPinned(tab)) { - // We don't have, or we already handled all the pinned tabs. - break; - } - - if (!pinnedTabs && tabsUtils.isPinned(tab)) { - // pinned tabs must be consider as taken positions. - ++pos; - continue; - } - - const userContextId = this._getUserContextIdFromTab(tab); - if (!map.has(userContextId)) { - map.set(userContextId, []); - } - map.get(userContextId).push(tab); - } - - // Let's sort the map. - const sortMap = new Map([...map.entries()].sort((a, b) => a[0] > b[0])); - - // Let's move tabs. - sortMap.forEach(tabs => { - for (const tab of tabs) { - xulWindow.gBrowser.moveTabTo(tab, pos++); - } - }); - }, - - async hideTabs(options) { - if (!("cookieStoreId" in options)) { - return new Error("hideTabs must be called with cookieStoreId option."); - } - - const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(options.cookieStoreId); - this._remapTabsIfMissing(userContextId); - if (!this._isKnownContainer(userContextId)) { - return null; - } - - const containersCounts = identityState.containersCounts(); - this.sendTelemetryPayload({ - "event": "hide-tabs", - "userContextId": userContextId, - "clickedContainerTabCount": this._containerTabCount(options.userContextId), - "shownContainersCount": containersCounts.shown, - "hiddenContainersCount": containersCounts.hidden, - "totalContainersCount": containersCounts.total - }); - - const containerState = await identityState.storageArea.get(options.cookieStoreId); - - await this._containerTabs(userContextId).then((tabs) => { - tabs.forEach((tab) => { - const tabObject = this._createTabObject(tab); - // This tab is going to be closed. Let's mark this tabObject as - // non-active. - tabObject.active = false; - containerState.hiddenTabs.push(tabObject); - }); - }); - - await this._closeTabs(userContextId); - - return identityState.storageArea.set(options.cookieStoreId, containerState); - }, - - async showTabs(options) { - if (!("cookieStoreId" in options)) { - return Promise.reject("showTabs must be called with cookieStoreId argument."); - } - - const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(options.cookieStoreId); - identityState.remapTabsIfMissing(options.cookieStoreId); - if (!identityState.isKnownContainer(userContextId)) { - return null; - } - - const containersCounts = identityState.containersCounts(); - this.sendTelemetryPayload({ - "event": "show-tabs", - "userContextId": userContextId, - "clickedContainerTabCount": identityState.containerTabCount(options.userContextId), - "shownContainersCount": containersCounts.shown, - "hiddenContainersCount": containersCounts.hidden, - "totalContainersCount": containersCounts.total - }); - - const promises = []; - - const containerState = await identityState.storageArea.get(options.cookieStoreId); - - for (let object of containerState.hiddenTabs) { // eslint-disable-line prefer-const - promises.push(this.openTab({ - userContextId: userContextId, - url: object.url, - nofocus: options.nofocus || false, - pinned: object.pinned, - })); - } - - containerState.hiddenTabs = []; - - await Promise.all(promises); - return await identityState.storageArea.set(options.cookieStoreId, containerState); - }, - - - sendTelemetryPayload(message = {}) { - if (!message.event) { - throw new Error("Missing event name for telemetry"); - } - message.method = "sendTelemetryPayload"; - browser.runtime.sendMessage(message); - }, - - cookieStoreId(userContextId) { - return `firefox-container-${userContextId}`; - }, - - _containerTabs(userContextId) { - return browser.tabs.query({ - cookieStoreId: this.cookieStoreId(userContextId) - }).catch((e) => {throw e;}); - }, -}; - -const messageHandler = { - // After the timer completes we assume it's a tab the user meant to keep open - // We use this to catch redirected tabs that have just opened - // If this were in platform we would change how the tab opens based on "new tab" link navigations such as ctrl+click - LAST_CREATED_TAB_TIMER: 2000, - - init() { - // Handles messages from webextension code - browser.runtime.onMessage.addListener((m) => { - let response; - - switch (m.method) { - case "deleteContainer": - response = backgroundLogic.deleteContainer(m.message.userContextId); - break; - case "createOrUpdateContainer": - response = backgroundLogic.createOrUpdateContainer(m.message); - break; - case "openTab": - // Same as open-tab for index.js - response = backgroundLogic.openTab(m.message); - break; - case "neverAsk": - assignManager._neverAsk(m); - break; - case "getAssignment": - response = browser.tabs.get(m.tabId).then((tab) => { - return assignManager._getAssignment(tab); - }); - break; - case "getAssignmentObjectByContainer": - response = assignManager._getByContainer(m.message.userContextId); - break; - case "setOrRemoveAssignment": - // m.tabId is used for where to place the in content message - // m.url is the assignment to be removed/added - response = browser.tabs.get(m.tabId).then((tab) => { - return assignManager._setOrRemoveAssignment(tab.id, m.url, m.userContextId, m.value); - }); - break; - case "exemptContainerAssignment": - response = assignManager._exemptTab(m); - break; - } - return response; - }); - - // Handles messages from sdk code - const port = browser.runtime.connect(); - port.onMessage.addListener(m => { - switch (m.type) { - case "lightweight-theme-changed": - themeManager.update(m.message); - break; - case "open-tab": - backgroundLogic.openTab(m.message); - break; - default: - throw new Error(`Unhandled message type: ${m.message}`); - } - }); - - browser.tabs.onCreated.addListener((tab) => { - // This works at capturing the tabs as they are created - // However we need onFocusChanged and onActivated to capture the initial tab - if (tab.id === -1) { - return {}; - } - tabPageCounter.initTabCounter(tab); - }); - - browser.tabs.onRemoved.addListener((tabId) => { - if (tabId === -1) { - return {}; - } - tabPageCounter.sendTabCountAndDelete(tabId); - }); - - browser.tabs.onActivated.addListener((info) => { - assignManager.removeContextMenu(); - browser.tabs.get(info.tabId).then((tab) => { - tabPageCounter.initTabCounter(tab); - assignManager.calculateContextMenu(tab); - }).catch((e) => { - throw e; - }); - }); - - browser.windows.onFocusChanged.addListener((windowId) => { - assignManager.removeContextMenu(); - // browserAction loses background color in new windows ... - // https://bugzil.la/1314674 - // https://github.com/mozilla/testpilot-containers/issues/608 - // ... so re-call displayBrowserActionBadge on window changes - displayBrowserActionBadge(); - browser.tabs.query({active: true, windowId}).then((tabs) => { - if (tabs && tabs[0]) { - tabPageCounter.initTabCounter(tabs[0]); - assignManager.calculateContextMenu(tabs[0]); - } - }).catch((e) => { - throw e; - }); - }); - - browser.idle.onStateChanged.addListener((newState) => { - browser.tabs.query({}).then(tabs => { - for (let tab of tabs) { // eslint-disable-line prefer-const - if (newState === "idle") { - tabPageCounter.sendTabCountAndDelete(tab.id, "user-went-idle"); - } else if (newState === "active" && tab.active) { - tabPageCounter.initTabCounter(tab); - } - } - }).catch(e => { - throw e; - }); - }); - - browser.webRequest.onCompleted.addListener((details) => { - if (details.frameId !== 0 || details.tabId === -1) { - return {}; - } - assignManager.removeContextMenu(); - - browser.tabs.get(details.tabId).then((tab) => { - tabPageCounter.incrementTabCount(tab); - assignManager.calculateContextMenu(tab); - }).catch((e) => { - throw e; - }); - }, {urls: [""], types: ["main_frame"]}); - - // lets remember the last tab created so we can close it if it looks like a redirect - browser.tabs.onCreated.addListener((details) => { - this.lastCreatedTab = details; - setTimeout(() => { - this.lastCreatedTab = null; - }, this.LAST_CREATED_TAB_TIMER); - }); - - } -}; - -const themeManager = { - existingTheme: null, - disabled: false, - async init() { - const browserInfo = await browser.runtime.getBrowserInfo(); - if (Number(browserInfo.buildID.substring(0, 8)) >= THEME_BUILD_DATE) { - this.disabled = true; - } - this.check(); - }, - setPopupIcon(theme) { - if (this.disabled) { - return; - } - let icons = { - 16: "img/container-site-d-24.png", - 32: "img/container-site-d-48.png" - }; - if (theme === "firefox-compact-dark@mozilla.org") { - icons = { - 16: "img/container-site-w-24.png", - 32: "img/container-site-w-48.png" - }; - } - browser.browserAction.setIcon({ - path: icons - }); - }, - check() { - browser.runtime.sendMessage({ - method: "getTheme" - }).then((theme) => { - this.update(theme); - }).catch(() => { - throw new Error("Unable to get theme"); - }); - }, - update(theme) { - if (this.existingTheme !== theme) { - this.setPopupIcon(theme); - this.existingTheme = theme; - } - } -}; - -const tabPageCounter = { - counters: {}, - - initTabCounter(tab) { - if (tab.id in this.counters) { - if (!("activity" in this.counters[tab.id])) { - this.counters[tab.id].activity = { - "cookieStoreId": tab.cookieStoreId, - "pageRequests": 0 - }; - } - if (!("tab" in this.counters[tab.id])) { - this.counters[tab.id].tab = { - "cookieStoreId": tab.cookieStoreId, - "pageRequests": 0 - }; - } - } else { - this.counters[tab.id] = {}; - this.counters[tab.id].tab = { - "cookieStoreId": tab.cookieStoreId, - "pageRequests": 0 - }; - this.counters[tab.id].activity = { - "cookieStoreId": tab.cookieStoreId, - "pageRequests": 0 - }; - } - }, - - sendTabCountAndDelete(tabId, why = "user-closed-tab") { - if (!(this.counters[tabId])) { - return; - } - if (why === "user-closed-tab" && this.counters[tabId].tab) { - backgroundLogic.sendTelemetryPayload({ - event: "page-requests-completed-per-tab", - userContextId: this.counters[tabId].tab.cookieStoreId, - pageRequestCount: this.counters[tabId].tab.pageRequests - }); - // When we send the ping because the user closed the tab, - // delete both the 'tab' and 'activity' counters - delete this.counters[tabId]; - } else if (why === "user-went-idle" && this.counters[tabId].activity) { - backgroundLogic.sendTelemetryPayload({ - event: "page-requests-completed-per-activity", - userContextId: this.counters[tabId].activity.cookieStoreId, - pageRequestCount: this.counters[tabId].activity.pageRequests - }); - // When we send the ping because the user went idle, - // only reset the 'activity' counter - this.counters[tabId].activity = { - "cookieStoreId": this.counters[tabId].tab.cookieStoreId, - "pageRequests": 0 - }; - } - }, - - incrementTabCount(tab) { - this.counters[tab.id].tab.pageRequests++; - this.counters[tab.id].activity.pageRequests++; - } -}; - -console.log("aaa, startting"); - -assignManager.init(); -themeManager.init(); -// Lets do this last as theme manager did a check before connecting before -messageHandler.init(); - -browser.runtime.sendMessage({ - method: "getPreference", - pref: "browser.privatebrowsing.autostart" -}).then(pbAutoStart => { - - // We don't want to disable the addon if we are in auto private-browsing. - if (!pbAutoStart) { - browser.tabs.onCreated.addListener(tab => { - if (tab.incognito) { - disableAddon(tab.id); - } - }); - - browser.tabs.query({}).then(tabs => { - for (let tab of tabs) { // eslint-disable-line prefer-const - if (tab.incognito) { - disableAddon(tab.id); - } - } - }).catch(() => {}); - } -}).catch(() => {}); - -function disableAddon(tabId) { - browser.browserAction.disable(tabId); - browser.browserAction.setTitle({ tabId, title: "Containers disabled in Private Browsing Mode" }); -} - -async function getExtensionInfo() { - const manifestPath = browser.extension.getURL("manifest.json"); - const response = await fetch(manifestPath); - const extensionInfo = await response.json(); - return extensionInfo; -} - -async function displayBrowserActionBadge() { - const extensionInfo = await getExtensionInfo(); - const storage = await browser.storage.local.get({browserActionBadgesClicked: []}); - - if (MAJOR_VERSIONS.indexOf(extensionInfo.version) > -1 && - storage.browserActionBadgesClicked.indexOf(extensionInfo.version) < 0) { - browser.browserAction.setBadgeBackgroundColor({color: "rgba(0,217,0,255)"}); - browser.browserAction.setBadgeText({text: "NEW"}); - } -} -displayBrowserActionBadge(); diff --git a/webextension/js/.eslintrc.js b/webextension/js/.eslintrc.js new file mode 100644 index 0000000..620e9c6 --- /dev/null +++ b/webextension/js/.eslintrc.js @@ -0,0 +1,14 @@ +module.exports = { + "extends": [ + "../../.eslintrc.js" + ], + "globals": { + "assignManager": true, + "badge": true, + "backgroundLogic": true, + "identityState": true, + "messageHandler": true, + "tabPageCounter": true, + "themeManager": true + } +}; diff --git a/webextension/js/background/assignManager.js b/webextension/js/background/assignManager.js new file mode 100644 index 0000000..e356c6d --- /dev/null +++ b/webextension/js/background/assignManager.js @@ -0,0 +1,330 @@ +const assignManager = { + MENU_ASSIGN_ID: "open-in-this-container", + MENU_REMOVE_ID: "remove-open-in-this-container", + storageArea: { + area: browser.storage.local, + exemptedTabs: {}, + + getSiteStoreKey(pageUrl) { + const url = new window.URL(pageUrl); + const storagePrefix = "siteContainerMap@@_"; + return `${storagePrefix}${url.hostname}`; + }, + + setExempted(pageUrl, tabId) { + const siteStoreKey = this.getSiteStoreKey(pageUrl); + if (!(siteStoreKey in this.exemptedTabs)) { + this.exemptedTabs[siteStoreKey] = []; + } + this.exemptedTabs[siteStoreKey].push(tabId); + }, + + removeExempted(pageUrl) { + const siteStoreKey = this.getSiteStoreKey(pageUrl); + this.exemptedTabs[siteStoreKey] = []; + }, + + isExempted(pageUrl, tabId) { + const siteStoreKey = this.getSiteStoreKey(pageUrl); + if (!(siteStoreKey in this.exemptedTabs)) { + return false; + } + return this.exemptedTabs[siteStoreKey].includes(tabId); + }, + + get(pageUrl) { + const siteStoreKey = this.getSiteStoreKey(pageUrl); + return new Promise((resolve, reject) => { + this.area.get([siteStoreKey]).then((storageResponse) => { + if (storageResponse && siteStoreKey in storageResponse) { + resolve(storageResponse[siteStoreKey]); + } + resolve(null); + }).catch((e) => { + reject(e); + }); + }); + }, + + set(pageUrl, data, exemptedTabIds) { + const siteStoreKey = this.getSiteStoreKey(pageUrl); + if (exemptedTabIds) { + exemptedTabIds.forEach((tabId) => { + this.setExempted(pageUrl, tabId); + }); + } + return this.area.set({ + [siteStoreKey]: data + }); + }, + + remove(pageUrl) { + const siteStoreKey = this.getSiteStoreKey(pageUrl); + // When we remove an assignment we should clear all the exemptions + this.removeExempted(pageUrl); + return this.area.remove([siteStoreKey]); + }, + + async deleteContainer(userContextId) { + const sitesByContainer = await this.getByContainer(userContextId); + this.area.remove(Object.keys(sitesByContainer)); + }, + + async getByContainer(userContextId) { + const sites = {}; + const siteConfigs = await this.area.get(); + Object.keys(siteConfigs).forEach((key) => { + // For some reason this is stored as string... lets check them both as that + if (String(siteConfigs[key].userContextId) === String(userContextId)) { + const site = siteConfigs[key]; + // In hindsight we should have stored this + // TODO file a follow up to clean the storage onLoad + site.hostname = key.replace(/^siteContainerMap@@_/, ""); + sites[key] = site; + } + }); + return sites; + } + }, + + _neverAsk(m) { + const pageUrl = m.pageUrl; + if (m.neverAsk === true) { + // If we have existing data and for some reason it hasn't been deleted etc lets update it + this.storageArea.get(pageUrl).then((siteSettings) => { + if (siteSettings) { + siteSettings.neverAsk = true; + this.storageArea.set(pageUrl, siteSettings); + } + }).catch((e) => { + throw e; + }); + } + }, + + // We return here so the confirm page can load the tab when exempted + async _exemptTab(m) { + const pageUrl = m.pageUrl; + this.storageArea.setExempted(pageUrl, m.tabId); + return true; + }, + + init() { + browser.contextMenus.onClicked.addListener((info, tab) => { + this._onClickedHandler(info, tab); + }); + + // Before a request is handled by the browser we decide if we should route through a different container + browser.webRequest.onBeforeRequest.addListener((options) => { + if (options.frameId !== 0 || options.tabId === -1) { + return {}; + } + this.removeContextMenu(); + return Promise.all([ + browser.tabs.get(options.tabId), + this.storageArea.get(options.url) + ]).then(([tab, siteSettings]) => { + const userContextId = this.getUserContextIdFromCookieStore(tab); + if (!siteSettings + || userContextId === siteSettings.userContextId + || tab.incognito + || this.storageArea.isExempted(options.url, tab.id)) { + return {}; + } + + this.reloadPageInContainer(options.url, userContextId, siteSettings.userContextId, tab.index + 1, siteSettings.neverAsk); + this.calculateContextMenu(tab); + + /* Removal of existing tabs: + We aim to open the new assigned container tab / warning prompt in it's own tab: + - As the history won't span from one container to another it seems most sane to not try and reopen a tab on history.back() + - When users open a new tab themselves we want to make sure we don't end up with three tabs as per: https://github.com/mozilla/testpilot-containers/issues/421 + If we are coming from an internal url that are used for the new tab page (NEW_TAB_PAGES), we can safely close as user is unlikely losing history + Detecting redirects on "new tab" opening actions is pretty hard as we don't get tab history: + - Redirects happen from Short URLs and tracking links that act as a gateway + - Extensions don't provide a way to history crawl for tabs, we could inject content scripts to do this + however they don't run on about:blank so this would likely be just as hacky. + We capture the time the tab was created and close if it was within the timeout to try to capture pages which haven't had user interaction or history. + */ + if (backgroundLogic.NEW_TAB_PAGES.has(tab.url) + || (messageHandler.lastCreatedTab + && messageHandler.lastCreatedTab.id === tab.id)) { + browser.tabs.remove(tab.id); + } + return { + cancel: true, + }; + }).catch((e) => { + throw e; + }); + },{urls: [""], types: ["main_frame"]}, ["blocking"]); + }, + + async _onClickedHandler(info, tab) { + const userContextId = this.getUserContextIdFromCookieStore(tab); + // Mapping ${URL(info.pageUrl).hostname} to ${userContextId} + if (userContextId) { + // let actionName; + let remove; + if (info.menuItemId === this.MENU_ASSIGN_ID) { + remove = false; + } else { + remove = true; + } + await this._setOrRemoveAssignment(tab.id, info.pageUrl, userContextId, remove); + } + }, + + + deleteContainer(userContextId) { + this.storageArea.deleteContainer(userContextId); + }, + + getUserContextIdFromCookieStore(tab) { + if (!("cookieStoreId" in tab)) { + return false; + } + return backgroundLogic.getUserContextIdFromCookieStoreId(tab.cookieStoreId); + }, + + isTabPermittedAssign(tab) { + // Ensure we are not an important about url + // Ensure we are not in incognito mode + const url = new URL(tab.url); + if (url.protocol === "about:" + || url.protocol === "moz-extension:" + || tab.incognito) { + return false; + } + return true; + }, + + async _setOrRemoveAssignment(tabId, pageUrl, userContextId, remove) { + let actionName; + + // https://github.com/mozilla/testpilot-containers/issues/626 + // Context menu has stored context IDs as strings, so we need to coerce + // the value to a string for accurate checking + userContextId = String(userContextId); + + if (!remove) { + const tabs = await browser.tabs.query({}); + const assignmentStoreKey = this.storageArea.getSiteStoreKey(pageUrl); + const exemptedTabIds = tabs.filter((tab) => { + const tabStoreKey = this.storageArea.getSiteStoreKey(tab.url); + /* Auto exempt all tabs that exist for this hostname that are not in the same container */ + if (tabStoreKey === assignmentStoreKey && + this.getUserContextIdFromCookieStore(tab) !== userContextId) { + return true; + } + return false; + }).map((tab) => { + return tab.id; + }); + + await this.storageArea.set(pageUrl, { + userContextId, + neverAsk: false + }, exemptedTabIds); + actionName = "added"; + } else { + await this.storageArea.remove(pageUrl); + actionName = "removed"; + } + browser.tabs.sendMessage(tabId, { + text: `Successfully ${actionName} site to always open in this container` + }); + backgroundLogic.sendTelemetryPayload({ + event: `${actionName}-container-assignment`, + userContextId: userContextId, + }); + const tab = await browser.tabs.get(tabId); + this.calculateContextMenu(tab); + }, + + async _getAssignment(tab) { + const cookieStore = this.getUserContextIdFromCookieStore(tab); + // Ensure we have a cookieStore to assign to + if (cookieStore + && this.isTabPermittedAssign(tab)) { + return await this.storageArea.get(tab.url); + } + return false; + }, + + _getByContainer(userContextId) { + return this.storageArea.getByContainer(userContextId); + }, + + removeContextMenu() { + // There is a focus issue in this menu where if you change window with a context menu click + // you get the wrong menu display because of async + // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1215376#c16 + // We also can't change for always private mode + // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1352102 + browser.contextMenus.remove(this.MENU_ASSIGN_ID); + browser.contextMenus.remove(this.MENU_REMOVE_ID); + }, + + async calculateContextMenu(tab) { + this.removeContextMenu(); + const siteSettings = await this._getAssignment(tab); + // Return early and not add an item if we have false + // False represents assignment is not permitted + if (siteSettings === false) { + return false; + } + // ✓ This is to mitigate https://bugzilla.mozilla.org/show_bug.cgi?id=1351418 + let prefix = " "; // Alignment of non breaking space, unknown why this requires so many spaces to align with the tick + let menuId = this.MENU_ASSIGN_ID; + const tabUserContextId = this.getUserContextIdFromCookieStore(tab); + if (siteSettings && + Number(siteSettings.userContextId) === Number(tabUserContextId)) { + prefix = "✓"; + menuId = this.MENU_REMOVE_ID; + } + browser.contextMenus.create({ + id: menuId, + title: `${prefix} Always Open in This Container`, + checked: true, + contexts: ["all"], + }); + }, + + reloadPageInContainer(url, currentUserContextId, userContextId, index, neverAsk = false) { + const cookieStoreId = backgroundLogic.cookieStoreId(userContextId); + const loadPage = browser.extension.getURL("confirm-page.html"); + // False represents assignment is not permitted + // If the user has explicitly checked "Never Ask Again" on the warning page we will send them straight there + if (neverAsk) { + browser.tabs.create({url, cookieStoreId, index}); + backgroundLogic.sendTelemetryPayload({ + event: "auto-reload-page-in-container", + userContextId: userContextId, + }); + } else { + backgroundLogic.sendTelemetryPayload({ + event: "prompt-to-reload-page-in-container", + userContextId: userContextId, + }); + let confirmUrl = `${loadPage}?url=${encodeURIComponent(url)}&cookieStoreId=${cookieStoreId}`; + let currentCookieStoreId; + if (currentUserContextId) { + currentCookieStoreId = backgroundLogic.cookieStoreId(currentUserContextId); + confirmUrl += `¤tCookieStoreId=${currentCookieStoreId}`; + } + browser.tabs.create({ + url: confirmUrl, + cookieStoreId: currentCookieStoreId, + index + }).then(() => { + // We don't want to sync this URL ever nor clutter the users history + browser.history.deleteUrl({url: confirmUrl}); + }).catch((e) => { + throw e; + }); + } + } +}; + +assignManager.init(); diff --git a/webextension/js/background/backgroundLogic.js b/webextension/js/background/backgroundLogic.js new file mode 100644 index 0000000..42ee382 --- /dev/null +++ b/webextension/js/background/backgroundLogic.js @@ -0,0 +1,345 @@ +const DEFAULT_TAB = "about:newtab"; +const backgroundLogic = { + NEW_TAB_PAGES: new Set([ + "about:startpage", + "about:newtab", + "about:home", + "about:blank" + ]), + + async getExtensionInfo() { + const manifestPath = browser.extension.getURL("manifest.json"); + const response = await fetch(manifestPath); + const extensionInfo = await response.json(); + return extensionInfo; + }, + + getUserContextIdFromCookieStoreId(cookieStoreId) { + if (!cookieStoreId) { + return false; + } + const container = cookieStoreId.replace("firefox-container-", ""); + if (container !== cookieStoreId) { + return container; + } + return false; + }, + + async deleteContainer(userContextId) { + this.sendTelemetryPayload({ + event: "delete-container", + userContextId + }); + + await this._closeTabs(userContextId); + await browser.contextualIdentities.remove(this.cookieStoreId(userContextId)); + assignManager.deleteContainer(userContextId); + await browser.runtime.sendMessage({ + method: "forgetIdentityAndRefresh" + }); + return {done: true, userContextId}; + }, + + async createOrUpdateContainer(options) { + let donePromise; + if (options.userContextId !== "new") { + donePromise = browser.contextualIdentities.update( + this.cookieStoreId(options.userContextId), + options.params + ); + this.sendTelemetryPayload({ + event: "edit-container", + userContextId: options.userContextId + }); + } else { + donePromise = browser.contextualIdentities.create(options.params); + this.sendTelemetryPayload({ + event: "add-container" + }); + } + await donePromise; + browser.runtime.sendMessage({ + method: "refreshNeeded" + }); + }, + + async openTab(options) { + let url = options.url || undefined; + const userContextId = ("userContextId" in options) ? options.userContextId : 0; + const active = ("nofocus" in options) ? options.nofocus : true; + const source = ("source" in options) ? options.source : null; + + const cookieStoreId = backgroundLogic.cookieStoreId(userContextId); + // Only send telemetry for tabs opened by UI - i.e., not via showTabs + if (source && userContextId) { + this.sendTelemetryPayload({ + "event": "open-tab", + "eventSource": source, + "userContextId": userContextId, + "clickedContainerTabCount": await identityState.containerTabCount(cookieStoreId) + }); + } + // Autofocus url bar will happen in 54: https://bugzilla.mozilla.org/show_bug.cgi?id=1295072 + + // We can't open new tab pages, so open a blank tab. Used in tab un-hide + if (this.NEW_TAB_PAGES.has(url)) { + url = undefined; + } + + // Unhide all hidden tabs + this.showTabs({ + cookieStoreId + }); + return browser.tabs.create({ + url, + active, + pinned: options.pinned || false, + cookieStoreId + }); + }, + + async getTabs(options) { + if (!("cookieStoreId" in options)) { + return new Error("getTabs must be called with cookieStoreId argument."); + } + + const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(options.cookieStoreId); + await identityState.remapTabsIfMissing(options.cookieStoreId); + const isKnownContainer = await identityState._isKnownContainer(userContextId); + if (!isKnownContainer) { + return []; + } + + const list = []; + const tabs = await this._containerTabs(options.cookieStoreId); + tabs.forEach((tab) => { + list.push(identityState._createTabObject(tab)); + }); + + const containerState = await identityState.storageArea.get(options.cookieStoreId); + return list.concat(containerState.hiddenTabs); + }, + + async moveTabsToWindow(options) { + if (!("cookieStoreId" in options)) { + return new Error("moveTabsToWindow must be called with cookieStoreId argument."); + } + + const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(options.cookieStoreId); + await identityState.remapTabsIfMissing(options.cookieStoreId); + if (!identityState._isKnownContainer(userContextId)) { + return null; + } + + this.sendTelemetryPayload({ + "event": "move-tabs-to-window", + "userContextId": userContextId, + "clickedContainerTabCount": identityState.containerTabCount(userContextId), + }); + + const list = await identityState._matchTabsByContainer(options.cookieStoreId); + + const containerState = await identityState.storageArea.get(options.cookieStoreId); + // Nothing to do + if (list.length === 0 && + containerState.hiddenTabs.length === 0) { + return; + } + const window = await browser.windows.create({ + tabId: list.shift().id + }); + browser.tabs.move(list, { + windowId: window.id, + index: -1 + }); + + // Let's show the hidden tabs. + for (let object of containerState.hiddenTabs) { // eslint-disable-line prefer-const + browser.tabs.create(object.url || DEFAULT_TAB, { + windowId: window.id, + cookieStoreId: options.cookieStoreId + }); + } + + containerState.hiddenTabs = []; + + // Let's close all the normal tab in the new window. In theory it + // should be only the first tab, but maybe there are addons doing + // crazy stuff. + const tabs = browser.tabs.query({windowId: window.id}); + for (let tab of tabs) { // eslint-disable-line prefer-const + if (tabs.cookieStoreId !== options.cookieStoreId) { + browser.tabs.remove(tab.id); + } + } + return await identityState.storageArea.set(options.cookieStoreId, containerState); + }, + + async _closeTabs(userContextId) { + const cookieStoreId = this.cookieStoreId(userContextId); + const tabs = await this._containerTabs(cookieStoreId); + const tabIds = tabs.map((tab) => tab.id); + return browser.tabs.remove(tabIds); + }, + + async queryIdentitiesState() { + const identities = await browser.contextualIdentities.query({}); + const identitiesOutput = {}; + const identitiesPromise = identities.map(async function (identity) { + await identityState.remapTabsIfMissing(identity.cookieStoreId); + const containerState = await identityState.storageArea.get(identity.cookieStoreId); + identitiesOutput[identity.cookieStoreId] = { + hasHiddenTabs: !!containerState.hiddenTabs.length, + hasOpenTabs: !!containerState.openTabs + }; + return; + }); + await Promise.all(identitiesPromise); + return identitiesOutput; + }, + + async sortTabs() { + const containersCounts = identityState.containersCounts(); + this.sendTelemetryPayload({ + "event": "sort-tabs", + "shownContainersCount": containersCounts.shown, + "totalContainerTabsCount": await identityState.totalContainerTabsCount(), + "totalNonContainerTabsCount": await identityState.totalNonContainerTabsCount() + }); + const windows = await browser.windows.getAll(); + for (let window of windows) { // eslint-disable-line prefer-const + // First the pinned tabs, then the normal ones. + await this._sortTabsInternal(window, true); + await this._sortTabsInternal(window, false); + } + }, + + async _sortTabsInternal(window, pinnedTabs) { + const tabs = await browser.tabs.query({windowId: window.id}); + let pos = 0; + + // Let's collect UCIs/tabs for this window. + const map = new Map; + for (const tab of tabs) { + if (pinnedTabs && !tab.pinned) { + // We don't have, or we already handled all the pinned tabs. + break; + } + + if (!pinnedTabs && tab.pinned) { + // pinned tabs must be consider as taken positions. + ++pos; + continue; + } + + const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(tab.cookieStoreId); + if (!map.has(userContextId)) { + map.set(userContextId, []); + } + map.get(userContextId).push(tab); + } + + // Let's sort the map. + const sortMap = new Map([...map.entries()].sort((a, b) => a[0] > b[0])); + + // Let's move tabs. + sortMap.forEach(tabs => { + for (const tab of tabs) { + ++pos; + browser.tabs.move(tab.id, { + windowId: window.id, + index: pos + }); + //xulWindow.gBrowser.moveTabTo(tab, pos++); + } + }); + }, + + async hideTabs(options) { + if (!("cookieStoreId" in options)) { + return new Error("hideTabs must be called with cookieStoreId option."); + } + + const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(options.cookieStoreId); + await identityState.remapTabsIfMissing(options.cookieStoreId); + const isKnownContainer = await identityState._isKnownContainer(userContextId); + if (!isKnownContainer) { + return null; + } + + const containersCounts = identityState.containersCounts(); + this.sendTelemetryPayload({ + "event": "hide-tabs", + "userContextId": userContextId, + "clickedContainerTabCount": identityState.containerTabCount(userContextId), + "shownContainersCount": containersCounts.shown, + "hiddenContainersCount": containersCounts.hidden, + "totalContainersCount": containersCounts.total + }); + + const containerState = await identityState.storeHidden(options.cookieStoreId); + await this._closeTabs(userContextId); + return containerState; + }, + + async showTabs(options) { + if (!("cookieStoreId" in options)) { + return Promise.reject("showTabs must be called with cookieStoreId argument."); + } + + const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(options.cookieStoreId); + await identityState.remapTabsIfMissing(options.cookieStoreId); + if (!identityState._isKnownContainer(userContextId)) { + return null; + } + + const containersCounts = identityState.containersCounts(); + this.sendTelemetryPayload({ + "event": "show-tabs", + "userContextId": userContextId, + "clickedContainerTabCount": await identityState.containerTabCount(options.cookieStoreId), + "shownContainersCount": containersCounts.shown, + "hiddenContainersCount": containersCounts.hidden, + "totalContainersCount": containersCounts.total + }); + + const promises = []; + + const containerState = await identityState.storageArea.get(options.cookieStoreId); + + for (let object of containerState.hiddenTabs) { // eslint-disable-line prefer-const + promises.push(this.openTab({ + userContextId: userContextId, + url: object.url, + nofocus: options.nofocus || false, + pinned: object.pinned, + })); + } + + containerState.hiddenTabs = []; + + await Promise.all(promises); + return await identityState.storageArea.set(options.cookieStoreId, containerState); + }, + + + sendTelemetryPayload(message = {}) { + if (!message.event) { + throw new Error("Missing event name for telemetry"); + } + message.method = "sendTelemetryPayload"; + //TODO decide where this goes + // browser.runtime.sendMessage(message); + }, + + cookieStoreId(userContextId) { + return `firefox-container-${userContextId}`; + }, + + _containerTabs(cookieStoreId) { + return browser.tabs.query({ + cookieStoreId + }).catch((e) => {throw e;}); + }, +}; + diff --git a/webextension/js/background/badge.js b/webextension/js/background/badge.js new file mode 100644 index 0000000..3a9abc7 --- /dev/null +++ b/webextension/js/background/badge.js @@ -0,0 +1,18 @@ +const MAJOR_VERSIONS = ["2.3.0", "2.4.0"]; +const badge = { + init() { + this.displayBrowserActionBadge(); + }, + async displayBrowserActionBadge() { + const extensionInfo = await backgroundLogic.getExtensionInfo(); + const storage = await browser.storage.local.get({browserActionBadgesClicked: []}); + + if (MAJOR_VERSIONS.indexOf(extensionInfo.version) > -1 && + storage.browserActionBadgesClicked.indexOf(extensionInfo.version) < 0) { + browser.browserAction.setBadgeBackgroundColor({color: "rgba(0,217,0,255)"}); + browser.browserAction.setBadgeText({text: "NEW"}); + } + } +}; + +badge.init(); diff --git a/webextension/js/background/identityState.js b/webextension/js/background/identityState.js new file mode 100644 index 0000000..2bc6205 --- /dev/null +++ b/webextension/js/background/identityState.js @@ -0,0 +1,159 @@ +const identityState = { + storageArea: { + area: browser.storage.local, + + getContainerStoreKey(cookieStoreId) { + const storagePrefix = "identitiesState@@_"; + return `${storagePrefix}${cookieStoreId}`; + }, + + async get(cookieStoreId) { + const storeKey = this.getContainerStoreKey(cookieStoreId); + const storageResponse = await this.area.get([storeKey]); + if (storageResponse && storeKey in storageResponse) { + return storageResponse[storeKey]; + } + return null; + }, + + set(cookieStoreId, data) { + const storeKey = this.getContainerStoreKey(cookieStoreId); + return this.area.set({ + [storeKey]: data + }); + }, + + remove(cookieStoreId) { + const storeKey = this.getContainerStoreKey(cookieStoreId); + return this.area.remove([storeKey]); + } + }, + + async _isKnownContainer(userContextId) { + const cookieStoreId = backgroundLogic.cookieStoreId(userContextId); + const state = await this.storageArea.get(cookieStoreId); + return !!state; + }, + + _createTabObject(tab) { + return Object.assign({}, tab); + }, + + async storeHidden(cookieStoreId) { + const containerState = await this.storageArea.get(cookieStoreId); + const tabsByContainer = await this._matchTabsByContainer(cookieStoreId); + tabsByContainer.forEach((tab) => { + const tabObject = this._createTabObject(tab); + // This tab is going to be closed. Let's mark this tabObject as + // non-active. + tabObject.active = false; + tabObject.hiddenState = true; + containerState.hiddenTabs.push(tabObject); + }); + + return this.storageArea.set(cookieStoreId, containerState); + }, + + async containersCounts() { + let containersCounts = { // eslint-disable-line prefer-const + "shown": 0, + "hidden": 0, + "total": 0 + }; + const containers = await browser.contextualIdentities.query({}); + for (const id in containers) { + const container = containers[id]; + const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(container.cookieStoreId); + await this.remapTabsIfMissing(container.cookieStoreId); + const containerState = await this.storageArea.get(container.cookieStoreId); + if (containerState.openTabs > 0) { + ++containersCounts.shown; + ++containersCounts.total; + continue; + } else if (containerState.hiddenTabs.length > 0) { + ++containersCounts.hidden; + ++containersCounts.total; + continue; + } + } + return containersCounts; + }, + + async containerTabCount(cookieStoreId) { + // Returns the total of open and hidden tabs with this userContextId + let containerTabsCount = 0; + await identityState.remapTabsIfMissing(cookieStoreId); + const containerState = await this.storageArea.get(cookieStoreId); + containerTabsCount += containerState.openTabs; + containerTabsCount += containerState.hiddenTabs.length; + return containerTabsCount; + }, + + async totalContainerTabsCount() { + // Returns the number of total open tabs across ALL containers + let totalContainerTabsCount = 0; + const containers = await browser.contextualIdentities.query({}); + for (const id in containers) { + const container = containers[id]; + const cookieStoreId = container.cookieStoreId; + await identityState.remapTabsIfMissing(cookieStoreId); + totalContainerTabsCount += await this.storageArea.get(cookieStoreId).openTabs; + } + return totalContainerTabsCount; + }, + + async totalNonContainerTabsCount() { + // Returns the number of open tabs NOT IN a container + let totalNonContainerTabsCount = 0; + const tabs = await browser.tabs.query({}); + for (const tab of tabs) { + const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(tab.cookieStoreId); + if (userContextId === 0) { + ++totalNonContainerTabsCount; + } + } + return totalNonContainerTabsCount; + }, + + async remapTabsIfMissing(cookieStoreId) { + // We already know this cookieStoreId. + const containerState = await this.storageArea.get(cookieStoreId) || this._createIdentityState(); +//REINSTATE THIS TODO, currently buggy +/* + if (containerState !== null) { + return; + } +*/ +const hiddenTabs = containerState.hiddenTabs; +//END REINSTATE + + await this.storageArea.set(cookieStoreId, containerState); + await this.remapTabsFromUserContextId(cookieStoreId); + }, + + _matchTabsByContainer(cookieStoreId) { + return browser.tabs.query({cookieStoreId}); + }, + + async remapTabsFromUserContextId(cookieStoreId) { + const tabsByContainer = await this._matchTabsByContainer(cookieStoreId); + const containerState = await this.storageArea.get(cookieStoreId); + containerState.openTabs = tabsByContainer.length; + await this.storageArea.set(cookieStoreId, containerState); + }, +/*TODO check if used + remapTab(tab) { + const userContextId = this._getUserContextIdFromTab(tab); + if (userContextId) { + this.remapTabsFromUserContextId(userContextId); + } + }, +*/ + + _createIdentityState() { + return { + hiddenTabs: [], + openTabs: 0 + }; + }, +}; diff --git a/webextension/js/background/index.html b/webextension/js/background/index.html new file mode 100644 index 0000000..24a0eba --- /dev/null +++ b/webextension/js/background/index.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/webextension/js/background/init.js b/webextension/js/background/init.js new file mode 100644 index 0000000..3875580 --- /dev/null +++ b/webextension/js/background/init.js @@ -0,0 +1,27 @@ +browser.runtime.sendMessage({ + method: "getPreference", + pref: "browser.privatebrowsing.autostart" +}).then(pbAutoStart => { + + // We don't want to disable the addon if we are in auto private-browsing. + if (!pbAutoStart) { + browser.tabs.onCreated.addListener(tab => { + if (tab.incognito) { + disableAddon(tab.id); + } + }); + + browser.tabs.query({}).then(tabs => { + for (let tab of tabs) { // eslint-disable-line prefer-const + if (tab.incognito) { + disableAddon(tab.id); + } + } + }).catch(() => {}); + } +}).catch(() => {}); + +function disableAddon(tabId) { + browser.browserAction.disable(tabId); + browser.browserAction.setTitle({ tabId, title: "Containers disabled in Private Browsing Mode" }); +} diff --git a/webextension/js/background/messageHandler.js b/webextension/js/background/messageHandler.js new file mode 100644 index 0000000..5a73d2b --- /dev/null +++ b/webextension/js/background/messageHandler.js @@ -0,0 +1,177 @@ +const messageHandler = { + // After the timer completes we assume it's a tab the user meant to keep open + // We use this to catch redirected tabs that have just opened + // If this were in platform we would change how the tab opens based on "new tab" link navigations such as ctrl+click + LAST_CREATED_TAB_TIMER: 2000, + + init() { + // Handles messages from webextension code + browser.runtime.onMessage.addListener((m) => { + let response; + + switch (m.method) { + case "deleteContainer": + response = backgroundLogic.deleteContainer(m.message.userContextId); + break; + case "createOrUpdateContainer": + response = backgroundLogic.createOrUpdateContainer(m.message); + break; + case "openTab": + // Same as open-tab for index.js + response = backgroundLogic.openTab(m.message); + break; + case "neverAsk": + assignManager._neverAsk(m); + break; + case "getAssignment": + response = browser.tabs.get(m.tabId).then((tab) => { + return assignManager._getAssignment(tab); + }); + break; + case "getAssignmentObjectByContainer": + response = assignManager._getByContainer(m.message.userContextId); + break; + case "setOrRemoveAssignment": + // m.tabId is used for where to place the in content message + // m.url is the assignment to be removed/added + response = browser.tabs.get(m.tabId).then((tab) => { + return assignManager._setOrRemoveAssignment(tab.id, m.url, m.userContextId, m.value); + }); + break; + case "sendTelemetryPayload": + // TODO + break; + case "sortTabs": + backgroundLogic.sortTabs(); + break; + case "showTabs": + backgroundLogic.showTabs({cookieStoreId: m.cookieStoreId}); + break; + case "hideTabs": + backgroundLogic.hideTabs({cookieStoreId: m.cookieStoreId}); + break; + case "checkIncompatibleAddons": + // TODO + break; + case "getShieldStudyVariation": + // TODO + break; + case "moveTabsToWindow": + response = backgroundLogic.moveTabsToWindow({ + cookieStoreId: m.cookieStoreId + }); + break; + case "getTabs": + response = backgroundLogic.getTabs({ + cookieStoreId: m.cookieStoreId + }); + break; + case "queryIdentitiesState": + response = backgroundLogic.queryIdentitiesState(); + break; + case "exemptContainerAssignment": + response = assignManager._exemptTab(m); + break; + } + return response; + }); + + // Handles messages from sdk code + const port = browser.runtime.connect(); + port.onMessage.addListener(m => { + switch (m.type) { + case "lightweight-theme-changed": + themeManager.update(m.message); + break; + case "open-tab": + backgroundLogic.openTab(m.message); + break; + default: + throw new Error(`Unhandled message type: ${m.message}`); + } + }); + + browser.tabs.onCreated.addListener((tab) => { + // This works at capturing the tabs as they are created + // However we need onFocusChanged and onActivated to capture the initial tab + if (tab.id === -1) { + return {}; + } + tabPageCounter.initTabCounter(tab); + }); + + browser.tabs.onRemoved.addListener((tabId) => { + if (tabId === -1) { + return {}; + } + tabPageCounter.sendTabCountAndDelete(tabId); + }); + + browser.tabs.onActivated.addListener((info) => { + assignManager.removeContextMenu(); + browser.tabs.get(info.tabId).then((tab) => { + tabPageCounter.initTabCounter(tab); + assignManager.calculateContextMenu(tab); + }).catch((e) => { + throw e; + }); + }); + + browser.windows.onFocusChanged.addListener((windowId) => { + assignManager.removeContextMenu(); + // browserAction loses background color in new windows ... + // https://bugzil.la/1314674 + // https://github.com/mozilla/testpilot-containers/issues/608 + // ... so re-call displayBrowserActionBadge on window changes + badge.displayBrowserActionBadge(); + browser.tabs.query({active: true, windowId}).then((tabs) => { + if (tabs && tabs[0]) { + tabPageCounter.initTabCounter(tabs[0]); + assignManager.calculateContextMenu(tabs[0]); + } + }).catch((e) => { + throw e; + }); + }); + + browser.idle.onStateChanged.addListener((newState) => { + browser.tabs.query({}).then(tabs => { + for (let tab of tabs) { // eslint-disable-line prefer-const + if (newState === "idle") { + tabPageCounter.sendTabCountAndDelete(tab.id, "user-went-idle"); + } else if (newState === "active" && tab.active) { + tabPageCounter.initTabCounter(tab); + } + } + }).catch(e => { + throw e; + }); + }); + + browser.webRequest.onCompleted.addListener((details) => { + if (details.frameId !== 0 || details.tabId === -1) { + return {}; + } + assignManager.removeContextMenu(); + + browser.tabs.get(details.tabId).then((tab) => { + tabPageCounter.incrementTabCount(tab); + assignManager.calculateContextMenu(tab); + }).catch((e) => { + throw e; + }); + }, {urls: [""], types: ["main_frame"]}); + + // lets remember the last tab created so we can close it if it looks like a redirect + browser.tabs.onCreated.addListener((details) => { + this.lastCreatedTab = details; + setTimeout(() => { + this.lastCreatedTab = null; + }, this.LAST_CREATED_TAB_TIMER); + }); + + } +}; + +// Lets do this last as theme manager did a check before connecting before +messageHandler.init(); diff --git a/webextension/js/background/tabPageCounter.js b/webextension/js/background/tabPageCounter.js new file mode 100644 index 0000000..f639f5e --- /dev/null +++ b/webextension/js/background/tabPageCounter.js @@ -0,0 +1,64 @@ +const tabPageCounter = { + counters: {}, + + initTabCounter(tab) { + if (tab.id in this.counters) { + if (!("activity" in this.counters[tab.id])) { + this.counters[tab.id].activity = { + "cookieStoreId": tab.cookieStoreId, + "pageRequests": 0 + }; + } + if (!("tab" in this.counters[tab.id])) { + this.counters[tab.id].tab = { + "cookieStoreId": tab.cookieStoreId, + "pageRequests": 0 + }; + } + } else { + this.counters[tab.id] = {}; + this.counters[tab.id].tab = { + "cookieStoreId": tab.cookieStoreId, + "pageRequests": 0 + }; + this.counters[tab.id].activity = { + "cookieStoreId": tab.cookieStoreId, + "pageRequests": 0 + }; + } + }, + + sendTabCountAndDelete(tabId, why = "user-closed-tab") { + if (!(this.counters[tabId])) { + return; + } + if (why === "user-closed-tab" && this.counters[tabId].tab) { + backgroundLogic.sendTelemetryPayload({ + event: "page-requests-completed-per-tab", + userContextId: this.counters[tabId].tab.cookieStoreId, + pageRequestCount: this.counters[tabId].tab.pageRequests + }); + // When we send the ping because the user closed the tab, + // delete both the 'tab' and 'activity' counters + delete this.counters[tabId]; + } else if (why === "user-went-idle" && this.counters[tabId].activity) { + backgroundLogic.sendTelemetryPayload({ + event: "page-requests-completed-per-activity", + userContextId: this.counters[tabId].activity.cookieStoreId, + pageRequestCount: this.counters[tabId].activity.pageRequests + }); + // When we send the ping because the user went idle, + // only reset the 'activity' counter + this.counters[tabId].activity = { + "cookieStoreId": this.counters[tabId].tab.cookieStoreId, + "pageRequests": 0 + }; + } + }, + + incrementTabCount(tab) { + this.initTabCounter(tab); + this.counters[tab.id].tab.pageRequests++; + this.counters[tab.id].activity.pageRequests++; + } +}; diff --git a/webextension/js/background/themeManager.js b/webextension/js/background/themeManager.js new file mode 100644 index 0000000..b77ade6 --- /dev/null +++ b/webextension/js/background/themeManager.js @@ -0,0 +1,51 @@ +const THEME_BUILD_DATE = 20170630; +const themeManager = { + existingTheme: null, + disabled: false, + async init() { + const browserInfo = await browser.runtime.getBrowserInfo(); + if (Number(browserInfo.buildID.substring(0, 8)) >= THEME_BUILD_DATE) { + this.disabled = true; + } else { + this.check(); + } + }, + setPopupIcon(theme) { + if (this.disabled) { + return; + } + let icons = { + 16: "img/container-site-d-24.png", + 32: "img/container-site-d-48.png" + }; + if (theme === "firefox-compact-dark@mozilla.org") { + icons = { + 16: "img/container-site-w-24.png", + 32: "img/container-site-w-48.png" + }; + } + browser.browserAction.setIcon({ + path: icons + }); + }, + check() { + if (this.disabled) { + return; + } + browser.runtime.sendMessage({ + method: "getTheme" + }).then((theme) => { + this.update(theme); + }).catch(() => { + throw new Error("Unable to get theme"); + }); + }, + update(theme) { + if (this.existingTheme !== theme) { + this.setPopupIcon(theme); + this.existingTheme = theme; + } + } +}; + +themeManager.init(); diff --git a/webextension/js/popup.js b/webextension/js/popup.js index c8f77bc..bcf133c 100644 --- a/webextension/js/popup.js +++ b/webextension/js/popup.js @@ -183,22 +183,21 @@ const Logic = { return false; }, - refreshIdentities() { - return Promise.all([ + async refreshIdentities() { + const [identities, state] = await Promise.all([ browser.contextualIdentities.query({}), browser.runtime.sendMessage({ method: "queryIdentitiesState" }) - ]).then(([identities, state]) => { - this._identities = identities.map((identity) => { - const stateObject = state[Logic.userContextId(identity.cookieStoreId)]; - if (stateObject) { - identity.hasOpenTabs = stateObject.hasOpenTabs; - identity.hasHiddenTabs = stateObject.hasHiddenTabs; - } - return identity; - }); - }).catch((e) => {throw e;}); + ]); + this._identities = identities.map((identity) => { + const stateObject = state[identity.cookieStoreId]; + if (stateObject) { + identity.hasOpenTabs = stateObject.hasOpenTabs; + identity.hasHiddenTabs = stateObject.hasHiddenTabs; + } + return identity; + }); }, getPanelSelector(panel) { @@ -313,12 +312,11 @@ const Logic = { }); }, - getShieldStudyVariation() { - return browser.runtime.sendMessage({ + async getShieldStudyVariation() { + const variation = await browser.runtime.sendMessage({ method: "getShieldStudyVariation" - }).then(variation => { - this._onboardingVariation = variation; }); + this._onboardingVariation = variation; }, generateIdentityName() { @@ -477,14 +475,15 @@ Logic.registerPanel(P_CONTAINERS_LIST, { Logic.showPanel(P_CONTAINERS_EDIT); }); - Logic.addEnterHandler(document.querySelector("#sort-containers-link"), () => { - browser.runtime.sendMessage({ - method: "sortTabs" - }).then(() => { + Logic.addEnterHandler(document.querySelector("#sort-containers-link"), async function () { + try { + await browser.runtime.sendMessage({ + method: "sortTabs" + }); window.close(); - }).catch(() => { + } catch (e) { window.close(); - }); + } }); document.addEventListener("keydown", (e) => { @@ -492,13 +491,15 @@ Logic.registerPanel(P_CONTAINERS_LIST, { function next() { const nextElement = element.nextElementSibling; if (nextElement) { - nextElement.querySelector("td[tabindex=0]").focus(); +//TOFIX, dunno when this broke + nextElement.querySelector(`td[tabindex="0"]`).focus(); } } function previous() { const previousElement = element.previousElementSibling; if (previousElement) { - previousElement.querySelector("td[tabindex=0]").focus(); +//TOFIX, dunno when this broke + previousElement.querySelector(`td[tabindex="0"]`).focus(); } } switch (e.keyCode) { @@ -608,21 +609,22 @@ Logic.registerPanel(P_CONTAINERS_LIST, { tr.appendChild(manage); } - Logic.addEnterHandler(tr, e => { + Logic.addEnterHandler(tr, async function (e) { if (e.target.matches(".open-newtab") || e.target.parentNode.matches(".open-newtab") || e.type === "keydown") { - browser.runtime.sendMessage({ - method: "openTab", - message: { - userContextId: Logic.userContextId(identity.cookieStoreId), - source: "pop-up" - } - }).then(() => { + try { + await browser.runtime.sendMessage({ + method: "openTab", + message: { + userContextId: Logic.userContextId(identity.cookieStoreId), + source: "pop-up" + } + }); window.close(); - }).catch(() => { + } catch (e) { window.close(); - }); + } } else if (hasTabs) { Logic.showPanel(P_CONTAINER_INFO, identity); } @@ -657,27 +659,29 @@ Logic.registerPanel(P_CONTAINER_INFO, { panelSelector: "#container-info-panel", // This method is called when the object is registered. - initialize() { + async initialize() { Logic.addEnterHandler(document.querySelector("#close-container-info-panel"), () => { Logic.showPreviousPanel(); }); - Logic.addEnterHandler(document.querySelector("#container-info-hideorshow"), () => { + Logic.addEnterHandler(document.querySelector("#container-info-hideorshow"), async function () { const identity = Logic.currentIdentity(); - browser.runtime.sendMessage({ - method: identity.hasHiddenTabs ? "showTabs" : "hideTabs", - cookieStoreId: Logic.currentCookieStoreId() - }).then(() => { + try { + browser.runtime.sendMessage({ + method: identity.hasHiddenTabs ? "showTabs" : "hideTabs", + cookieStoreId: Logic.currentCookieStoreId() + }); window.close(); - }).catch(() => { + } catch (e) { window.close(); - }); + } }); // Check if the user has incompatible add-ons installed - browser.runtime.sendMessage({ - method: "checkIncompatibleAddons" - }).then(incompatible => { + try { + const incompatible = await browser.runtime.sendMessage({ + method: "checkIncompatibleAddons" + }); const moveTabsEl = document.querySelector("#container-info-movetabs"); if (incompatible) { const fragment = document.createDocumentFragment(); @@ -693,22 +697,21 @@ Logic.registerPanel(P_CONTAINER_INFO, { moveTabsEl.parentNode.insertBefore(fragment, moveTabsEl.nextSibling); } else { - Logic.addEnterHandler(moveTabsEl, () => { - browser.runtime.sendMessage({ + Logic.addEnterHandler(moveTabsEl, async function () { + await browser.runtime.sendMessage({ method: "moveTabsToWindow", - userContextId: Logic.userContextId(Logic.currentIdentity().cookieStoreId), - }).then(() => { - window.close(); - }).catch((e) => { throw e; }); + cookieStoreId: Logic.currentIdentity().cookieStoreId, + }); + window.close(); }); } - }).catch(() => { + } catch (e) { throw new Error("Could not check for incompatible add-ons."); - }); + } }, // This method is called when the panel is shown. - prepare() { + async prepare() { const identity = Logic.currentIdentity(); // Populating the panel: name and icon @@ -736,10 +739,11 @@ Logic.registerPanel(P_CONTAINER_INFO, { } // Let's retrieve the list of tabs. - return browser.runtime.sendMessage({ + const tabs = await browser.runtime.sendMessage({ method: "getTabs", - userContextId: Logic.currentUserContextId(), - }).then(this.buildInfoTable); + cookieStoreId: Logic.currentIdentity().cookieStoreId + }); + return this.buildInfoTable(tabs); }, buildInfoTable(tabs) { @@ -752,15 +756,14 @@ Logic.registerPanel(P_CONTAINER_INFO, { tr.innerHTML = escaped` ${tab.title}`; - tr.querySelector("td").appendChild(Utils.createFavIconElement(tab.favicon)); + tr.querySelector("td").appendChild(Utils.createFavIconElement(tab.favIconUrl)); // On click, we activate this tab. But only if this tab is active. - if (tab.active) { + if (!tab.hiddenState) { tr.classList.add("clickable"); - Logic.addEnterHandler(tr, () => { - browser.tabs.update(tab.id, {selected: true}).then(() => { - window.close(); - }); + Logic.addEnterHandler(tr, async function () { + await browser.tabs.update(tab.id, {active: true}); + window.close(); }); } } @@ -871,25 +874,25 @@ Logic.registerPanel(P_CONTAINER_EDIT, { }, - _submitForm() { + async _submitForm() { const formValues = new FormData(this._editForm); - return browser.runtime.sendMessage({ - method: "createOrUpdateContainer", - message: { - userContextId: formValues.get("container-id") || NEW_CONTAINER_ID, - params: { - name: document.getElementById("edit-container-panel-name-input").value || Logic.generateIdentityName(), - icon: formValues.get("container-icon") || DEFAULT_ICON, - color: formValues.get("container-color") || DEFAULT_COLOR, + try { + await browser.runtime.sendMessage({ + method: "createOrUpdateContainer", + message: { + userContextId: formValues.get("container-id") || NEW_CONTAINER_ID, + params: { + name: document.getElementById("edit-container-panel-name-input").value || Logic.generateIdentityName(), + icon: formValues.get("container-icon") || DEFAULT_ICON, + color: formValues.get("container-color") || DEFAULT_COLOR, + } } - } - }).then(() => { - return Logic.refreshIdentities(); - }).then(() => { + }); + await Logic.refreshIdentities(); Logic.showPreviousPanel(); - }).catch(() => { + } catch (e) { Logic.showPanel(P_CONTAINERS_LIST); - }); + } }, showAssignedContainers(assignments) { @@ -919,17 +922,15 @@ Logic.registerPanel(P_CONTAINER_EDIT, { src="/img/container-delete.svg" />`; const deleteButton = trElement.querySelector(".delete-assignment"); - Logic.addEnterHandler(deleteButton, () => { + const that = this; + Logic.addEnterHandler(deleteButton, async function () { const userContextId = Logic.currentUserContextId(); // Lets show the message to the current tab // TODO remove then when firefox supports arrow fn async - Logic.currentTab().then((currentTab) => { - Logic.setOrRemoveAssignment(currentTab.id, assumedUrl, userContextId, true); - delete assignments[siteKey]; - this.showAssignedContainers(assignments); - }).catch((e) => { - throw e; - }); + const currentTab = await Logic.currentTab(); + Logic.setOrRemoveAssignment(currentTab.id, assumedUrl, userContextId, true); + delete assignments[siteKey]; + that.showAssignedContainers(assignments); }); trElement.classList.add("container-info-tab-row", "clickable"); tableElement.appendChild(trElement); @@ -1002,19 +1003,19 @@ Logic.registerPanel(P_CONTAINER_DELETE, { Logic.showPreviousPanel(); }); - Logic.addEnterHandler(document.querySelector("#delete-container-ok-link"), () => { + Logic.addEnterHandler(document.querySelector("#delete-container-ok-link"), async function () { /* This promise wont resolve if the last tab was removed from the window. as the message async callback stops listening, this isn't an issue for us however it might be in future if you want to do anything post delete do it in the background script. Browser console currently warns about not listening also. */ - Logic.removeIdentity(Logic.userContextId(Logic.currentIdentity().cookieStoreId)).then(() => { - return Logic.refreshIdentities(); - }).then(() => { + try { + await Logic.removeIdentity(Logic.userContextId(Logic.currentIdentity().cookieStoreId)); + await Logic.refreshIdentities(); Logic.showPreviousPanel(); - }).catch(() => { + } catch(e) { Logic.showPanel(P_CONTAINERS_LIST); - }); + } }); }, diff --git a/webextension/manifest.json b/webextension/manifest.json index 9d0d04c..474d8b3 100644 --- a/webextension/manifest.json +++ b/webextension/manifest.json @@ -85,7 +85,7 @@ }, "background": { - "scripts": ["background.js"] + "page": "js/background/index.html" }, "content_scripts": [ From 38c098edb6e19052f183e034a8c67d8a1dfbfd4d Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 3 Aug 2017 11:26:00 +0100 Subject: [PATCH 04/10] Fix on update icons being reset to central defaults. Fixes #703 --- index.js | 6 +++--- package.json | 2 +- webextension/manifest.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/index.js b/index.js index afdfa61..fc45f12 100644 --- a/index.js +++ b/index.js @@ -1028,6 +1028,8 @@ const ContainerService = { // Let's forget all the previous closed tabs. this._forgetIdentity(); + this._resetContainerToCentralIcons(); + const preInstalledIdentities = data.preInstalledIdentities; ContextualIdentityProxy.getIdentities().forEach(identity => { if (!preInstalledIdentities.includes(identity.userContextId)) { @@ -1326,8 +1328,6 @@ ContainerWindow.prototype = { this._shutdownFileMenu(); this._shutdownAllTabsMenu(); this._shutdownContextMenu(); - - this._shutdownContainers(); }, _shutDownPlusButtonMenuElement(buttonElement) { @@ -1390,7 +1390,7 @@ ContainerWindow.prototype = { return true; }, - _shutdownContainers() { + _resetContainerToCentralIcons() { ContextualIdentityProxy.getIdentities().forEach(identity => { if (IDENTITY_ICONS_STANDARD.indexOf(identity.icon) !== -1 && IDENTITY_COLORS_STANDARD.indexOf(identity.color) !== -1) { diff --git a/package.json b/package.json index 5ea8d37..d2725bc 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "testpilot-containers", "title": "Containers Experiment", "description": "Containers works by isolating cookie jars using separate origin-attributes defined visually by colored ‘Container Tabs’. This add-on is a modified version of the containers feature for Firefox Test Pilot.", - "version": "2.4.1", + "version": "2.5.0", "author": "Andrea Marchesini, Luke Crouch and Jonathan Kingston", "bugs": { "url": "https://github.com/mozilla/testpilot-containers/issues" diff --git a/webextension/manifest.json b/webextension/manifest.json index 8f7dec0..2934bf1 100644 --- a/webextension/manifest.json +++ b/webextension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Containers Experiment", - "version": "2.4.1", + "version": "2.5.0", "description": "Containers works by isolating cookie jars using separate origin-attributes defined visually by colored ‘Container Tabs’. This add-on is a modified version of the containers feature for Firefox Test Pilot.", "icons": { From fe77c891cdf4ed565378d485385d085fb3277c2e Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Mon, 17 Jul 2017 16:31:51 +0100 Subject: [PATCH 05/10] Adding back in legacy code without message passing code. --- index.js | 930 ++++++++++++++- index.js.bk | 1060 ------------------ package.json | 2 +- webextension/js/background/identityState.js | 17 - webextension/js/background/index.html | 3 + webextension/js/background/tabPageCounter.js | 1 + webextension/js/popup.js | 12 +- webextension/manifest.json | 2 +- 8 files changed, 941 insertions(+), 1086 deletions(-) delete mode 100644 index.js.bk diff --git a/index.js b/index.js index 84fb0ee..92b4228 100644 --- a/index.js +++ b/index.js @@ -1,2 +1,930 @@ +/* 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/. */ + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +const IDENTITY_COLORS = [ + { name: "blue", color: "#00a7e0" }, + { name: "turquoise", color: "#01bdad" }, + { name: "green", color: "#7dc14c" }, + { name: "yellow", color: "#ffcb00" }, + { name: "orange", color: "#f89c24" }, + { name: "red", color: "#d92215" }, + { name: "pink", color: "#ee5195" }, + { name: "purple", color: "#7a2f7a" }, +]; + +const IDENTITY_ICONS = [ + { name: "fingerprint", image: "chrome://browser/skin/usercontext/personal.svg" }, + { name: "briefcase", image: "chrome://browser/skin/usercontext/work.svg" }, + { name: "dollar", image: "chrome://browser/skin/usercontext/banking.svg" }, + { name: "cart", image: "chrome://browser/skin/usercontext/shopping.svg" }, + // All of these do not exist in gecko + { name: "gift", image: "gift" }, + { name: "vacation", image: "vacation" }, + { name: "food", image: "food" }, + { name: "fruit", image: "fruit" }, + { name: "pet", image: "pet" }, + { name: "tree", image: "tree" }, + { name: "chill", image: "chill" }, + { name: "circle", image: "circle" }, +]; + +const IDENTITY_COLORS_STANDARD = [ + "blue", "orange", "green", "pink", +]; + +const IDENTITY_ICONS_STANDARD = [ + "fingerprint", "briefcase", "dollar", "cart", +]; + +const PREFS = [ + [ "privacy.userContext.enabled", true ], + [ "privacy.userContext.longPressBehavior", 2 ], + [ "privacy.userContext.ui.enabled", false ], + [ "privacy.usercontext.about_newtab_segregation.enabled", true ], +]; + +const { attachTo, detachFrom } = require("sdk/content/mod"); +const { Cu } = require("chrome"); +const { ContextualIdentityService } = require("resource://gre/modules/ContextualIdentityService.jsm"); +const Metrics = require("./testpilot-metrics"); +const { modelFor } = require("sdk/model/core"); +const prefService = require("sdk/preferences/service"); +const self = require("sdk/self"); +const { Services } = require("resource://gre/modules/Services.jsm"); +const ss = require("sdk/simple-storage"); +const { study } = require("./study"); +const { Style } = require("sdk/stylesheet/style"); +const tabs = require("sdk/tabs"); +const uuid = require("sdk/util/uuid"); +const { viewFor } = require("sdk/view/core"); const webExtension = require("sdk/webextension"); -webExtension.startup(); +const windows = require("sdk/windows"); +const windowUtils = require("sdk/window/utils"); + +Cu.import("resource:///modules/CustomizableUI.jsm"); +Cu.import("resource:///modules/CustomizableWidgets.jsm"); +Cu.import("resource:///modules/sessionstore/SessionStore.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + + +// ContextualIdentityProxy +const ContextualIdentityProxy = { + getIdentities() { + let response; + if ("getPublicIdentities" in ContextualIdentityService) { + response = ContextualIdentityService.getPublicIdentities(); + } else { + response = ContextualIdentityService.getIdentities(); + } + + return response.map((identity) => { + return this._convert(identity); + }); + }, + + getIdentityFromId(userContextId) { + let response; + if ("getPublicIdentityFromId" in ContextualIdentityService) { + response = ContextualIdentityService.getPublicIdentityFromId(userContextId); + } else { + response = ContextualIdentityService.getIdentityFromId(userContextId); + } + if (response) { + return this._convert(response); + } + return response; + }, + + _convert(identity) { + return { + name: ContextualIdentityService.getUserContextLabel(identity.userContextId), + icon: identity.icon, + color: identity.color, + userContextId: identity.userContextId, + }; + }, +}; + +// ---------------------------------------------------------------------------- +// ContainerService + +const ContainerService = { + _windowMap: new Map(), + _containerWasEnabled: false, + _onBackgroundConnectCallback: null, + + async init(installation, reason) { + // If we are just been installed, we must store some information for the + // uninstallation. This object contains also a version number, in case we + // need to implement a migration in the future. + // In 1.1.1 and less we deleted savedConfiguration on upgrade so we need to rebuild + if (!("savedConfiguration" in ss.storage) || + !("prefs" in ss.storage.savedConfiguration) || + (installation && reason !== "upgrade")) { + let preInstalledIdentities = []; // eslint-disable-line prefer-const + ContextualIdentityProxy.getIdentities().forEach(identity => { + preInstalledIdentities.push(identity.userContextId); + }); + + const object = { + version: 1, + prefs: {}, + metricsUUID: uuid.uuid().toString(), + preInstalledIdentities: preInstalledIdentities + }; + + PREFS.forEach(pref => { + object.prefs[pref[0]] = prefService.get(pref[0]); + }); + + ss.storage.savedConfiguration = object; + + if (prefService.get("privacy.userContext.enabled") !== true) { + // Maybe rename the Banking container. + const identity = ContextualIdentityProxy.getIdentityFromId(3); + if (identity && identity.l10nID === "userContextBanking.label") { + ContextualIdentityService.update(identity.userContextId, + "Finance", + identity.icon, + identity.color); + } + + // Let's create the default containers in case there are none. + if (ss.storage.savedConfiguration.preInstalledIdentities.length === 0) { + // Note: we have to create them in this way because there is no way to + // reuse the same ID and the localized strings. + ContextualIdentityService.create("Personal", "fingerprint", "blue"); + ContextualIdentityService.create("Work", "briefcase", "orange"); + ContextualIdentityService.create("Finance", "dollar", "green"); + ContextualIdentityService.create("Shopping", "cart", "pink"); + } + } + } + + // TOCHECK should this run on all code + ContextualIdentityProxy.getIdentities().forEach(identity => { + const newIcon = this._fromIconToName(identity.icon); + const newColor = this._fromColorToName(identity.color); + if (newIcon !== identity.icon || newColor !== identity.color) { + ContextualIdentityService.update(identity.userContextId, + ContextualIdentityService.getUserContextLabel(identity.userContextId), + newIcon, + newColor); + } + }); + + // Let's see if containers were enabled before this addon. + this._containerWasEnabled = + ss.storage.savedConfiguration.prefs["privacy.userContext.enabled"]; + + // Enabling preferences + + PREFS.forEach((pref) => { + prefService.set(pref[0], pref[1]); + }); + + this._metricsUUID = ss.storage.savedConfiguration.metricsUUID; + + // Disabling the customizable container panel. + CustomizableUI.destroyWidget("containers-panelmenu"); + + tabs.on("open", tab => { + this._restyleTab(tab); + }); + + tabs.on("activate", tab => { + this._restyleActiveTab(tab).catch(() => {}); + this._configureActiveWindows(); + }); + + // Modify CSS and other stuff for each window. + + this._configureWindows().catch(() => {}); + + windows.browserWindows.on("open", window => { + this._configureWindow(viewFor(window)).catch(() => {}); + }); + + windows.browserWindows.on("close", window => { + this.closeWindow(viewFor(window)); + }); + + // WebExtension startup + + try { + const api = await webExtension.startup(); + this.registerBackgroundConnection(api); + } catch (e) { + throw new Error("WebExtension startup failed. Unable to continue."); + } + + this._sendEvent = new Metrics({ + type: "sdk", + id: self.id, + version: self.version + }).sendEvent; + + // Begin-Of-Hack + ContextualIdentityService.workaroundForCookieManager = function(method, userContextId) { + let identity = method.call(ContextualIdentityService, userContextId); + if (!identity && userContextId) { + identity = { + userContextId, + icon: "", + color: "", + name: "Pending to be deleted", + public: true, + }; + } + + return identity; + }; + + if (!this._oldGetIdentityFromId) { + this._oldGetIdentityFromId = ContextualIdentityService.getIdentityFromId; + } + ContextualIdentityService.getIdentityFromId = function(userContextId) { + return this.workaroundForCookieManager(ContainerService._oldGetIdentityFromId, userContextId); + }; + + if ("getPublicIdentityFromId" in ContextualIdentityService) { + if (!this._oldGetPublicIdentityFromId) { + this._oldGetPublicIdentityFromId = ContextualIdentityService.getPublicIdentityFromId; + } + ContextualIdentityService.getPublicIdentityFromId = function(userContextId) { + return this.workaroundForCookieManager(ContainerService._oldGetPublicIdentityFromId, userContextId); + }; + } + // End-Of-Hack + + if (self.id === "@shield-study-containers") { + study.startup(reason); + this.shieldStudyVariation = study.variation; + } + }, + + registerBackgroundConnection(api) { + // This is only used for theme notifications and new tab + api.browser.runtime.onConnect.addListener((port) => { + this._onBackgroundConnectCallback = (message, topic) => { + port.postMessage({ + type: topic, + message + }); + }; + }); + }, + + triggerBackgroundCallback(message, topic) { + if (this._onBackgroundConnectCallback) { + this._onBackgroundConnectCallback(message, topic); + } + }, + + // 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. + + // Helper methods for converting colors to names and names to colors. + + _fromNameToColor(name) { + return this._fromNameOrColor(name, "color"); + }, + + _fromColorToName(color) { + return this._fromNameOrColor(color, "name"); + }, + + _fromNameOrColor(what, attribute) { + for (let color of IDENTITY_COLORS) { // eslint-disable-line prefer-const + if (what === color.color || what === color.name) { + return color[attribute]; + } + } + return ""; + }, + + // Helper methods for converting icons to names and names to icons. + + _fromIconToName(icon) { + return this._fromNameOrIcon(icon, "name", "circle"); + }, + + _fromNameOrIcon(what, attribute, defaultValue) { + for (let icon of IDENTITY_ICONS) { // eslint-disable-line prefer-const + if (what === icon.image || what === icon.name) { + return icon[attribute]; + } + } + return defaultValue; + }, + + // Tab Helpers + + _getUserContextIdFromTab(tab) { + return parseInt(viewFor(tab).getAttribute("usercontextid") || 0, 10); + }, + + _matchTabsByContainer(userContextId) { + const matchedTabs = []; + for (const tab of tabs) { + if (userContextId === this._getUserContextIdFromTab(tab)) { + matchedTabs.push(tab); + } + } + return matchedTabs; + }, + + async _closeTabs(tabsToClose) { + // We create a new tab only if the current operation closes all the + // existing ones. + if (tabs.length === tabsToClose.length) { + await this.openTab({}); + } + + for (const tab of tabsToClose) { + // after .close() window is null. Let's take it now. + const window = viewFor(tab.window); + + tab.close(); + + // forget about this tab. 0 is the index of the forgotten tab and 0 + // means the last one. + try { + SessionStore.forgetClosedTab(window, 0); + } catch (e) {} // eslint-disable-line no-empty + } + }, + + _recentBrowserWindow() { + const browserWin = windowUtils.getMostRecentBrowserWindow(); + + // This should not really happen. + if (!browserWin || !browserWin.gBrowser) { + return Promise.resolve(null); + } + + return Promise.resolve(browserWin); + }, + + // Tabs management + + openTab(args) { + return this.triggerBackgroundCallback(args, "open-tab"); + }, + + // Identities management + + queryIdentities() { + return new Promise(resolve => { + const identities = ContextualIdentityProxy.getIdentities(); + resolve(identities); + }); + }, + + // Styling the window + + _configureWindows() { + const promises = []; + for (let window of windows.browserWindows) { // eslint-disable-line prefer-const + promises.push(this._configureWindow(viewFor(window))); + } + return Promise.all(promises); + }, + + _configureWindow(window) { + return this._getOrCreateContainerWindow(window).configure(); + }, + + _configureActiveWindows() { + const promises = []; + for (let window of windows.browserWindows) { // eslint-disable-line prefer-const + promises.push(this._configureActiveWindow(viewFor(window))); + } + return Promise.all(promises); + }, + + _configureActiveWindow(window) { + return this._getOrCreateContainerWindow(window).configureActive(); + }, + + closeWindow(window) { + this._windowMap.delete(window); + }, + + _getOrCreateContainerWindow(window) { + if (!(this._windowMap.has(window))) { + this._windowMap.set(window, new ContainerWindow(window)); + } + + return this._windowMap.get(window); + }, + + refreshNeeded() { + return this._configureWindows(); + }, + + _restyleActiveTab(tab) { + if (!tab) { + return Promise.resolve(null); + } + + const userContextId = ContainerService._getUserContextIdFromTab(tab); + const identity = ContextualIdentityProxy.getIdentityFromId(userContextId); + const hbox = viewFor(tab.window).document.getElementById("userContext-icons"); + + if (!identity) { + hbox.setAttribute("data-identity-color", ""); + return Promise.resolve(null); + } + + hbox.setAttribute("data-identity-color", identity.color); + + const label = viewFor(tab.window).document.getElementById("userContext-label"); + label.setAttribute("value", identity.name); + label.style.color = ContainerService._fromNameToColor(identity.color); + + const indicator = viewFor(tab.window).document.getElementById("userContext-indicator"); + indicator.setAttribute("data-identity-icon", identity.icon); + indicator.style.listStyleImage = ""; + + return this._restyleTab(tab); + }, + + _restyleTab(tab) { + if (!tab) { + return Promise.resolve(null); + } + const userContextId = ContainerService._getUserContextIdFromTab(tab); + const identity = ContextualIdentityProxy.getIdentityFromId(userContextId); + if (!identity) { + return Promise.resolve(null); + } + return Promise.resolve(viewFor(tab).setAttribute("data-identity-color", identity.color)); + }, + + // Uninstallation + uninstall(reason) { + const data = ss.storage.savedConfiguration; + if (!data) { + throw new DOMError("ERROR - No saved configuration!!"); + } + + if (data.version !== 1) { + throw new DOMError("ERROR - Unknown version!!"); + } + + if (reason !== "upgrade") { + PREFS.forEach(pref => { + if (pref[0] in data.prefs) { + prefService.set(pref[0], data.prefs[pref[0]]); + } + }); + } + + // Note: We cannot go back renaming the Finance identity back to Banking: + // the locale system doesn't work with renamed containers. + + // Restore the customizable container panel. + const widget = CustomizableWidgets.find(widget => widget.id === "containers-panelmenu"); + if (widget) { + CustomizableUI.createWidget(widget); + } + + for (let window of windows.browserWindows) { // eslint-disable-line prefer-const + // Let's close all the container tabs. + // Note: We cannot use _closeTabs() because at this point tab.window is + // null. + if (!this._containerWasEnabled && reason !== "upgrade") { + for (let tab of window.tabs) { // eslint-disable-line prefer-const + if (this._getUserContextIdFromTab(tab)) { + tab.close(); + try { + SessionStore.forgetClosedTab(viewFor(window), 0); + } catch(e) {} // eslint-disable-line no-empty + } + } + } + + this._getOrCreateContainerWindow(viewFor(window)).shutdown(); + } + + // all the configuration must go away now. + this._windowMap = new Map(); + + if (reason !== "upgrade") { + // Let's forget all the previous closed tabs. + this._forgetIdentity(); + + const preInstalledIdentities = data.preInstalledIdentities; + ContextualIdentityProxy.getIdentities().forEach(identity => { + if (!preInstalledIdentities.includes(identity.userContextId)) { + ContextualIdentityService.remove(identity.userContextId); + } else { + // Let's cleanup all the cookies for this container. + Services.obs.notifyObservers(null, "clear-origin-attributes-data", + JSON.stringify({ userContextId: identity.userContextId })); + } + }); + + // Let's delete the configuration. + delete ss.storage.savedConfiguration; + } + + // Begin-Of-Hack + if (this._oldGetIdentityFromId) { + ContextualIdentityService.getIdentityFromId = this._oldGetIdentityFromId; + } + + if (this._oldGetPublicIdentityFromId) { + ContextualIdentityService.getPublicIdentityFromId = this._oldGetPublicIdentityFromId; + } + // End-Of-Hack + }, + + forgetIdentityAndRefresh(args) { + this._forgetIdentity(args.userContextId); + return this.refreshNeeded(); + }, + + _forgetIdentity(userContextId = 0) { + for (let window of windows.browserWindows) { // eslint-disable-line prefer-const + window = viewFor(window); + const closedTabData = JSON.parse(SessionStore.getClosedTabData(window)); + for (let i = closedTabData.length - 1; i >= 0; --i) { + if (!closedTabData[i].state.userContextId) { + continue; + } + + if (userContextId === 0 || + closedTabData[i].state.userContextId === userContextId) { + try { + SessionStore.forgetClosedTab(window, i); + } catch(e) {} // eslint-disable-line no-empty + } + } + } + }, +}; + +// ---------------------------------------------------------------------------- +// ContainerWindow + +// This object is used to configure a single window. +function ContainerWindow(window) { + this._init(window); +} + +ContainerWindow.prototype = { + _window: null, + _style: null, + _panelElement: null, + _timeoutStore: new Map(), + _elementCache: new Map(), + _tooltipCache: new Map(), + _tabsElement: null, + + _init(window) { + this._window = window; + this._tabsElement = this._window.document.getElementById("tabbrowser-tabs"); + this._style = Style({ uri: self.data.url("usercontext.css") }); + this._plusButton = this._window.document.getAnonymousElementByAttribute(this._tabsElement, "anonid", "tabs-newtab-button"); + this._overflowPlusButton = this._window.document.getElementById("new-tab-button"); + + // Only hack the normal plus button as the alltabs is done elsewhere + this.attachMenuEvent("plus-button", this._plusButton); + + attachTo(this._style, this._window); + }, + + attachMenuEvent(source, button) { + const popup = button.querySelector(".new-tab-popup"); + popup.addEventListener("popupshown", () => { + popup.querySelector("menuseparator").remove(); + const popupMenuItems = [...popup.querySelectorAll("menuitem")]; + popupMenuItems.forEach((item) => { + const userContextId = item.getAttribute("data-usercontextid"); + if (!userContextId) { + item.remove(); + } + item.setAttribute("command", ""); + item.addEventListener("command", (e) => { + e.stopPropagation(); + e.preventDefault(); + ContainerService.openTab({ + userContextId: userContextId, + source: source + }); + }); + }); + }); + }, + + configure() { + return Promise.all([ + this._configureActiveTab(), + this._configureFileMenu(), + this._configureAllTabsMenu(), + this._configureTabStyle(), + this.configureActive(), + ]); + }, + + configureActive() { + return this._configureContextMenu(); + }, + + _configureTabStyle() { + const promises = []; + for (let tab of modelFor(this._window).tabs) { // eslint-disable-line prefer-const + promises.push(ContainerService._restyleTab(tab)); + } + return Promise.all(promises); + }, + + _configureActiveTab() { + const tab = modelFor(this._window).tabs.activeTab; + return ContainerService._restyleActiveTab(tab); + }, + + _configureFileMenu() { + return this._configureMenu("menu_newUserContext", null, e => { + const userContextId = parseInt(e.target.getAttribute("data-usercontextid"), 10); + ContainerService.openTab({ + userContextId: userContextId, + source: "file-menu" + }); + }); + }, + + _configureAllTabsMenu() { + return this._configureMenu("alltabs_containersTab", null, e => { + const userContextId = parseInt(e.target.getAttribute("data-usercontextid"), 10); + ContainerService.showTabs({ + userContextId, + nofocus: true, + window: this._window, + }).then(() => { + return ContainerService.openTab({ + userContextId, + source: "alltabs-menu" + }); + }).catch(() => {}); + }); + }, + + _configureContextMenu() { + return Promise.all([ + this._configureMenu("context-openlinkinusercontext-menu", + () => { + // This userContextId is what we want to exclude. + const tab = modelFor(this._window).tabs.activeTab; + return ContainerService._getUserContextIdFromTab(tab); + }, + e => { + // This is a super internal method. Hopefully it will be stable in the + // next FF releases. + this._window.gContextMenu.openLinkInTab(e); + + const userContextId = parseInt(e.target.getAttribute("data-usercontextid"), 10); + ContainerService.showTabs({ + userContextId, + nofocus: true, + window: this._window, + }); + } + ), + this._configureContextMenuOpenLink(), + ]); + }, + + _configureContextMenuOpenLink() { + return new Promise(resolve => { + const self = this; + this._window.gSetUserContextIdAndClick = function(event) { + const tab = modelFor(self._window).tabs.activeTab; + const userContextId = ContainerService._getUserContextIdFromTab(tab); + event.target.setAttribute("data-usercontextid", userContextId); + self._window.gContextMenu.openLinkInTab(event); + }; + + let item = this._window.document.getElementById("context-openlinkincontainertab"); + item.setAttribute("oncommand", "gSetUserContextIdAndClick(event)"); + + item = this._window.document.getElementById("context-openlinkintab"); + item.setAttribute("oncommand", "gSetUserContextIdAndClick(event)"); + + resolve(); + }); + }, + + // Generic menu configuration. + _configureMenu(menuId, excludedContainerCb, clickCb) { + const menu = this._window.document.getElementById(menuId); + if (!this._disableElement(menu)) { + // Delete stale menu that isn't native elements + while (menu.firstChild) { + menu.removeChild(menu.firstChild); + } + } + + const menupopup = this._window.document.createElementNS(XUL_NS, "menupopup"); + menu.appendChild(menupopup); + + menupopup.addEventListener("command", clickCb); + return this._createMenu(menupopup, excludedContainerCb); + }, + + _createMenu(target, excludedContainerCb) { + while (target.hasChildNodes()) { + target.removeChild(target.firstChild); + } + + return new Promise((resolve, reject) => { + ContainerService.queryIdentities().then(identities => { + const fragment = this._window.document.createDocumentFragment(); + + const excludedUserContextId = excludedContainerCb ? excludedContainerCb() : 0; + if (excludedUserContextId) { + const bundle = this._window.document.getElementById("bundle_browser"); + + const menuitem = this._window.document.createElementNS(XUL_NS, "menuitem"); + menuitem.setAttribute("data-usercontextid", "0"); + menuitem.setAttribute("label", bundle.getString("userContextNone.label")); + menuitem.setAttribute("accesskey", bundle.getString("userContextNone.accesskey")); + + fragment.appendChild(menuitem); + + const menuseparator = this._window.document.createElementNS(XUL_NS, "menuseparator"); + fragment.appendChild(menuseparator); + } + + identities.forEach(identity => { + if (identity.userContextId === excludedUserContextId) { + return; + } + + const menuitem = this._window.document.createElementNS(XUL_NS, "menuitem"); + menuitem.setAttribute("label", identity.name); + menuitem.classList.add("menuitem-iconic"); + menuitem.setAttribute("data-usercontextid", identity.userContextId); + menuitem.setAttribute("data-identity-color", identity.color); + menuitem.setAttribute("data-identity-icon", identity.icon); + fragment.appendChild(menuitem); + }); + + target.appendChild(fragment); + resolve(); + }).catch(() => {reject();}); + }); + }, + + // This timer is used to hide the panel auto-magically if it's not used in + // the following X seconds. This is need to avoid the leaking of the panel + // when the mouse goes out of of the 'plus' button. + _createTimeout(key, callback, timeoutTime) { + this._cleanTimeout(key); + this._timeoutStore.set(key, this._window.setTimeout(() => { + callback(); + this._timeoutStore.delete(key); + }, timeoutTime)); + }, + + _cleanAllTimeouts() { + for (let key of this._timeoutStore.keys()) { // eslint-disable-line prefer-const + this._cleanTimeout(key); + } + }, + + _cleanTimeout(key) { + if (this._timeoutStore.has(key)) { + this._window.clearTimeout(this._timeoutStore.get(key)); + this._timeoutStore.delete(key); + } + }, + + shutdown() { + // CSS must be removed. + detachFrom(this._style, this._window); + + this._shutdownFileMenu(); + this._shutdownAllTabsMenu(); + this._shutdownContextMenu(); + + this._shutdownContainers(); + }, + + _shutDownPlusButtonMenuElement(buttonElement) { + if (buttonElement) { + this._shutdownElement(buttonElement); + buttonElement.setAttribute("tooltip", this._tooltipCache.get(buttonElement)); + + buttonElement.removeEventListener("mouseover", this); + buttonElement.removeEventListener("click", this); + buttonElement.removeEventListener("mouseout", this); + } + }, + + _shutdownFileMenu() { + this._shutdownMenu("menu_newUserContext"); + }, + + _shutdownAllTabsMenu() { + this._shutdownMenu("alltabs_containersTab"); + }, + + _shutdownContextMenu() { + this._shutdownMenu("context-openlinkinusercontext-menu"); + }, + + _shutdownMenu(menuId) { + const menu = this._window.document.getElementById(menuId); + this._shutdownElement(menu); + }, + + _shutdownElement(element) { + // Let's remove our elements. + while (element.firstChild) { + element.firstChild.remove(); + } + + const elementCache = this._elementCache.get(element); + if (elementCache) { + for (let e of elementCache) { // eslint-disable-line prefer-const + element.appendChild(e); + } + } + }, + + _disableElement(element) { + // Nothing to disable. + if (!element || this._elementCache.has(element)) { + return false; + } + const cacheArray = []; + + // Let's store the previous elements so that we can repopulate it in case + // the addon is uninstalled. + while (element.firstChild) { + cacheArray.push(element.removeChild(element.firstChild)); + } + + this._elementCache.set(element, cacheArray); + + return true; + }, + + _shutdownContainers() { + ContextualIdentityProxy.getIdentities().forEach(identity => { + if (IDENTITY_ICONS_STANDARD.indexOf(identity.icon) !== -1 && + IDENTITY_COLORS_STANDARD.indexOf(identity.color) !== -1) { + return; + } + + if (IDENTITY_ICONS_STANDARD.indexOf(identity.icon) === -1) { + if (identity.userContextId <= IDENTITY_ICONS_STANDARD.length) { + identity.icon = IDENTITY_ICONS_STANDARD[identity.userContextId - 1]; + } else { + identity.icon = IDENTITY_ICONS_STANDARD[0]; + } + } + + if (IDENTITY_COLORS_STANDARD.indexOf(identity.color) === -1) { + if (identity.userContextId <= IDENTITY_COLORS_STANDARD.length) { + identity.color = IDENTITY_COLORS_STANDARD[identity.userContextId - 1]; + } else { + identity.color = IDENTITY_COLORS_STANDARD[0]; + } + } + + ContextualIdentityService.update(identity.userContextId, + identity.name, + identity.icon, + identity.color); + }); + } +}; + +// uninstall/install events --------------------------------------------------- + +exports.main = function (options) { + const installation = options.loadReason === "install" || + options.loadReason === "downgrade" || + options.loadReason === "enable" || + options.loadReason === "upgrade"; + + // Let's start :) + ContainerService.init(installation, options.loadReason); +}; + +exports.onUnload = function (reason) { + if (reason === "disable" || + reason === "downgrade" || + reason === "uninstall" || + reason === "upgrade") { + ContainerService.uninstall(reason); + } +}; diff --git a/index.js.bk b/index.js.bk deleted file mode 100644 index f38c596..0000000 --- a/index.js.bk +++ /dev/null @@ -1,1060 +0,0 @@ -/* 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/. */ - -const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; - -const INCOMPATIBLE_ADDON_IDS = [ - "pulse@mozilla.com", - "snoozetabs@mozilla.com", - "jid1-NeEaf3sAHdKHPA@jetpack" // PageShot -]; - -const IDENTITY_COLORS = [ - { name: "blue", color: "#00a7e0" }, - { name: "turquoise", color: "#01bdad" }, - { name: "green", color: "#7dc14c" }, - { name: "yellow", color: "#ffcb00" }, - { name: "orange", color: "#f89c24" }, - { name: "red", color: "#d92215" }, - { name: "pink", color: "#ee5195" }, - { name: "purple", color: "#7a2f7a" }, -]; - -const IDENTITY_ICONS = [ - { name: "fingerprint", image: "chrome://browser/skin/usercontext/personal.svg" }, - { name: "briefcase", image: "chrome://browser/skin/usercontext/work.svg" }, - { name: "dollar", image: "chrome://browser/skin/usercontext/banking.svg" }, - { name: "cart", image: "chrome://browser/skin/usercontext/shopping.svg" }, - // All of these do not exist in gecko - { name: "gift", image: "gift" }, - { name: "vacation", image: "vacation" }, - { name: "food", image: "food" }, - { name: "fruit", image: "fruit" }, - { name: "pet", image: "pet" }, - { name: "tree", image: "tree" }, - { name: "chill", image: "chill" }, - { name: "circle", image: "circle" }, -]; - -const IDENTITY_COLORS_STANDARD = [ - "blue", "orange", "green", "pink", -]; - -const IDENTITY_ICONS_STANDARD = [ - "fingerprint", "briefcase", "dollar", "cart", -]; - -const PREFS = [ - [ "privacy.userContext.enabled", true ], - [ "privacy.userContext.longPressBehavior", 2 ], - [ "privacy.userContext.ui.enabled", false ], - [ "privacy.usercontext.about_newtab_segregation.enabled", true ], -]; - -const { AddonManager } = require("resource://gre/modules/AddonManager.jsm"); -const { attachTo, detachFrom } = require("sdk/content/mod"); -const { Cu } = require("chrome"); -const { ContextualIdentityService } = require("resource://gre/modules/ContextualIdentityService.jsm"); -const { LightweightThemeManager } = Cu.import("resource://gre/modules/LightweightThemeManager.jsm", {}); -const Metrics = require("./testpilot-metrics"); -const { modelFor } = require("sdk/model/core"); -const prefService = require("sdk/preferences/service"); -const self = require("sdk/self"); -const { Services } = require("resource://gre/modules/Services.jsm"); -const ss = require("sdk/simple-storage"); -const { study } = require("./study"); -const { Style } = require("sdk/stylesheet/style"); -const tabs = require("sdk/tabs"); -const uuid = require("sdk/util/uuid"); -const { viewFor } = require("sdk/view/core"); -const webExtension = require("sdk/webextension"); -const windows = require("sdk/windows"); -const windowUtils = require("sdk/window/utils"); - -Cu.import("resource:///modules/CustomizableUI.jsm"); -Cu.import("resource:///modules/CustomizableWidgets.jsm"); -Cu.import("resource:///modules/sessionstore/SessionStore.jsm"); -Cu.import("resource://gre/modules/Services.jsm"); - - -// ContextualIdentityProxy -const ContextualIdentityProxy = { - getIdentities() { - let response; - if ("getPublicIdentities" in ContextualIdentityService) { - response = ContextualIdentityService.getPublicIdentities(); - } else { - response = ContextualIdentityService.getIdentities(); - } - - return response.map((identity) => { - return this._convert(identity); - }); - }, - - getIdentityFromId(userContextId) { - let response; - if ("getPublicIdentityFromId" in ContextualIdentityService) { - response = ContextualIdentityService.getPublicIdentityFromId(userContextId); - } else { - response = ContextualIdentityService.getIdentityFromId(userContextId); - } - if (response) { - return this._convert(response); - } - return response; - }, - - _convert(identity) { - return { - name: ContextualIdentityService.getUserContextLabel(identity.userContextId), - icon: identity.icon, - color: identity.color, - userContextId: identity.userContextId, - }; - }, -}; - -// ---------------------------------------------------------------------------- -// ContainerService - -const ContainerService = { - _windowMap: new Map(), - _containerWasEnabled: false, - _onBackgroundConnectCallback: null, - - async init(installation, reason) { - // If we are just been installed, we must store some information for the - // uninstallation. This object contains also a version number, in case we - // need to implement a migration in the future. - // In 1.1.1 and less we deleted savedConfiguration on upgrade so we need to rebuild - if (!("savedConfiguration" in ss.storage) || - !("prefs" in ss.storage.savedConfiguration) || - (installation && reason !== "upgrade")) { - let preInstalledIdentities = []; // eslint-disable-line prefer-const - ContextualIdentityProxy.getIdentities().forEach(identity => { - preInstalledIdentities.push(identity.userContextId); - }); - - const object = { - version: 1, - prefs: {}, - metricsUUID: uuid.uuid().toString(), - preInstalledIdentities: preInstalledIdentities - }; - - PREFS.forEach(pref => { - object.prefs[pref[0]] = prefService.get(pref[0]); - }); - - ss.storage.savedConfiguration = object; - - if (prefService.get("privacy.userContext.enabled") !== true) { - // Maybe rename the Banking container. - const identity = ContextualIdentityProxy.getIdentityFromId(3); - if (identity && identity.l10nID === "userContextBanking.label") { - ContextualIdentityService.update(identity.userContextId, - "Finance", - identity.icon, - identity.color); - } - - // Let's create the default containers in case there are none. - if (ss.storage.savedConfiguration.preInstalledIdentities.length === 0) { - // Note: we have to create them in this way because there is no way to - // reuse the same ID and the localized strings. - ContextualIdentityService.create("Personal", "fingerprint", "blue"); - ContextualIdentityService.create("Work", "briefcase", "orange"); - ContextualIdentityService.create("Finance", "dollar", "green"); - ContextualIdentityService.create("Shopping", "cart", "pink"); - } - } - } - - // TOCHECK should this run on all code - ContextualIdentityProxy.getIdentities().forEach(identity => { - const newIcon = this._fromIconToName(identity.icon); - const newColor = this._fromColorToName(identity.color); - if (newIcon !== identity.icon || newColor !== identity.color) { - ContextualIdentityService.update(identity.userContextId, - ContextualIdentityService.getUserContextLabel(identity.userContextId), - newIcon, - newColor); - } - }); - - // Let's see if containers were enabled before this addon. - this._containerWasEnabled = - ss.storage.savedConfiguration.prefs["privacy.userContext.enabled"]; - - // Enabling preferences - - PREFS.forEach((pref) => { - prefService.set(pref[0], pref[1]); - }); - - this._metricsUUID = ss.storage.savedConfiguration.metricsUUID; - - // Disabling the customizable container panel. - CustomizableUI.destroyWidget("containers-panelmenu"); - - // Message routing - - // only these methods are allowed. We have a 1:1 mapping between messages - // and methods. These methods must return a promise. - const methods = [ - "showTabs", - "sortTabs", - "getIdentity", - "getPreference", - "sendTelemetryPayload", - "getTheme", - "getShieldStudyVariation", - "refreshNeeded", - "forgetIdentityAndRefresh", - "checkIncompatibleAddons" - ]; - - // Map of identities. - ContextualIdentityProxy.getIdentities().forEach(identity => { - this._remapTabsIfMissing(identity.userContextId); - }); - - // Let's restore the hidden tabs from the previous session. - if (prefService.get("browser.startup.page") === 3 && - "identitiesData" in ss.storage) { - // TODO move Restore State? - /* - ContextualIdentityProxy.getIdentities().forEach(identity => { - if (identity.userContextId in ss.storage.identitiesData && - "hiddenTabs" in ss.storage.identitiesData[identity.userContextId]) { - this._identitiesState[identity.userContextId].hiddenTabs = - ss.storage.identitiesData[identity.userContextId].hiddenTabs; - } - }); -*/ - } - - tabs.on("open", tab => { - this._restyleTab(tab); - this._remapTab(tab); - }); - - tabs.on("close", tab => { - this._remapTab(tab); - }); - - tabs.on("activate", tab => { - this._restyleActiveTab(tab).catch(() => {}); - this._configureActiveWindows(); - this._remapTab(tab); - }); - - // Modify CSS and other stuff for each window. - - this._configureWindows().catch(() => {}); - - windows.browserWindows.on("open", window => { - this._configureWindow(viewFor(window)).catch(() => {}); - }); - - windows.browserWindows.on("close", window => { - this.closeWindow(viewFor(window)); - }); - - // WebExtension startup - - try { - const api = await webExtension.startup(); - api.browser.runtime.onMessage.addListener((message, sender, sendReply) => { - if ("method" in message && methods.indexOf(message.method) !== -1) { - sendReply(this[message.method](message)); - } - }); - - this.registerBackgroundConnection(api); - } catch (e) { - throw new Error("WebExtension startup failed. Unable to continue."); - } - - this._sendEvent = new Metrics({ - type: "sdk", - id: self.id, - version: self.version - }).sendEvent; - - // Begin-Of-Hack - ContextualIdentityService.workaroundForCookieManager = function(method, userContextId) { - let identity = method.call(ContextualIdentityService, userContextId); - if (!identity && userContextId) { - identity = { - userContextId, - icon: "", - color: "", - name: "Pending to be deleted", - public: true, - }; - } - - return identity; - }; - - if (!this._oldGetIdentityFromId) { - this._oldGetIdentityFromId = ContextualIdentityService.getIdentityFromId; - } - ContextualIdentityService.getIdentityFromId = function(userContextId) { - return this.workaroundForCookieManager(ContainerService._oldGetIdentityFromId, userContextId); - }; - - if ("getPublicIdentityFromId" in ContextualIdentityService) { - if (!this._oldGetPublicIdentityFromId) { - this._oldGetPublicIdentityFromId = ContextualIdentityService.getPublicIdentityFromId; - } - ContextualIdentityService.getPublicIdentityFromId = function(userContextId) { - return this.workaroundForCookieManager(ContainerService._oldGetPublicIdentityFromId, userContextId); - }; - } - // End-Of-Hack - - Services.obs.addObserver(this, "lightweight-theme-changed", false); - - if (self.id === "@shield-study-containers") { - study.startup(reason); - this.shieldStudyVariation = study.variation; - } - }, - - registerBackgroundConnection(api) { - // This is only used for theme notifications and new tab - api.browser.runtime.onConnect.addListener((port) => { - this._onBackgroundConnectCallback = (message, topic) => { - port.postMessage({ - type: topic, - message - }); - }; - }); - }, - - triggerBackgroundCallback(message, topic) { - if (this._onBackgroundConnectCallback) { - this._onBackgroundConnectCallback(message, topic); - } - }, - - async observe(subject, topic) { - if (topic === "lightweight-theme-changed") { - try { - const theme = await this.getTheme(); - this.triggerBackgroundCallback(theme, topic); - } catch (e) { - throw new Error("Unable to get theme"); - } - } - }, - - getTheme() { - const defaultTheme = "firefox-compact-light@mozilla.org"; - return new Promise(function (resolve) { - let theme = defaultTheme; - if (LightweightThemeManager.currentTheme && LightweightThemeManager.currentTheme.id) { - theme = LightweightThemeManager.currentTheme.id; - } - resolve(theme); - }); - }, - - getShieldStudyVariation() { - return this.shieldStudyVariation; - }, - - // 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. - - // Helper methods for converting colors to names and names to colors. - - _fromNameToColor(name) { - return this._fromNameOrColor(name, "color"); - }, - - _fromColorToName(color) { - return this._fromNameOrColor(color, "name"); - }, - - _fromNameOrColor(what, attribute) { - for (let color of IDENTITY_COLORS) { // eslint-disable-line prefer-const - if (what === color.color || what === color.name) { - return color[attribute]; - } - } - return ""; - }, - - // Helper methods for converting icons to names and names to icons. - - _fromIconToName(icon) { - return this._fromNameOrIcon(icon, "name", "circle"); - }, - - _fromNameOrIcon(what, attribute, defaultValue) { - for (let icon of IDENTITY_ICONS) { // eslint-disable-line prefer-const - if (what === icon.image || what === icon.name) { - return icon[attribute]; - } - } - return defaultValue; - }, - - // Tab Helpers - - _getUserContextIdFromTab(tab) { - return parseInt(viewFor(tab).getAttribute("usercontextid") || 0, 10); - }, - - _matchTabsByContainer(userContextId) { - const matchedTabs = []; - for (const tab of tabs) { - if (userContextId === this._getUserContextIdFromTab(tab)) { - matchedTabs.push(tab); - } - } - return matchedTabs; - }, - - async _closeTabs(tabsToClose) { - // We create a new tab only if the current operation closes all the - // existing ones. - if (tabs.length === tabsToClose.length) { - await this.openTab({}); - } - - for (const tab of tabsToClose) { - // after .close() window is null. Let's take it now. - const window = viewFor(tab.window); - - tab.close(); - - // forget about this tab. 0 is the index of the forgotten tab and 0 - // means the last one. - try { - SessionStore.forgetClosedTab(window, 0); - } catch (e) {} // eslint-disable-line no-empty - } - }, - - _recentBrowserWindow() { - const browserWin = windowUtils.getMostRecentBrowserWindow(); - - // This should not really happen. - if (!browserWin || !browserWin.gBrowser) { - return Promise.resolve(null); - } - - return Promise.resolve(browserWin); - }, - - - sendTelemetryPayload(args = {}) { - // when pings come from popup, delete "method" prop - delete args.method; - let payload = { // eslint-disable-line prefer-const - "uuid": this._metricsUUID - }; - Object.assign(payload, args); - - this._sendEvent(payload); - }, - - checkIncompatibleAddons() { - return new Promise(resolve => { - AddonManager.getAddonsByIDs(INCOMPATIBLE_ADDON_IDS, (addons) => { - addons = addons.filter((a) => a && a.isActive); - const incompatibleAddons = addons.length !== 0; - if (incompatibleAddons) { - this.sendTelemetryPayload({ - "event": "incompatible-addons-detected" - }); - } - resolve(incompatibleAddons); - }); - }); - }, - - // Tabs management - - openTab(args) { - return this.triggerBackgroundCallback(args, "open-tab"); - }, - - // Identities management - - queryIdentities() { - return new Promise(resolve => { - const identities = ContextualIdentityProxy.getIdentities(); - identities.forEach(identity => { - this._remapTabsIfMissing(identity.userContextId); - }); - - resolve(identities); - }); - }, - - // Preferences - - getPreference(args) { - if (!("pref" in args)) { - return Promise.reject("getPreference must be called with pref argument."); - } - - return Promise.resolve(prefService.get(args.pref)); - }, - - // Styling the window - - _configureWindows() { - const promises = []; - for (let window of windows.browserWindows) { // eslint-disable-line prefer-const - promises.push(this._configureWindow(viewFor(window))); - } - return Promise.all(promises); - }, - - _configureWindow(window) { - return this._getOrCreateContainerWindow(window).configure(); - }, - - _configureActiveWindows() { - const promises = []; - for (let window of windows.browserWindows) { // eslint-disable-line prefer-const - promises.push(this._configureActiveWindow(viewFor(window))); - } - return Promise.all(promises); - }, - - _configureActiveWindow(window) { - return this._getOrCreateContainerWindow(window).configureActive(); - }, - - closeWindow(window) { - this._windowMap.delete(window); - }, - - _getOrCreateContainerWindow(window) { - if (!(this._windowMap.has(window))) { - this._windowMap.set(window, new ContainerWindow(window)); - } - - return this._windowMap.get(window); - }, - - refreshNeeded() { - return this._configureWindows(); - }, - - _restyleActiveTab(tab) { - if (!tab) { - return Promise.resolve(null); - } - - const userContextId = ContainerService._getUserContextIdFromTab(tab); - const identity = ContextualIdentityProxy.getIdentityFromId(userContextId); - const hbox = viewFor(tab.window).document.getElementById("userContext-icons"); - - if (!identity) { - hbox.setAttribute("data-identity-color", ""); - return Promise.resolve(null); - } - - hbox.setAttribute("data-identity-color", identity.color); - - const label = viewFor(tab.window).document.getElementById("userContext-label"); - label.setAttribute("value", identity.name); - label.style.color = ContainerService._fromNameToColor(identity.color); - - const indicator = viewFor(tab.window).document.getElementById("userContext-indicator"); - indicator.setAttribute("data-identity-icon", identity.icon); - indicator.style.listStyleImage = ""; - - return this._restyleTab(tab); - }, - - _restyleTab(tab) { - if (!tab) { - return Promise.resolve(null); - } - const userContextId = ContainerService._getUserContextIdFromTab(tab); - const identity = ContextualIdentityProxy.getIdentityFromId(userContextId); - if (!identity) { - return Promise.resolve(null); - } - return Promise.resolve(viewFor(tab).setAttribute("data-identity-color", identity.color)); - }, - - // Uninstallation - uninstall(reason) { - const data = ss.storage.savedConfiguration; - if (!data) { - throw new DOMError("ERROR - No saved configuration!!"); - } - - if (data.version !== 1) { - throw new DOMError("ERROR - Unknown version!!"); - } - - if (reason !== "upgrade") { - PREFS.forEach(pref => { - if (pref[0] in data.prefs) { - prefService.set(pref[0], data.prefs[pref[0]]); - } - }); - } - - // Note: We cannot go back renaming the Finance identity back to Banking: - // the locale system doesn't work with renamed containers. - - // Restore the customizable container panel. - const widget = CustomizableWidgets.find(widget => widget.id === "containers-panelmenu"); - if (widget) { - CustomizableUI.createWidget(widget); - } - - for (let window of windows.browserWindows) { // eslint-disable-line prefer-const - // Let's close all the container tabs. - // Note: We cannot use _closeTabs() because at this point tab.window is - // null. - if (!this._containerWasEnabled && reason !== "upgrade") { - for (let tab of window.tabs) { // eslint-disable-line prefer-const - if (this._getUserContextIdFromTab(tab)) { - tab.close(); - try { - SessionStore.forgetClosedTab(viewFor(window), 0); - } catch(e) {} // eslint-disable-line no-empty - } - } - } - - this._getOrCreateContainerWindow(viewFor(window)).shutdown(); - } - - // all the configuration must go away now. - this._windowMap = new Map(); - - if (reason !== "upgrade") { - // Let's forget all the previous closed tabs. - this._forgetIdentity(); - - const preInstalledIdentities = data.preInstalledIdentities; - ContextualIdentityProxy.getIdentities().forEach(identity => { - if (!preInstalledIdentities.includes(identity.userContextId)) { - ContextualIdentityService.remove(identity.userContextId); - } else { - // Let's cleanup all the cookies for this container. - Services.obs.notifyObservers(null, "clear-origin-attributes-data", - JSON.stringify({ userContextId: identity.userContextId })); - } - }); - - // Let's delete the configuration. - delete ss.storage.savedConfiguration; - } - - // Begin-Of-Hack - if (this._oldGetIdentityFromId) { - ContextualIdentityService.getIdentityFromId = this._oldGetIdentityFromId; - } - - if (this._oldGetPublicIdentityFromId) { - ContextualIdentityService.getPublicIdentityFromId = this._oldGetPublicIdentityFromId; - } - // End-Of-Hack - }, - - forgetIdentityAndRefresh(args) { - this._forgetIdentity(args.userContextId); - return this.refreshNeeded(); - }, - - _forgetIdentity(userContextId = 0) { - for (let window of windows.browserWindows) { // eslint-disable-line prefer-const - window = viewFor(window); - const closedTabData = JSON.parse(SessionStore.getClosedTabData(window)); - for (let i = closedTabData.length - 1; i >= 0; --i) { - if (!closedTabData[i].state.userContextId) { - continue; - } - - if (userContextId === 0 || - closedTabData[i].state.userContextId === userContextId) { - try { - SessionStore.forgetClosedTab(window, i); - } catch(e) {} // eslint-disable-line no-empty - } - } - } - }, -}; - -// ---------------------------------------------------------------------------- -// ContainerWindow - -// This object is used to configure a single window. -function ContainerWindow(window) { - this._init(window); -} - -ContainerWindow.prototype = { - _window: null, - _style: null, - _panelElement: null, - _timeoutStore: new Map(), - _elementCache: new Map(), - _tooltipCache: new Map(), - _tabsElement: null, - - _init(window) { - this._window = window; - this._tabsElement = this._window.document.getElementById("tabbrowser-tabs"); - this._style = Style({ uri: self.data.url("usercontext.css") }); - this._plusButton = this._window.document.getAnonymousElementByAttribute(this._tabsElement, "anonid", "tabs-newtab-button"); - this._overflowPlusButton = this._window.document.getElementById("new-tab-button"); - - // Only hack the normal plus button as the alltabs is done elsewhere - this.attachMenuEvent("plus-button", this._plusButton); - - attachTo(this._style, this._window); - }, - - attachMenuEvent(source, button) { - const popup = button.querySelector(".new-tab-popup"); - popup.addEventListener("popupshown", () => { - ContainerService.sendTelemetryPayload({ - "event": "show-plus-button-menu", - "eventSource": source - }); - popup.querySelector("menuseparator").remove(); - const popupMenuItems = [...popup.querySelectorAll("menuitem")]; - popupMenuItems.forEach((item) => { - const userContextId = item.getAttribute("data-usercontextid"); - if (!userContextId) { - item.remove(); - } - item.setAttribute("command", ""); - item.addEventListener("command", (e) => { - e.stopPropagation(); - e.preventDefault(); - ContainerService.openTab({ - userContextId: userContextId, - source: source - }); - }); - }); - }); - }, - - configure() { - return Promise.all([ - this._configureActiveTab(), - this._configureFileMenu(), - this._configureAllTabsMenu(), - this._configureTabStyle(), - this.configureActive(), - ]); - }, - - configureActive() { - return this._configureContextMenu(); - }, - - _configureTabStyle() { - const promises = []; - for (let tab of modelFor(this._window).tabs) { // eslint-disable-line prefer-const - promises.push(ContainerService._restyleTab(tab)); - } - return Promise.all(promises); - }, - - _configureActiveTab() { - const tab = modelFor(this._window).tabs.activeTab; - return ContainerService._restyleActiveTab(tab); - }, - - _configureFileMenu() { - return this._configureMenu("menu_newUserContext", null, e => { - const userContextId = parseInt(e.target.getAttribute("data-usercontextid"), 10); - ContainerService.openTab({ - userContextId: userContextId, - source: "file-menu" - }); - }); - }, - - _configureAllTabsMenu() { - return this._configureMenu("alltabs_containersTab", null, e => { - const userContextId = parseInt(e.target.getAttribute("data-usercontextid"), 10); - ContainerService.showTabs({ - userContextId, - nofocus: true, - window: this._window, - }).then(() => { - return ContainerService.openTab({ - userContextId, - source: "alltabs-menu" - }); - }).catch(() => {}); - }); - }, - - _configureContextMenu() { - return Promise.all([ - this._configureMenu("context-openlinkinusercontext-menu", - () => { - // This userContextId is what we want to exclude. - const tab = modelFor(this._window).tabs.activeTab; - return ContainerService._getUserContextIdFromTab(tab); - }, - e => { - // This is a super internal method. Hopefully it will be stable in the - // next FF releases. - this._window.gContextMenu.openLinkInTab(e); - - const userContextId = parseInt(e.target.getAttribute("data-usercontextid"), 10); - ContainerService.showTabs({ - userContextId, - nofocus: true, - window: this._window, - }); - } - ), - this._configureContextMenuOpenLink(), - ]); - }, - - _configureContextMenuOpenLink() { - return new Promise(resolve => { - const self = this; - this._window.gSetUserContextIdAndClick = function(event) { - const tab = modelFor(self._window).tabs.activeTab; - const userContextId = ContainerService._getUserContextIdFromTab(tab); - event.target.setAttribute("data-usercontextid", userContextId); - self._window.gContextMenu.openLinkInTab(event); - }; - - let item = this._window.document.getElementById("context-openlinkincontainertab"); - item.setAttribute("oncommand", "gSetUserContextIdAndClick(event)"); - - item = this._window.document.getElementById("context-openlinkintab"); - item.setAttribute("oncommand", "gSetUserContextIdAndClick(event)"); - - resolve(); - }); - }, - - // Generic menu configuration. - _configureMenu(menuId, excludedContainerCb, clickCb) { - const menu = this._window.document.getElementById(menuId); - if (!this._disableElement(menu)) { - // Delete stale menu that isn't native elements - while (menu.firstChild) { - menu.removeChild(menu.firstChild); - } - } - - const menupopup = this._window.document.createElementNS(XUL_NS, "menupopup"); - menu.appendChild(menupopup); - - menupopup.addEventListener("command", clickCb); - return this._createMenu(menupopup, excludedContainerCb); - }, - - _createMenu(target, excludedContainerCb) { - while (target.hasChildNodes()) { - target.removeChild(target.firstChild); - } - - return new Promise((resolve, reject) => { - ContainerService.queryIdentities().then(identities => { - const fragment = this._window.document.createDocumentFragment(); - - const excludedUserContextId = excludedContainerCb ? excludedContainerCb() : 0; - if (excludedUserContextId) { - const bundle = this._window.document.getElementById("bundle_browser"); - - const menuitem = this._window.document.createElementNS(XUL_NS, "menuitem"); - menuitem.setAttribute("data-usercontextid", "0"); - menuitem.setAttribute("label", bundle.getString("userContextNone.label")); - menuitem.setAttribute("accesskey", bundle.getString("userContextNone.accesskey")); - - fragment.appendChild(menuitem); - - const menuseparator = this._window.document.createElementNS(XUL_NS, "menuseparator"); - fragment.appendChild(menuseparator); - } - - identities.forEach(identity => { - if (identity.userContextId === excludedUserContextId) { - return; - } - - const menuitem = this._window.document.createElementNS(XUL_NS, "menuitem"); - menuitem.setAttribute("label", identity.name); - menuitem.classList.add("menuitem-iconic"); - menuitem.setAttribute("data-usercontextid", identity.userContextId); - menuitem.setAttribute("data-identity-color", identity.color); - menuitem.setAttribute("data-identity-icon", identity.icon); - fragment.appendChild(menuitem); - }); - - target.appendChild(fragment); - resolve(); - }).catch(() => {reject();}); - }); - }, - - // This timer is used to hide the panel auto-magically if it's not used in - // the following X seconds. This is need to avoid the leaking of the panel - // when the mouse goes out of of the 'plus' button. - _createTimeout(key, callback, timeoutTime) { - this._cleanTimeout(key); - this._timeoutStore.set(key, this._window.setTimeout(() => { - callback(); - this._timeoutStore.delete(key); - }, timeoutTime)); - }, - - _cleanAllTimeouts() { - for (let key of this._timeoutStore.keys()) { // eslint-disable-line prefer-const - this._cleanTimeout(key); - } - }, - - _cleanTimeout(key) { - if (this._timeoutStore.has(key)) { - this._window.clearTimeout(this._timeoutStore.get(key)); - this._timeoutStore.delete(key); - } - }, - - shutdown() { - // CSS must be removed. - detachFrom(this._style, this._window); - - this._shutdownFileMenu(); - this._shutdownAllTabsMenu(); - this._shutdownContextMenu(); - - this._shutdownContainers(); - }, - - _shutDownPlusButtonMenuElement(buttonElement) { - if (buttonElement) { - this._shutdownElement(buttonElement); - buttonElement.setAttribute("tooltip", this._tooltipCache.get(buttonElement)); - - buttonElement.removeEventListener("mouseover", this); - buttonElement.removeEventListener("click", this); - buttonElement.removeEventListener("mouseout", this); - } - }, - - _shutdownFileMenu() { - this._shutdownMenu("menu_newUserContext"); - }, - - _shutdownAllTabsMenu() { - this._shutdownMenu("alltabs_containersTab"); - }, - - _shutdownContextMenu() { - this._shutdownMenu("context-openlinkinusercontext-menu"); - }, - - _shutdownMenu(menuId) { - const menu = this._window.document.getElementById(menuId); - this._shutdownElement(menu); - }, - - _shutdownElement(element) { - // Let's remove our elements. - while (element.firstChild) { - element.firstChild.remove(); - } - - const elementCache = this._elementCache.get(element); - if (elementCache) { - for (let e of elementCache) { // eslint-disable-line prefer-const - element.appendChild(e); - } - } - }, - - _disableElement(element) { - // Nothing to disable. - if (!element || this._elementCache.has(element)) { - return false; - } - const cacheArray = []; - - // Let's store the previous elements so that we can repopulate it in case - // the addon is uninstalled. - while (element.firstChild) { - cacheArray.push(element.removeChild(element.firstChild)); - } - - this._elementCache.set(element, cacheArray); - - return true; - }, - - _shutdownContainers() { - ContextualIdentityProxy.getIdentities().forEach(identity => { - if (IDENTITY_ICONS_STANDARD.indexOf(identity.icon) !== -1 && - IDENTITY_COLORS_STANDARD.indexOf(identity.color) !== -1) { - return; - } - - if (IDENTITY_ICONS_STANDARD.indexOf(identity.icon) === -1) { - if (identity.userContextId <= IDENTITY_ICONS_STANDARD.length) { - identity.icon = IDENTITY_ICONS_STANDARD[identity.userContextId - 1]; - } else { - identity.icon = IDENTITY_ICONS_STANDARD[0]; - } - } - - if (IDENTITY_COLORS_STANDARD.indexOf(identity.color) === -1) { - if (identity.userContextId <= IDENTITY_COLORS_STANDARD.length) { - identity.color = IDENTITY_COLORS_STANDARD[identity.userContextId - 1]; - } else { - identity.color = IDENTITY_COLORS_STANDARD[0]; - } - } - - ContextualIdentityService.update(identity.userContextId, - identity.name, - identity.icon, - identity.color); - }); - } -}; - -// uninstall/install events --------------------------------------------------- - -exports.main = function (options) { - const installation = options.loadReason === "install" || - options.loadReason === "downgrade" || - options.loadReason === "enable" || - options.loadReason === "upgrade"; - - // Let's start :) - ContainerService.init(installation, options.loadReason); -}; - -exports.onUnload = function (reason) { - if (reason === "disable" || - reason === "downgrade" || - reason === "uninstall" || - reason === "upgrade") { - ContainerService.uninstall(reason); - } -}; diff --git a/package.json b/package.json index 5ea8d37..85177f0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "testpilot-containers", "title": "Containers Experiment", "description": "Containers works by isolating cookie jars using separate origin-attributes defined visually by colored ‘Container Tabs’. This add-on is a modified version of the containers feature for Firefox Test Pilot.", - "version": "2.4.1", + "version": "3.0.0", "author": "Andrea Marchesini, Luke Crouch and Jonathan Kingston", "bugs": { "url": "https://github.com/mozilla/testpilot-containers/issues" diff --git a/webextension/js/background/identityState.js b/webextension/js/background/identityState.js index 2bc6205..b43a2f6 100644 --- a/webextension/js/background/identityState.js +++ b/webextension/js/background/identityState.js @@ -63,7 +63,6 @@ const identityState = { const containers = await browser.contextualIdentities.query({}); for (const id in containers) { const container = containers[id]; - const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(container.cookieStoreId); await this.remapTabsIfMissing(container.cookieStoreId); const containerState = await this.storageArea.get(container.cookieStoreId); if (containerState.openTabs > 0) { @@ -118,14 +117,6 @@ const identityState = { async remapTabsIfMissing(cookieStoreId) { // We already know this cookieStoreId. const containerState = await this.storageArea.get(cookieStoreId) || this._createIdentityState(); -//REINSTATE THIS TODO, currently buggy -/* - if (containerState !== null) { - return; - } -*/ -const hiddenTabs = containerState.hiddenTabs; -//END REINSTATE await this.storageArea.set(cookieStoreId, containerState); await this.remapTabsFromUserContextId(cookieStoreId); @@ -141,14 +132,6 @@ const hiddenTabs = containerState.hiddenTabs; containerState.openTabs = tabsByContainer.length; await this.storageArea.set(cookieStoreId, containerState); }, -/*TODO check if used - remapTab(tab) { - const userContextId = this._getUserContextIdFromTab(tab); - if (userContextId) { - this.remapTabsFromUserContextId(userContextId); - } - }, -*/ _createIdentityState() { return { diff --git a/webextension/js/background/index.html b/webextension/js/background/index.html index 24a0eba..2a2924d 100644 --- a/webextension/js/background/index.html +++ b/webextension/js/background/index.html @@ -1,4 +1,7 @@ + + + + 51.0a1 + * + + + 3.1.0 + false + + + diff --git a/package.json b/package.json index 85177f0..fd09b9f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "testpilot-containers", "title": "Containers Experiment", "description": "Containers works by isolating cookie jars using separate origin-attributes defined visually by colored ‘Container Tabs’. This add-on is a modified version of the containers feature for Firefox Test Pilot.", - "version": "3.0.0", + "version": "3.1.0", "author": "Andrea Marchesini, Luke Crouch and Jonathan Kingston", "bugs": { "url": "https://github.com/mozilla/testpilot-containers/issues" @@ -24,17 +24,7 @@ "stylelint-order": "^0.3.0", "testpilot-metrics": "^2.1.0" }, - "engines": { - "firefox": ">=51.0" - }, - "permissions": { - "multiprocess": true - }, - "hasEmbeddedWebExtension": true, "homepage": "https://github.com/mozilla/testpilot-containers#readme", - "keywords": [ - "jetpack" - ], "license": "MPL-2.0", "main": "index.js", "repository": { diff --git a/webextension/js/.eslintrc.js b/webextension/js/.eslintrc.js index 620e9c6..801dad5 100644 --- a/webextension/js/.eslintrc.js +++ b/webextension/js/.eslintrc.js @@ -8,7 +8,6 @@ module.exports = { "backgroundLogic": true, "identityState": true, "messageHandler": true, - "tabPageCounter": true, - "themeManager": true + "tabPageCounter": true } }; diff --git a/webextension/js/background/index.html b/webextension/js/background/index.html index 2a2924d..cd0021e 100644 --- a/webextension/js/background/index.html +++ b/webextension/js/background/index.html @@ -12,7 +12,6 @@ "js/background/identityState.js", "js/background/messageHandler.js", "js/background/tabPageCounter.js", - "js/background/themeManager.js", "js/backdround/init.js" ] --> @@ -22,7 +21,6 @@ - diff --git a/webextension/js/background/messageHandler.js b/webextension/js/background/messageHandler.js index 5a73d2b..4f96891 100644 --- a/webextension/js/background/messageHandler.js +++ b/webextension/js/background/messageHandler.js @@ -80,9 +80,6 @@ const messageHandler = { const port = browser.runtime.connect(); port.onMessage.addListener(m => { switch (m.type) { - case "lightweight-theme-changed": - themeManager.update(m.message); - break; case "open-tab": backgroundLogic.openTab(m.message); break; diff --git a/webextension/js/background/themeManager.js b/webextension/js/background/themeManager.js deleted file mode 100644 index b77ade6..0000000 --- a/webextension/js/background/themeManager.js +++ /dev/null @@ -1,51 +0,0 @@ -const THEME_BUILD_DATE = 20170630; -const themeManager = { - existingTheme: null, - disabled: false, - async init() { - const browserInfo = await browser.runtime.getBrowserInfo(); - if (Number(browserInfo.buildID.substring(0, 8)) >= THEME_BUILD_DATE) { - this.disabled = true; - } else { - this.check(); - } - }, - setPopupIcon(theme) { - if (this.disabled) { - return; - } - let icons = { - 16: "img/container-site-d-24.png", - 32: "img/container-site-d-48.png" - }; - if (theme === "firefox-compact-dark@mozilla.org") { - icons = { - 16: "img/container-site-w-24.png", - 32: "img/container-site-w-48.png" - }; - } - browser.browserAction.setIcon({ - path: icons - }); - }, - check() { - if (this.disabled) { - return; - } - browser.runtime.sendMessage({ - method: "getTheme" - }).then((theme) => { - this.update(theme); - }).catch(() => { - throw new Error("Unable to get theme"); - }); - }, - update(theme) { - if (this.existingTheme !== theme) { - this.setPopupIcon(theme); - this.existingTheme = theme; - } - } -}; - -themeManager.init(); diff --git a/webextension/js/popup.js b/webextension/js/popup.js index b658e8c..a68f19a 100644 --- a/webextension/js/popup.js +++ b/webextension/js/popup.js @@ -150,13 +150,18 @@ const Logic = { }, async identity(cookieStoreId) { - const identity = await browser.contextualIdentities.get(cookieStoreId); - return identity || { + const defaultContainer = { name: "Default", cookieStoreId, icon: "default-tab", color: "default-tab" }; + // Handle old style rejection with null and also Promise.reject new style + try { + return await browser.contextualIdentities.get(cookieStoreId) || defaultContainer; + } catch(e) { + return defaultContainer; + } }, addEnterHandler(element, handler) { diff --git a/webextension/manifest.json b/webextension/manifest.json index 489cd1f..1c080b8 100644 --- a/webextension/manifest.json +++ b/webextension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Containers Experiment", - "version": "3.0.0", + "version": "3.1.0", "description": "Containers works by isolating cookie jars using separate origin-attributes defined visually by colored ‘Container Tabs’. This add-on is a modified version of the containers feature for Firefox Test Pilot.", "icons": { From 57a31f7f97c699b35566c7a880e5febf758d497e Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 15 Aug 2017 17:30:27 +0100 Subject: [PATCH 10/10] Remove legacy telemetry code as non functional now --- .eslintignore | 2 - .eslintrc.js | 7 +- README.md | 22 +- bootstrap.js | 22 +- lib/shield/event-target.js | 55 --- lib/shield/index.js | 428 ------------------ lib/testpilot/experiment.js | 95 ---- package.json | 6 +- study.js | 40 -- testpilot-metrics.js | 336 -------------- webextension/js/background/assignManager.js | 12 - webextension/js/background/backgroundLogic.js | 65 --- webextension/js/background/messageHandler.js | 6 - webextension/js/background/tabPageCounter.js | 10 - webextension/js/confirm-page.js | 8 - webextension/js/popup.js | 24 +- 16 files changed, 32 insertions(+), 1106 deletions(-) delete mode 100644 lib/shield/event-target.js delete mode 100644 lib/shield/index.js delete mode 100644 lib/testpilot/experiment.js delete mode 100644 study.js delete mode 100644 testpilot-metrics.js diff --git a/.eslintignore b/.eslintignore index 4ced8a0..9f1953c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1 @@ -testpilot-metrics.js -lib/shield/*.js lib/testpilot/*.js diff --git a/.eslintrc.js b/.eslintrc.js index d9f7270..1b0e906 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -13,7 +13,12 @@ module.exports = { "CustomizableUI": true, "CustomizableWidgets": true, "SessionStore": true, - "Services": true + "Services": true, + "Components": true, + "XPCOMUtils": true, + "OS": true, + "ADDON_UNINSTALL": true, + "ADDON_DISABLE": true }, "plugins": [ "promise", diff --git a/README.md b/README.md index 027dc58..8ae8c43 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,17 @@ Release & Beta channels do not allow un-signed add-ons, even with the DevPrefs. 5. Click the gear, and select "Install Add-on From File..." 6. Select the `.xpi` file +#### Correct prefs + +Whilst this is still using legacy code to test you will need the following in your profile: + + +Change the following prefs in about:config: + +- extensions.legacy.enabled = true +- xpinstall.signatures.required = false + + #### Run the TxP experiment with `jpm` 1. `git clone git@github.com:mozilla/testpilot-containers.git` @@ -49,23 +60,12 @@ Release & Beta channels do not allow un-signed add-ons, even with the DevPrefs. Check out the [Browser Toolbox](https://developer.mozilla.org/en-US/docs/Tools/Browser_Toolbox) for more information about debugging add-on code. -#### Run the shield study with `shield` - -1. `git clone git@github.com:mozilla/testpilot-containers.git` -2. `cd testpilot-containers` -3. `npm install` -4. `npm install -g shield-study-cli` -5. `shield run . -- --binary Nightly` - ### Building .xpi To build a local testpilot-containers.xpi, use the plain [`jpm xpi`](https://developer.mozilla.org/en-US/Add-ons/SDK/Tools/jpm#jpm_xpi) command, or run `npm run build`. -#### Building a shield .xpi -To build a local shield-study-containers.xpi, run `npm run build-shield`. - ### Signing an .xpi To sign an .xpi, use [`jpm diff --git a/bootstrap.js b/bootstrap.js index 9fcf037..64e89f0 100644 --- a/bootstrap.js +++ b/bootstrap.js @@ -22,12 +22,11 @@ const PREFS = [ type: "bool" }, ]; -const Cc = Components.classes; const Ci = Components.interfaces; const Cu = Components.utils; Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); -const { TextDecoder, TextEncoder } = Cu.import('resource://gre/modules/commonjs/toolkit/loader.js', {}); +const { TextDecoder, TextEncoder } = Cu.import("resource://gre/modules/commonjs/toolkit/loader.js", {}); XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); @@ -36,7 +35,7 @@ const JETPACK_DIR_BASENAME = "jetpack"; const EXTENSION_ID = "@testpilot-containers"; function filename() { - let storeFile = Services.dirsvc.get("ProfD", Ci.nsIFile); + const storeFile = Services.dirsvc.get("ProfD", Ci.nsIFile); storeFile.append(JETPACK_DIR_BASENAME); storeFile.append(EXTENSION_ID); storeFile.append("simple-storage"); @@ -46,7 +45,7 @@ function filename() { async function getConfig() { const bytes = await OS.File.read(filename()); - let raw = new TextDecoder().decode(bytes) || ""; + const raw = new TextDecoder().decode(bytes) || ""; let savedConfig = {savedConfiguration: {}}; if (raw) { savedConfig = JSON.parse(raw); @@ -69,7 +68,7 @@ async function initConfig() { }); } const serialized = JSON.stringify(savedConfig); - let bytes = new TextEncoder().encode(serialized) || ""; + const bytes = new TextEncoder().encode(serialized) || ""; await OS.File.writeAtomic(filename(), bytes, { }); } @@ -83,14 +82,16 @@ function setPrefs() { }); } +// eslint-disable-next-line no-unused-vars async function install() { await initConfig(); setPrefs(); } +// eslint-disable-next-line no-unused-vars async function uninstall(aData, aReason) { - if (aReason == ADDON_UNINSTALL - || aReason == ADDON_DISABLE) { + if (aReason === ADDON_UNINSTALL + || aReason === ADDON_DISABLE) { const config = await getConfig(); const storedPrefs = config.savedConfiguration.prefs; PREFS.forEach((pref) => { @@ -105,14 +106,15 @@ async function uninstall(aData, aReason) { } } +// eslint-disable-next-line no-unused-vars function startup({webExtension}) { // Reset prefs that may have changed, or are legacy setPrefs(); // Start the embedded webextension. - webExtension.startup().then(api => { - }); + webExtension.startup(); } -function shutdown(data) { +// eslint-disable-next-line no-unused-vars +function shutdown() { } diff --git a/lib/shield/event-target.js b/lib/shield/event-target.js deleted file mode 100644 index 4335d8c..0000000 --- a/lib/shield/event-target.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Drop-in replacement for {@link external:sdk/event/target.EventTarget} for use - * with es6 classes. - * @module event-target - * @author Martin Giger - * @license MPL-2.0 - */ - /** - * An SDK class that add event reqistration methods - * @external sdk/event/target - * @requires sdk/event/target - */ -/** - * @class EventTarget - * @memberof external:sdk/event/target - * @see {@link https://developer.mozilla.org/en-US/Add-ons/SDK/Low-Level_APIs/event_target#EventTarget} - */ - -// slightly modified from: https://raw.githubusercontent.com/freaktechnik/justintv-stream-notifications/master/lib/event-target.js - -"use strict"; - -const { on, once, off, setListeners } = require("sdk/event/core"); - -/* istanbul ignore next */ -/** - * @class - */ -class EventTarget { - constructor(options) { - setListeners(this, options); - } - - on(...args) { - on(this, ...args); - return this; - } - - once(...args) { - once(this, ...args); - return this; - } - - off(...args) { - off(this, ...args); - return this; - } - - removeListener(...args) { - off(this, ...args); - return this; - } -} - -exports.EventTarget = EventTarget; diff --git a/lib/shield/index.js b/lib/shield/index.js deleted file mode 100644 index 9d4ec95..0000000 --- a/lib/shield/index.js +++ /dev/null @@ -1,428 +0,0 @@ -"use strict"; - -// Chrome privileged -const {Cu} = require("chrome"); -const { Services } = Cu.import("resource://gre/modules/Services.jsm"); -const { TelemetryController } = Cu.import("resource://gre/modules/TelemetryController.jsm"); -const CID = Cu.import("resource://gre/modules/ClientID.jsm"); - -// sdk -const { merge } = require("sdk/util/object"); -const querystring = require("sdk/querystring"); -const { prefs } = require("sdk/simple-prefs"); -const prefSvc = require("sdk/preferences/service"); -const { setInterval } = require("sdk/timers"); -const tabs = require("sdk/tabs"); -const { URL } = require("sdk/url"); - -const { EventTarget } = require("./event-target"); -const { emit } = require("sdk/event/core"); -const self = require("sdk/self"); - -const DAY = 86400*1000; - -// ongoing within-addon fuses / timers -let lastDailyPing = Date.now(); - -/* Functional, self-contained utils */ - -// equal probability choices from a list "choices" -function chooseVariation(choices,rng=Math.random()) { - let l = choices.length; - return choices[Math.floor(l*Math.random())]; -} - -function dateToUTC(date) { - return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds()); -} - -function generateTelemetryIdIfNeeded() { - let id = TelemetryController.clientID; - /* istanbul ignore next */ - if (id == undefined) { - return CID.ClientIDImpl._doLoadClientID() - } else { - return Promise.resolve(id) - } -} - -function userId () { - return prefSvc.get("toolkit.telemetry.cachedClientID","unknown"); -} - -var Reporter = new EventTarget().on("report", - (d) => prefSvc.get('shield.debug') && console.log("report",d) -); - -function report(data, src="addon", bucket="shield-study") { - data = merge({}, data , { - study_version: self.version, - about: { - _src: src, - _v: 2 - } - }); - if (prefSvc.get('shield.testing')) data.testing = true - - emit(Reporter, "report", data); - let telOptions = {addClientId: true, addEnvironment: true} - return TelemetryController.submitExternalPing(bucket, data, telOptions); -} - -function survey (url, queryArgs={}) { - if (! url) return - - let U = new URL(url); - let q = U.search; - if (q) { - url = U.href.split(q)[0]; - q = querystring.parse(querystring.unescape(q.slice(1))); - } else { - q = {}; - } - // get user info. - let newArgs = merge({}, - q, - queryArgs - ); - let searchstring = querystring.stringify(newArgs); - url = url + "?" + searchstring; - return url; -} - - -function setOrGetFirstrun () { - let firstrun = prefs["shield.firstrun"]; - if (firstrun === undefined) { - firstrun = prefs["shield.firstrun"] = String(dateToUTC(new Date())) // in utc, user set - } - return Number(firstrun) -} - -function reuseVariation (choices) { - return prefs["shield.variation"]; -} - -function setVariation (choice) { - prefs["shield.variation"] = choice - return choice -} - -function die (addonId=self.id) { - /* istanbul ignore else */ - if (prefSvc.get("shield.fakedie")) return; - /* istanbul ignore next */ - require("sdk/addon/installer").uninstall(addonId); -} - -// TODO: GRL vulnerable to clock time issues #1 -function expired (xconfig, now = Date.now() ) { - return ((now - Number(xconfig.firstrun))/ DAY) > xconfig.days; -} - -function resetShieldPrefs () { - delete prefs['shield.firstrun']; - delete prefs['shield.variation']; -} - -function cleanup () { - prefSvc.keys(`extensions.${self.preferencesBranch}`).forEach ( - (p) => { - delete prefs[p]; - }) -} - -function telemetrySubset (xconfig) { - return { - study_name: xconfig.name, - branch: xconfig.variation, - } -} - -class Study extends EventTarget { - constructor (config) { - super(); - this.config = merge({ - name: self.addonId, - variations: {'observe-only': () => {}}, - surveyUrls: {}, - days: 7 - },config); - - this.config.firstrun = setOrGetFirstrun(); - - let variation = reuseVariation(); - if (variation === undefined) { - variation = this.decideVariation(); - if (!(variation in this.config.variations)) { - // chaijs doesn't think this is an instanceof Error - // freaktechnik and gregglind debugged for a while. - // sdk errors might not be 'Errors' or chai is wack, who knows. - // https://dxr.mozilla.org/mozilla-central/search?q=regexp%3AError%5Cs%3F(%3A%7C%3D)+path%3Aaddon-sdk%2Fsource%2F&redirect=false would list - throw new Error("Study Error: chosen variation must be in config.variations") - } - setVariation(variation); - } - this.config.variation = variation; - - this.flags = { - ineligibleDie: undefined - }; - this.states = []; - // all these work, but could be cleaner. I hate the `bind` stuff. - this.on( - "change", (function (newstate) { - prefSvc.get('shield.debug') && console.log(newstate, this.states); - this.states.push(newstate); - emit(this, newstate); // could have checks here. - }).bind(this) - ) - this.on( - "starting", (function () { - this.changeState("modifying"); - }).bind(this) - ) - this.on( - "maybe-installing", (function () { - if (!this.isEligible()) { - this.changeState("ineligible-die"); - } else { - this.changeState("installed") - } - }).bind(this) - ) - this.on( - "ineligible-die", (function () { - try {this.whenIneligible()} catch (err) {/*ok*/} finally { /*ok*/ } - this.flags.ineligibleDie = true; - this.report(merge({}, telemetrySubset(this.config), {study_state: "ineligible"}), "shield"); - this.final(); - die(); - }).bind(this) - ) - this.on( - "installed", (function () { - try {this.whenInstalled()} catch (err) {/*ok*/} finally { /*ok*/ } - this.report(merge({}, telemetrySubset(this.config), {study_state: "install"}), "shield"); - this.changeState("modifying"); - }).bind(this) - ) - this.on( - "modifying", (function () { - var mybranchname = this.variation; - this.config.variations[mybranchname](); // do the effect - this.changeState("running"); - }).bind(this) - ) - this.on( // the one 'many' - "running", (function () { - // report success - this.report(merge({}, telemetrySubset(this.config), {study_state: "running"}), "shield"); - this.final(); - }).bind(this) - ) - this.on( - "normal-shutdown", (function () { - this.flags.dying = true; - this.report(merge({}, telemetrySubset(this.config), {study_state: "shutdown"}), "shield"); - this.final(); - }).bind(this) - ) - this.on( - "end-of-study", (function () { - if (this.flags.expired) { // safe to call multiple times - this.final(); - return; - } else { - // first time seen. - this.flags.expired = true; - try {this.whenComplete()} catch (err) { /*ok*/ } finally { /*ok*/ } - this.report(merge({}, telemetrySubset(this.config) ,{study_state: "end-of-study"}), "shield"); - // survey for end of study - let that = this; - generateTelemetryIdIfNeeded().then(()=>that.showSurvey("end-of-study")); - try {this.cleanup()} catch (err) {/*ok*/} finally { /*ok*/ } - this.final(); - die(); - } - }).bind(this) - ) - this.on( - "user-uninstall-disable", (function () { - if (this.flags.dying) { - this.final(); - return; - } - this.flags.dying = true; - this.report(merge({}, telemetrySubset(this.config), {study_state: "user-ended-study"}), "shield"); - let that = this; - generateTelemetryIdIfNeeded().then(()=>that.showSurvey("user-ended-study")); - try {this.cleanup()} catch (err) {/*ok*/} finally { /*ok*/ } - this.final(); - die(); - }).bind(this) - ) - } - - get state () { - let n = this.states.length; - return n ? this.states[n-1] : undefined - } - - get variation () { - return this.config.variation; - } - - get firstrun () { - return this.config.firstrun; - } - - dieIfExpired () { - let xconfig = this.config; - if (expired(xconfig)) { - emit(this, "change", "end-of-study"); - return true - } else { - return false - } - } - - alivenessPulse (last=lastDailyPing) { - // check for new day, phone home if true. - let t = Date.now(); - if ((t - last) >= DAY) { - lastDailyPing = t; - // phone home - emit(this,"change","running"); - } - // check expiration, and die with report if needed - return this.dieIfExpired(); - } - - changeState (newstate) { - emit(this,'change', newstate); - } - - final () { - emit(this,'final', {}); - } - - startup (reason) { - // https://developer.mozilla.org/en-US/Add-ons/SDK/Tutorials/Listening_for_load_and_unload - - // check expiry first, before anything, quit and die if so - - // check once, right away, short circuit both install and startup - // to prevent modifications from happening. - if (this.dieIfExpired()) return this - - switch (reason) { - case "install": - emit(this, "change", "maybe-installing"); - break; - - case "enable": - case "startup": - case "upgrade": - case "downgrade": - emit(this, "change", "starting"); - } - - if (! this._pulseTimer) this._pulseTimer = setInterval(this.alivenessPulse.bind(this), 5*60*1000 /*5 minutes */) - return this; - } - - shutdown (reason) { - // https://developer.mozilla.org/en-US/Add-ons/SDK/Tutorials/Listening_for_load_and_unload - if (this.flags.ineligibleDie || - this.flags.expired || - this.flags.dying - ) { return this } // special cases. - - switch (reason) { - case "uninstall": - case "disable": - emit(this, "change", "user-uninstall-disable"); - break; - - // 5. usual end of session. - case "shutdown": - case "upgrade": - case "downgrade": - emit(this, "change", "normal-shutdown") - break; - } - return this; - } - - cleanup () { - // do the simple prefs and simplestorage cleanup - // extend by extension - resetShieldPrefs(); - cleanup(); - } - - isEligible () { - return true; - } - - whenIneligible () { - // empty function unless overrided - } - - whenInstalled () { - // empty unless overrided - } - - whenComplete () { - // when the study expires - } - - /** - * equal choice from varations, by default. override to get unequal - */ - decideVariation (rng=Math.random()) { - return chooseVariation(Object.keys(this.config.variations), rng); - } - - get surveyQueryArgs () { - return { - variation: this.variation, - xname: this.config.name, - who: userId(), - updateChannel: Services.appinfo.defaultUpdateChannel, - fxVersion: Services.appinfo.version, - } - } - - showSurvey(reason) { - let partial = this.config.surveyUrls[reason]; - - let queryArgs = this.surveyQueryArgs; - queryArgs.reason = reason; - if (partial) { - let url = survey(partial, queryArgs); - tabs.open(url); - return url - } else { - return - } - } - - report () { // convenience only - return report.apply(null, arguments); - } -} - -module.exports = { - chooseVariation: chooseVariation, - die: die, - expired: expired, - generateTelemetryIdIfNeeded: generateTelemetryIdIfNeeded, - report: report, - Reporter: Reporter, - resetShieldPrefs: resetShieldPrefs, - Study: Study, - cleanup: cleanup, - survey: survey -} diff --git a/lib/testpilot/experiment.js b/lib/testpilot/experiment.js deleted file mode 100644 index 1b4e98f..0000000 --- a/lib/testpilot/experiment.js +++ /dev/null @@ -1,95 +0,0 @@ -const { AddonManager } = require('resource://gre/modules/AddonManager.jsm'); -const { ClientID } = require('resource://gre/modules/ClientID.jsm'); -const Events = require('sdk/system/events'); -const { Services } = require('resource://gre/modules/Services.jsm'); -const { storage } = require('sdk/simple-storage'); -const { - TelemetryController -} = require('resource://gre/modules/TelemetryController.jsm'); -const { Request } = require('sdk/request'); - - -const EVENT_SEND_METRIC = 'testpilot::send-metric'; -const startTime = (Services.startup.getStartupInfo().process); - -function makeTimestamp(timestamp) { - return Math.round((timestamp - startTime) / 1000); -} - -function experimentPing(event) { - const timestamp = new Date(); - const { subject, data } = event; - let parsed; - try { - parsed = JSON.parse(data); - } catch (err) { - // eslint-disable-next-line no-console - return console.error(`Dropping bad metrics packet: ${err}`); - } - - AddonManager.getAddonByID(subject, addon => { - const payload = { - test: subject, - version: addon.version, - timestamp: makeTimestamp(timestamp), - variants: storage.experimentVariants && - subject in storage.experimentVariants - ? storage.experimentVariants[subject] - : null, - payload: parsed - }; - TelemetryController.submitExternalPing('testpilottest', payload, { - addClientId: true, - addEnvironment: true - }); - - // TODO: DRY up this ping centre code here and in lib/Telemetry. - const pcPing = TelemetryController.getCurrentPingData(); - pcPing.type = 'testpilot'; - pcPing.payload = payload; - const pcPayload = { - // 'method' is used by testpilot-metrics library. - // 'event' was used before that library existed. - event_type: parsed.event || parsed.method, - client_time: makeTimestamp(parsed.timestamp || timestamp), - addon_id: subject, - addon_version: addon.version, - firefox_version: pcPing.environment.build.version, - os_name: pcPing.environment.system.os.name, - os_version: pcPing.environment.system.os.version, - locale: pcPing.environment.settings.locale, - // Note: these two keys are normally inserted by the ping-centre client. - client_id: ClientID.getCachedClientID(), - topic: 'testpilot' - }; - // Add any other extra top-level keys = require(the payload, possibly including - // 'object' or 'category', among others. - Object.keys(parsed).forEach(f => { - // Ignore the keys we've already added to `pcPayload`. - const ignored = ['event', 'method', 'timestamp']; - if (!ignored.includes(f)) { - pcPayload[f] = parsed[f]; - } - }); - - const req = new Request({ - url: 'https://tiles.services.mozilla.com/v3/links/ping-centre', - contentType: 'application/json', - content: JSON.stringify(pcPayload) - }); - req.post(); - }); -} - -function Experiment() { - // If the user has @testpilot-addon, it already bound - // experimentPing to testpilot::send-metric, - // so we don't need to bind this one - AddonManager.getAddonByID('@testpilot-addon', addon => { - if (!addon) { - Events.on(EVENT_SEND_METRIC, experimentPing); - } - }); -} - -module.exports = Experiment; diff --git a/package.json b/package.json index fd09b9f..e50ec0d 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,9 @@ "jpm": "^1.2.2", "json": "^9.0.6", "npm-run-all": "^4.0.0", - "shield-studies-addon-utils": "^2.0.0", "stylelint": "^7.9.0", "stylelint-config-standard": "^16.0.0", - "stylelint-order": "^0.3.0", - "testpilot-metrics": "^2.1.0" + "stylelint-order": "^0.3.0" }, "homepage": "https://github.com/mozilla/testpilot-containers#readme", "license": "MPL-2.0", @@ -33,7 +31,6 @@ }, "scripts": { "build": "npm test && jpm xpi", - "build-shield": "npm test && npm run package-shield", "deploy": "deploy-txp", "lint": "npm-run-all lint:*", "lint:addon": "addons-linter webextension --self-hosted", @@ -41,7 +38,6 @@ "lint:html": "htmllint webextension/*.html", "lint:js": "eslint .", "package": "npm run build && mv testpilot-containers.xpi addon.xpi", - "package-shield": "./node_modules/.bin/json -I -f package.json -e 'this.name=\"shield-study-containers\"' && jpm xpi && ./node_modules/.bin/json -I -f package.json -e 'this.name=\"testpilot-containers\"'", "test": "npm run lint" }, "updateURL": "https://testpilot.firefox.com/files/@testpilot-containers/updates.json" diff --git a/study.js b/study.js deleted file mode 100644 index df59555..0000000 --- a/study.js +++ /dev/null @@ -1,40 +0,0 @@ -/* 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/. */ - -const self = require("sdk/self"); -const { when: unload } = require("sdk/system/unload"); - -const shield = require("./lib/shield/index"); - -const surveyUrl = "https://www.surveygizmo.com/s3/3621810/shield-txp-containers"; - -const studyConfig = { - name: self.addonId, - days: 28, - surveyUrls: { - "end-of-study": surveyUrl, - "user-ended-study": surveyUrl, - ineligible: null, - }, - variations: { - "control": () => {}, - "securityOnboarding": () => {} - } -}; - -class ContainersStudy extends shield.Study { - isEligible () { - // If the user already has testpilot-containers extension, they are in the - // Test Pilot experiment, so exclude them. - return super.isEligible(); - } -} - -const thisStudy = new ContainersStudy(studyConfig); - -if (self.id === "@shield-study-containers") { - unload((reason) => thisStudy.shutdown(reason)); -} - -exports.study = thisStudy; diff --git a/testpilot-metrics.js b/testpilot-metrics.js deleted file mode 100644 index 2914884..0000000 --- a/testpilot-metrics.js +++ /dev/null @@ -1,336 +0,0 @@ -// 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/. -const Experiment = require('./lib/testpilot/experiment'); - -const experiment = new Experiment(); - -/** - * Class that represents a metrics event broker. Events are sent to Google - * Analytics if the `tid` parameter is set. Events are sent to Mozilla's - * data pipeline via the Test Pilot add-on. No metrics code changes are - * needed when the experiment is added to or removed from Test Pilot. - * @constructor - * @param {string} $0.id - addon ID, e.g. '@testpilot-addon'. See https://mdn.io/add_on_id. - * @param {string} $0.version - addon version, e.g. '1.0.2'. - * @param {string} [$0.uid] - unique identifier for a specific instance of an addon. - * Optional, but required to send events to Google Analytics. Sent to Google Analytics - * but not Mozilla services. - * @param {string} [$0.tid] - Google Analytics tracking ID. Optional, but required - * to send events to Google Analytics. - * @param {string} [$0.type=webextension] - addon type. one of: 'webextension', - * 'sdk', 'bootstrapped'. - * @param {boolean} [$0.debug=false] - if true, enables logging. Note that this - * value can be changed on a running instance, by modifying its `debug` property. - * @throws {SyntaxError} If the required properties are missing, or if the - * 'type' property is unrecognized. - * @throws {Error} if initializing the transports fails. - */ -function Metrics({id, version, uid, tid = null, type = 'webextension', debug = false}) { - if (!id) { - throw new SyntaxError(`'id' property is required.`); - } else if (!version) { - throw new SyntaxError(`'version' property is required.`); - } else if (tid && !uid) { - throw new SyntaxError(`'uid' property is required to send events to Google Analytics.`); - } - - if (!['webextension', 'sdk', 'bootstrapped'].includes(type)) { - throw new SyntaxError(`'type' property must be one of: 'webextension', 'sdk', or 'bootstrapped'`); - } - Object.assign(this, {id, uid, version, tid, type, debug}); - - // The test pilot add-on uses its own nsIObserverService topic for sending - // pings to Telemetry. Otherwise, the topic is based on add-on type. - if (id === '@testpilot-addon') { - this.topic = 'testpilot'; - } else if (type === 'webextension') { - this.topic = 'testpilot-telemetry'; - } else { - this.topic = 'testpilottest'; - } - - // NOTE: order is important here. _initTransports uses console.log, which may - // not be available before _initConsole has run. - this._initConsole(); - this._initTransports(); - - this.sendEvent = this.sendEvent.bind(this); - - this._log(`Initialized topic to ${this.topic}`); - if (!tid) { - this._log(`Google Analytics disabled: 'tid' value not passed to constructor.`); - } else { - this._log(`Google Analytics enabled for Tracking ID ${tid}.`); - } - this._log('Constructor finished successfully.'); -} -Metrics.prototype = { - /** - * Sends an event to the Mozilla data pipeline (and Google Analytics, if - * a `tid` was passed to the constructor). Note: to avoid breaking callers, - * if sending the event fails, no Errors will be thrown. Instead, the message - * will be silently dropped, and, if debug mode is enabled, an error will be - * logged to the Browser Console. - * - * If you want to pass extra fields to GA, or use a GA hit type other than - * `Event`, you can transform the output data object yourself using the - * `transform` parameter. You will need to add Custom Dimensions to GA for any - * extra fields: https://support.google.com/analytics/answer/2709828. Note - * that, by convention, the `variant` argument is mapped to the first Custom - * Dimension (`cd1`) when constructing the GA Event hit. - * - * Note: the data object format is currently different for each experiment, - * and should be defined based on the result of conversations with the Mozilla - * data team. - * - * A suggested default format is: - * @param {string} [$0.method] - What is happening? e.g. `click` - * @param {string} [$0.object] - What is being affected? e.g. `home-button-1` - * @param {string} [$0.category=interactions] - If you want to add a category - * for easy reporting later. e.g. `mainmenu` - * @param {string} [$0.variant=null] - An identifying string if you're running - * different variants. e.g. `cohort-A` - * @param {function} [transform] - Transform function used to alter the - * parameters sent to GA. The `transform` function signature is - * `transform(input, output)`, where `input` is the object passed to - * `sendEvent` (excluding `transform`), and `output` is the default GA - * object generated by the `_gaTransform` method. The `transform` function - * should return an object whose keys are GA Measurement Protocol parameters. - * The returned object will be form encoded and sent to GA. - */ - sendEvent: function(params = {}, transform) { - const args = this._clone(params); - args.object = params.object || null; - args.category = params.category || 'interactions'; - args.variant = params.variant || null; - - this._log(`sendEvent called with method = ${args.method}, object = ${args.object}, category = ${args.category}, variant = ${args.variant}.`); - - const clientData = this._clone(args); - const gaData = this._clone(args); - if (!clientData) { - this._error(`Unable to process data object. Dropping packet.`); - return; - } - this._sendToClient(clientData); - - if (this.tid && this.uid) { - const defaultEvent = this._gaTransform(gaData); - - let userEvent; - if (transform) { - userEvent = transform.call(null, gaData, defaultEvent); - } - - this._gaSend(userEvent || defaultEvent); - } - }, - - /** - * Clone a data object by serializing / deserializing it. - * @private - * @param {object} o - Object to be cloned. - * @returns A clone of the object, or `null` if cloning failed. - */ - _clone: function(o) { - let cloned; - try { - cloned = JSON.parse(JSON.stringify(o)); - } catch (ex) { - this._error(`Unable to clone object: ${ex}.`); - return null; - } - return cloned; - }, - - /** - * Sends an event to the Mozilla data pipeline via the Test Pilot add-on. - * Uses BroadcastChannel for WebExtensions, and nsIObserverService for other - * add-on types. - * @private - * @param {object} params - Entire object sent to `sendEvent`. - */ - _sendToClient: function(params) { - if (this.type === 'webextension') { - this._channel.postMessage(params); - this._log(`Sent client message via postMessage: ${params}`); - } else { - let stringified; - - try { - stringified = JSON.stringify(params); - } catch(ex) { - this._error(`Unable to serialize metrics event: ${ex}`); - return; - } - - const subject = { - wrappedJSObject: { - observersModuleSubjectWrapper: true, - object: this.id - } - }; - - try { - Services.obs.notifyObservers(subject, 'testpilot::send-metric', stringified); - this._log(`Sent client message via nsIObserverService: ${stringified}`); - } catch (ex) { - this._error(`Failed to send nsIObserver client ping: ${ex}`); - return; - } - } - }, - - /** - * Transforms `sendEvent()` arguments into a Google Analytics `Event` hit. - * @private - * @param {string} method - see `sendEvent` docs - * @param {string} [object] - see `sendEvent` docs - * @param {string} category - see `sendEvent` docs. Note that `category` is - * required here, assuming the default value was filled in by `sendEvent()`. - * @param {string} variant - see `sendEvent` docs. Note that `variant` is - * required here, assuming the default value was filled in by `sendEvent()`. - */ - _gaTransform: function({method, object, category, variant}) { - const data = { - v: 1, - an: this.id, - av: this.version, - tid: this.tid, - uid: this.uid, - t: 'event', - ec: category, - ea: method - }; - if (object) { - data.el = object; - } - if (variant) { - data.cd1 = variant; - } - return data; - }, - - /** - * Encodes and sends an event message to Google Analytics. - * @private - * @param {object} msg - An object whose keys correspond to parameters in the - * Google Analytics Measurement Protocol. - */ - _gaSend: function(msg) { - const encoded = this._formEncode(msg); - const GA_URL = 'https://ssl.google-analytics.com/collect'; - if (this.type === 'webextension') { - navigator.sendBeacon(GA_URL, encoded); - } else { - // SDK and bootstrapped types might not have a window reference, so get - // the sendBeacon DOM API from the hidden window. - Services.appShell.hiddenDOMWindow.navigator.sendBeacon(GA_URL, encoded); - } - this._log(`Sent GA message: ${encoded}`); - }, - - /** - * URL encodes an object. Encodes spaces as '%20', not '+', following the - * GA docs. - * - * @example - * // returns 'a=b&foo=b%20ar' - * metrics._formEncode({a: 'b', foo: 'b ar'}); - * @private - * @param {Object} obj - Any JS object - * @returns {string} - */ - _formEncode: function(obj) { - const params = []; - if (!obj) { return ''; } - Object.keys(obj).forEach(item => { - const encoded = encodeURIComponent(item) + '=' + encodeURIComponent(obj[item]); - params.push(encoded); - }); - return params.join('&'); - }, - - /** - * Initializes transports used for sending messages. For WebExtensions, - * creates a `BroadcastChannel` (transport for client pings). WebExtensions - * use navigator.sendBeacon for GA transport, and they always have access - * to DOM APIs, so there's no setup work required. For other types, loads - * `Services.jsm`, which exposes the nsIObserverService (transport for client - * pings), and exposes the navigator.sendBeacon API (GA transport) via the - * appShell service's hidden window. - * @private - * @throws {Error} if transport setup unexpectedly fails - */ - _initTransports: function() { - if (this.type === 'webextension') { - try { - this._channel = new BroadcastChannel(this.topic); - } catch(ex) { - throw new Error(`Unable to create BroadcastChannel: ${ex}`); - } - } else if (this.type === 'sdk') { - try { - const { Cu } = require('chrome'); - Cu.import('resource://gre/modules/Services.jsm'); - } catch(ex) { - throw new Error(`Unable to load Services.jsm: ${ex}`); - } - } else { /* this.type === 'bootstrapped' */ - try { - Components.utils.import('resource://gre/modules/Services.jsm'); - } catch(ex) { - throw new Error(`Unable to load Services.jsm: ${ex}`); - } - } - this._log('Successfully initialized transports.'); - }, - - /** - * Initializes a console for 'bootstrapped' add-ons. - * @private - */ - _initConsole: function() { - if (this.type === 'bootstrapped') { - try { - Components.utils.import('resource://gre/modules/Console.jsm'); - this._log('Successfully initialized console.'); - } catch(ex) { - throw new Error(`Unable to initialize console: ${ex}`); - } - } - }, - - /** - * Logs messages to the console. Only enabled if `this.debug` is truthy. - * @private - * @param {string} msg - A message - */ - _log: function(msg) { - if (this.debug) { - console.log(msg); // eslint-disable-line no-console - } - }, - - /** - * Logs errors to the console. Only enabled if `this.debug` is truthy. - * @private - * @param {string} msg - An error message - */ - _error: function(msg) { - if (this.debug) { - console.error(msg); // eslint-disable-line no-console - } - } -}; - -// WebExtensions don't support CommonJS module style, so 'module' might not be -// defined. -if (typeof module !== 'undefined') { - module.exports = Metrics; -} - -// Export the Metrics constructor in Gecko JSM style, for legacy addons -// that use the JSM loader. See also: https://mdn.io/jsm/using -const EXPORTED_SYMBOLS = ['Metrics']; // eslint-disable-line no-unused-vars diff --git a/webextension/js/background/assignManager.js b/webextension/js/background/assignManager.js index e356c6d..72d1dc2 100644 --- a/webextension/js/background/assignManager.js +++ b/webextension/js/background/assignManager.js @@ -234,10 +234,6 @@ const assignManager = { browser.tabs.sendMessage(tabId, { text: `Successfully ${actionName} site to always open in this container` }); - backgroundLogic.sendTelemetryPayload({ - event: `${actionName}-container-assignment`, - userContextId: userContextId, - }); const tab = await browser.tabs.get(tabId); this.calculateContextMenu(tab); }, @@ -298,15 +294,7 @@ const assignManager = { // If the user has explicitly checked "Never Ask Again" on the warning page we will send them straight there if (neverAsk) { browser.tabs.create({url, cookieStoreId, index}); - backgroundLogic.sendTelemetryPayload({ - event: "auto-reload-page-in-container", - userContextId: userContextId, - }); } else { - backgroundLogic.sendTelemetryPayload({ - event: "prompt-to-reload-page-in-container", - userContextId: userContextId, - }); let confirmUrl = `${loadPage}?url=${encodeURIComponent(url)}&cookieStoreId=${cookieStoreId}`; let currentCookieStoreId; if (currentUserContextId) { diff --git a/webextension/js/background/backgroundLogic.js b/webextension/js/background/backgroundLogic.js index 42ee382..b0d0355 100644 --- a/webextension/js/background/backgroundLogic.js +++ b/webextension/js/background/backgroundLogic.js @@ -26,11 +26,6 @@ const backgroundLogic = { }, async deleteContainer(userContextId) { - this.sendTelemetryPayload({ - event: "delete-container", - userContextId - }); - await this._closeTabs(userContextId); await browser.contextualIdentities.remove(this.cookieStoreId(userContextId)); assignManager.deleteContainer(userContextId); @@ -47,15 +42,8 @@ const backgroundLogic = { this.cookieStoreId(options.userContextId), options.params ); - this.sendTelemetryPayload({ - event: "edit-container", - userContextId: options.userContextId - }); } else { donePromise = browser.contextualIdentities.create(options.params); - this.sendTelemetryPayload({ - event: "add-container" - }); } await donePromise; browser.runtime.sendMessage({ @@ -67,18 +55,8 @@ const backgroundLogic = { let url = options.url || undefined; const userContextId = ("userContextId" in options) ? options.userContextId : 0; const active = ("nofocus" in options) ? options.nofocus : true; - const source = ("source" in options) ? options.source : null; const cookieStoreId = backgroundLogic.cookieStoreId(userContextId); - // Only send telemetry for tabs opened by UI - i.e., not via showTabs - if (source && userContextId) { - this.sendTelemetryPayload({ - "event": "open-tab", - "eventSource": source, - "userContextId": userContextId, - "clickedContainerTabCount": await identityState.containerTabCount(cookieStoreId) - }); - } // Autofocus url bar will happen in 54: https://bugzilla.mozilla.org/show_bug.cgi?id=1295072 // We can't open new tab pages, so open a blank tab. Used in tab un-hide @@ -131,12 +109,6 @@ const backgroundLogic = { return null; } - this.sendTelemetryPayload({ - "event": "move-tabs-to-window", - "userContextId": userContextId, - "clickedContainerTabCount": identityState.containerTabCount(userContextId), - }); - const list = await identityState._matchTabsByContainer(options.cookieStoreId); const containerState = await identityState.storageArea.get(options.cookieStoreId); @@ -199,13 +171,6 @@ const backgroundLogic = { }, async sortTabs() { - const containersCounts = identityState.containersCounts(); - this.sendTelemetryPayload({ - "event": "sort-tabs", - "shownContainersCount": containersCounts.shown, - "totalContainerTabsCount": await identityState.totalContainerTabsCount(), - "totalNonContainerTabsCount": await identityState.totalNonContainerTabsCount() - }); const windows = await browser.windows.getAll(); for (let window of windows) { // eslint-disable-line prefer-const // First the pinned tabs, then the normal ones. @@ -267,16 +232,6 @@ const backgroundLogic = { return null; } - const containersCounts = identityState.containersCounts(); - this.sendTelemetryPayload({ - "event": "hide-tabs", - "userContextId": userContextId, - "clickedContainerTabCount": identityState.containerTabCount(userContextId), - "shownContainersCount": containersCounts.shown, - "hiddenContainersCount": containersCounts.hidden, - "totalContainersCount": containersCounts.total - }); - const containerState = await identityState.storeHidden(options.cookieStoreId); await this._closeTabs(userContextId); return containerState; @@ -293,16 +248,6 @@ const backgroundLogic = { return null; } - const containersCounts = identityState.containersCounts(); - this.sendTelemetryPayload({ - "event": "show-tabs", - "userContextId": userContextId, - "clickedContainerTabCount": await identityState.containerTabCount(options.cookieStoreId), - "shownContainersCount": containersCounts.shown, - "hiddenContainersCount": containersCounts.hidden, - "totalContainersCount": containersCounts.total - }); - const promises = []; const containerState = await identityState.storageArea.get(options.cookieStoreId); @@ -322,16 +267,6 @@ const backgroundLogic = { return await identityState.storageArea.set(options.cookieStoreId, containerState); }, - - sendTelemetryPayload(message = {}) { - if (!message.event) { - throw new Error("Missing event name for telemetry"); - } - message.method = "sendTelemetryPayload"; - //TODO decide where this goes - // browser.runtime.sendMessage(message); - }, - cookieStoreId(userContextId) { return `firefox-container-${userContextId}`; }, diff --git a/webextension/js/background/messageHandler.js b/webextension/js/background/messageHandler.js index 4f96891..d675b19 100644 --- a/webextension/js/background/messageHandler.js +++ b/webextension/js/background/messageHandler.js @@ -38,9 +38,6 @@ const messageHandler = { return assignManager._setOrRemoveAssignment(tab.id, m.url, m.userContextId, m.value); }); break; - case "sendTelemetryPayload": - // TODO - break; case "sortTabs": backgroundLogic.sortTabs(); break; @@ -53,9 +50,6 @@ const messageHandler = { case "checkIncompatibleAddons": // TODO break; - case "getShieldStudyVariation": - // TODO - break; case "moveTabsToWindow": response = backgroundLogic.moveTabsToWindow({ cookieStoreId: m.cookieStoreId diff --git a/webextension/js/background/tabPageCounter.js b/webextension/js/background/tabPageCounter.js index b0b6272..19d9591 100644 --- a/webextension/js/background/tabPageCounter.js +++ b/webextension/js/background/tabPageCounter.js @@ -34,20 +34,10 @@ const tabPageCounter = { return; } if (why === "user-closed-tab" && this.counters[tabId].tab) { - backgroundLogic.sendTelemetryPayload({ - event: "page-requests-completed-per-tab", - userContextId: this.counters[tabId].tab.cookieStoreId, - pageRequestCount: this.counters[tabId].tab.pageRequests - }); // When we send the ping because the user closed the tab, // delete both the 'tab' and 'activity' counters delete this.counters[tabId]; } else if (why === "user-went-idle" && this.counters[tabId].activity) { - backgroundLogic.sendTelemetryPayload({ - event: "page-requests-completed-per-activity", - userContextId: this.counters[tabId].activity.cookieStoreId, - pageRequestCount: this.counters[tabId].activity.pageRequests - }); // When we send the ping because the user went idle, // only reset the 'activity' counter this.counters[tabId].activity = { diff --git a/webextension/js/confirm-page.js b/webextension/js/confirm-page.js index aa8c9a2..fef08d4 100644 --- a/webextension/js/confirm-page.js +++ b/webextension/js/confirm-page.js @@ -49,10 +49,6 @@ function confirmSubmit(redirectUrl, cookieStoreId) { pageUrl: redirectUrl }); } - browser.runtime.sendMessage({ - method: "sendTelemetryPayload", - event: "click-to-reload-page-in-container", - }); openInContainer(redirectUrl, cookieStoreId); } @@ -70,10 +66,6 @@ async function denySubmit(redirectUrl) { tabId: tab[0].id, pageUrl: redirectUrl }); - browser.runtime.sendMessage({ - method: "sendTelemetryPayload", - event: "click-to-reload-page-in-same-container", - }); document.location.replace(redirectUrl); } diff --git a/webextension/js/popup.js b/webextension/js/popup.js index a68f19a..82f9658 100644 --- a/webextension/js/popup.js +++ b/webextension/js/popup.js @@ -81,11 +81,9 @@ const Logic = { // Retrieve the list of identities. const identitiesPromise = this.refreshIdentities(); - // Get the onboarding variation - const variationPromise = this.getShieldStudyVariation(); try { - await Promise.all([identitiesPromise, variationPromise]); + await identitiesPromise; } catch(e) { throw new Error("Failed to retrieve the identities or variation. We cannot continue. ", e.message); } @@ -158,7 +156,7 @@ const Logic = { }; // Handle old style rejection with null and also Promise.reject new style try { - return await browser.contextualIdentities.get(cookieStoreId) || defaultContainer; + return await browser.contextualIdentities.get(cookieStoreId) || defaultContainer; } catch(e) { return defaultContainer; } @@ -274,14 +272,6 @@ const Logic = { return identity.cookieStoreId; }, - sendTelemetryPayload(message = {}) { - if (!message.event) { - throw new Error("Missing event name for telemetry"); - } - message.method = "sendTelemetryPayload"; - browser.runtime.sendMessage(message); - }, - removeIdentity(userContextId) { if (!userContextId) { return Promise.reject("removeIdentity must be called with userContextId argument."); @@ -317,13 +307,6 @@ const Logic = { }); }, - async getShieldStudyVariation() { - const variation = await browser.runtime.sendMessage({ - method: "getShieldStudyVariation" - }); - this._onboardingVariation = variation; - }, - generateIdentityName() { const defaultName = "Container #"; const ids = []; @@ -474,9 +457,6 @@ Logic.registerPanel(P_CONTAINERS_LIST, { }); Logic.addEnterHandler(document.querySelector("#edit-containers-link"), () => { - Logic.sendTelemetryPayload({ - event: "edit-containers" - }); Logic.showPanel(P_CONTAINERS_EDIT); });