diff --git a/.eslintignore b/.eslintignore index 9b27377..4ced8a0 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,3 @@ testpilot-metrics.js +lib/shield/*.js +lib/testpilot/*.js diff --git a/.eslintrc.js b/.eslintrc.js index f2a7957..d9f7270 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -9,6 +9,7 @@ module.exports = { "webextensions": true }, "globals": { + "Utils": true, "CustomizableUI": true, "CustomizableWidgets": true, "SessionStore": true, diff --git a/.gitignore b/.gitignore index f5198a3..5e16ffe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ .DS_Store +package-lock.json node_modules README.html *.xpi *.swp +*.swo .vimrc .env addon.env diff --git a/.stylelintrc b/.stylelintrc index 656873f..cf506c8 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -11,7 +11,7 @@ "declaration-block-no-duplicate-properties": true, "order/declaration-block-properties-alphabetical-order": true, "property-blacklist": [ - "/height/", + "/(min[-]|max[-])height/", "/width/", "/top/", "/bottom/", diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..2fb0cb6 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,3 @@ +# Code Of Conduct + +This add-on follows the [Mozilla Community Participation Guidelines](https://www.mozilla.org/en-US/about/governance/policies/participation/) for our code of conduct. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0681439 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +# Contributing + +Everyone is welcome to contribute to containers. Reach out to team members if you have questions: + +- IRC: #containers on irc.mozilla.org +- Email: containers@mozilla.com + +## Filing bugs + +If you find a bug with containers, please file a issue. + +Check first if the bug might already exist: https://github.com/mozilla/testpilot-containers/issues + +[Open an issue](https://github.com/mozilla/testpilot-containers/issues/new) + +1. Visit about:support +2. Click "Copy raw data to clipboard" and paste into the bug. Alternatively copy the following sections into the issue: + - Application Basics + - Nightly Features (if you are in nightly) + - Extensions + - Experimental Features +3. Include clear steps to reproduce the issue you have experienced. +4. Include screenshots if possible. + +## Sending Pull Requests + +Patches should be submitted as pull requests. When submitting patches as PRs: + +- You agree to license your code under the project's open source license (MPL 2.0). +- Base your branch off the current master (see below for an example workflow). +- Add both your code and new tests if relevant. +- Run npm test to make sure all tests still pass. +- Please do not include merge commits in pull requests; include only commits with the new relevant code. + +See the main [README](./README.md) for information on prerequisites, installing, running and testing. diff --git a/README.md b/README.md index 3fead42..027dc58 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,22 @@ -# Containers: Test Pilot Experiment +# Containers Add-on [![Available on Test Pilot](https://img.shields.io/badge/available_on-Test_Pilot-0996F8.svg)](https://testpilot.firefox.com/experiments/containers) -[Embedded Web Extension](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Embedded_WebExtensions) to experiment with [Containers](https://blog.mozilla.org/tanvi/2016/06/16/contextual-identities-on-the-web/) in [Firefox Test Pilot](https://testpilot.firefox.com/) to learn: +[Embedded Web Extension](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Embedded_WebExtensions) to build [Containers](https://blog.mozilla.org/tanvi/2016/06/16/contextual-identities-on-the-web/) as a Firefox [Test Pilot](https://testpilot.firefox.com/) Experiment and [Shield Study](https://wiki.mozilla.org/Firefox/Shield/Shield_Studies) to learn: * Will a general Firefox audience understand the Containers feature? * Is the UI as currently implemented in Nightly clear or discoverable? -See [the Product Hypothesis Document for more -details](https://docs.google.com/document/d/1WQdHTVXROk7dYkSFluc6_hS44tqZjIrG9I-uPyzevE8/edit?ts=5824ba12#). +For more info, see: + +* [Test Pilot Product Hypothesis Document](https://docs.google.com/document/d/1WQdHTVXROk7dYkSFluc6_hS44tqZjIrG9I-uPyzevE8/edit#) +* [Shield Product Hypothesis Document](https://docs.google.com/document/d/1vMD-fH_5hGDDqNvpRZk12_RhCN2WAe4_yaBamaNdtik/edit#) ## Requirements * node 7+ (for jpm) -* Firefox 51+ - - -## Run it - -See Development +* Firefox 53+ ## Development @@ -27,28 +24,23 @@ See Development Add-on development is better with [a particular environment](https://developer.mozilla.org/en-US/Add-ons/Setting_up_extension_development_environment). One simple way to get that environment set up is to install the [DevPrefs add-on](https://addons.mozilla.org/en-US/firefox/addon/devprefs/). You can make a custom Firefox profile that includes the DevPrefs add-on, and use that profile when you run the code in this repository. - 1. Make a new profile by running `/path/to/firefox -P`, which launches the profile editor. "Create Profile" -- name it whatever you wish (e.g. 'addon_dev') and store it in the default location. It's probably best to deselect the option to "Use without asking," since you probably don't want to use this as your default profile. 2. Once you've created your profile, click "Start Firefox". A new instance of Firefox should launch. Go to Tools->Add-ons and search for "DevPrefs". Install it. Quit Firefox. 3. Now you have a new, vanilla Firefox profile with the DevPrefs add-on installed. You can use your new profile with the code in _this_ repository like so: -**Beta building** +#### Run the `.xpi` file in an unbranded build +Release & Beta channels do not allow un-signed add-ons, even with the DevPrefs. So, you must run the add-on in an [unbranded build](https://wiki.mozilla.org/Add-ons/Extension_Signing#Unbranded_Builds): -To build this for 51 beta just using the downloaded version of beta will not work as XPI signature checking is disabled fully. +1. Download and install an un-branded build of Firefox +2. Download the latest `.xpi` from this repository's releases +3. Run the un-branded build of Firefox with your DevPrefs profile +4. Go to `about:addons` +5. Click the gear, and select "Install Add-on From File..." +6. Select the `.xpi` file -The only way to run the experiment is using an [unbranded version build](https://wiki.mozilla.org/Add-ons/Extension_Signing#Unbranded_Builds) or to build beta yourself: - -1. [Download the mozilla-beta repo](https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Source_Code/Mercurial#mozilla-beta_(prerelease_development_tree)) -2. [Create a mozconfig file](https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Build_Instructions/Configuring_Build_Options) - probably optional -3. `cd ` -3. `./mach bootstrap` -4. `./mach build` -5. Follow the above instructions by creating the new profile via: `~//obj-x86_64-pc-linux-gnu/dist/bin/firefox -P` (Where "obj-x86_64-pc-linux-gnu" may be different depending on platform obj-...) - - -### Run with jpm +#### Run the TxP experiment with `jpm` 1. `git clone git@github.com:mozilla/testpilot-containers.git` 2. `cd testpilot-containers` @@ -57,11 +49,22 @@ The only way to run the experiment is using an [unbranded version build](https:/ 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 .xpi, use the plain [`jpm -xpi`](https://developer.mozilla.org/en-US/Add-ons/SDK/Tools/jpm#jpm_xpi) command. +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 @@ -75,6 +78,11 @@ add-on](https://addons.mozilla.org/en-US/developers/addon/containers-experiment/ ### Testing TBD - ### Distributing TBD + +### Links + +- [Licence](./LICENSE.txt) +- [Contributing](./CONTRIBUTING.md) +- [Code Of Conduct](./CODE_OF_CONDUCT.md) diff --git a/data/usercontext.css b/data/usercontext.css index 7ee9bf5..11530b7 100644 --- a/data/usercontext.css +++ b/data/usercontext.css @@ -52,55 +52,55 @@ value, or chrome url path as an alternate selector mitiages this bug.*/ [data-identity-icon="fingerprint"], [data-identity-icon="chrome://browser/skin/usercontext/personal.svg"] { - --identity-icon: url("resource://testpilot-containers/data/usercontext.svg#fingerprint"); + --identity-icon: url("/data/usercontext.svg#fingerprint"); } [data-identity-icon="briefcase"], [data-identity-icon="chrome://browser/skin/usercontext/work.svg"] { - --identity-icon: url("resource://testpilot-containers/data/usercontext.svg#briefcase"); + --identity-icon: url("/data/usercontext.svg#briefcase"); } [data-identity-icon="dollar"], [data-identity-icon="chrome://browser/skin/usercontext/banking.svg"] { - --identity-icon: url("resource://testpilot-containers/data/usercontext.svg#dollar"); + --identity-icon: url("/data/usercontext.svg#dollar"); } [data-identity-icon="cart"], [data-identity-icon="chrome://browser/skin/usercontext/cart.svg"], [data-identity-icon="chrome://browser/skin/usercontext/shopping.svg"] { - --identity-icon: url("resource://testpilot-containers/data/usercontext.svg#cart"); + --identity-icon: url("/data/usercontext.svg#cart"); } [data-identity-icon="circle"] { - --identity-icon: url("resource://testpilot-containers/data/usercontext.svg#circle"); + --identity-icon: url("/data/usercontext.svg#circle"); } [data-identity-icon="gift"] { - --identity-icon: url("resource://testpilot-containers/data/usercontext.svg#gift"); + --identity-icon: url("/data/usercontext.svg#gift"); } [data-identity-icon="vacation"] { - --identity-icon: url("resource://testpilot-containers/data/usercontext.svg#vacation"); + --identity-icon: url("/data/usercontext.svg#vacation"); } [data-identity-icon="food"] { - --identity-icon: url("resource://testpilot-containers/data/usercontext.svg#food"); + --identity-icon: url("/data/usercontext.svg#food"); } [data-identity-icon="fruit"] { - --identity-icon: url("resource://testpilot-containers/data/usercontext.svg#fruit"); + --identity-icon: url("/data/usercontext.svg#fruit"); } [data-identity-icon="pet"] { - --identity-icon: url("resource://testpilot-containers/data/usercontext.svg#pet"); + --identity-icon: url("/data/usercontext.svg#pet"); } [data-identity-icon="tree"] { - --identity-icon: url("resource://testpilot-containers/data/usercontext.svg#tree"); + --identity-icon: url("/data/usercontext.svg#tree"); } [data-identity-icon="chill"] { - --identity-icon: url("resource://testpilot-containers/data/usercontext.svg#chill"); + --identity-icon: url("/data/usercontext.svg#chill"); } #userContext-indicator { @@ -139,7 +139,7 @@ value, or chrome url path as an alternate selector mitiages this bug.*/ background-size: contain; fill: var(--identity-icon-color) !important; filter: url(/img/filters.svg#fill); - filter: url(resource://testpilot-containers/data/filters.svg#fill); + filter: url(/data/filters.svg#fill); } /* containers experiment */ @@ -200,7 +200,7 @@ special cases are addressed below */ } #new-tab-overlay { - --icon-size: 26px; + --icon-size: 16px; -moz-appearance: none; background: transparent; font-style: -moz-use-system-font; @@ -252,8 +252,8 @@ special cases are addressed below */ } #new-tab-overlay .menuitem-iconic[data-usercontextid] > .menu-iconic-left > .menu-iconic-icon { - block-height: var(--icon-size); - block-width: var(--icon-size); + block-size: var(--icon-size); + inline-size: var(--icon-size); } .menuitem-iconic[data-usercontextid] > .menu-iconic-left { diff --git a/docs/metrics.md b/docs/metrics.md index 135ed97..317ff4e 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -68,6 +68,16 @@ Containers will use Test Pilot Telemetry with no batching of data. Details of when pings are sent are below, along with examples of the `payload` portion of a `testpilottest` telemetry ping for each scenario. +* The user shows the new tab menu + +```js + { + "uuid": , + "event": "show-plus-button-menu", + "eventSource": ["plus-button"] + } +``` + * The user clicks on a container name to open a tab in that container ```js @@ -76,7 +86,7 @@ of a `testpilottest` telemetry ping for each scenario. "userContextId": , "clickedContainerTabCount": , "event": "open-tab", - "eventSource": ["tab-bar"|"pop-up"|"file-menu"] + "eventSource": ["tab-bar"|"pop-up"|"file-menu"|"alltabs-menu"|"plus-button"] } ``` @@ -220,7 +230,7 @@ of a `testpilottest` telemetry ping for each scenario. } ``` -* The user clicks "Take me there" to reload a site into a container after the user picked "Always Open in this Container". +* The user clicks "Open in *assigned* container" to reload a site into a container after the user picked "Always Open in this Container". ```js { @@ -229,6 +239,15 @@ of a `testpilottest` telemetry ping for each scenario. } ``` +* The user clicks "Open in *Current* container" to reload a site into a container after the user picked "Always Open in this Container". + +```js + { + "uuid": , + "event": "click-to-reload-page-in-same-container" + } +``` + * Firefox automatically reloads a site into a container after the user picked "Always Open in this Container". ```js @@ -260,7 +279,7 @@ local schema = { ### Valid data should be enforced on the server side: -* `eventSource` should be one of `tab-bar`, `pop-up`, or `file-menu`. +* `eventSource` should be one of `tab-bar`, `pop-up`, `file-menu`, "alltabs-nmenu" or "plus-button". All Mozilla data is kept by default for 180 days and in accordance with our privacy policies. diff --git a/index.js b/index.js index bf70a1c..afdfa61 100644 --- a/index.js +++ b/index.js @@ -6,9 +6,6 @@ const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; const DEFAULT_TAB = "about:newtab"; const LOOKUP_KEY = "$ref"; -const SHOW_MENU_TIMEOUT = 100; -const HIDE_MENU_TIMEOUT = 300; - const INCOMPATIBLE_ADDON_IDS = [ "pulse@mozilla.com", "snoozetabs@mozilla.com", @@ -42,8 +39,17 @@ const IDENTITY_ICONS = [ { 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 ], ]; @@ -60,6 +66,7 @@ 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"); @@ -213,6 +220,7 @@ const ContainerService = { "getPreference", "sendTelemetryPayload", "getTheme", + "getShieldStudyVariation", "refreshNeeded", "forgetIdentityAndRefresh", "checkIncompatibleAddons" @@ -236,18 +244,15 @@ const ContainerService = { } tabs.on("open", tab => { - this._hideAllPanels(); this._restyleTab(tab); this._remapTab(tab); }); tabs.on("close", tab => { - this._hideAllPanels(); this._remapTab(tab); }); tabs.on("activate", tab => { - this._hideAllPanels(); this._restyleActiveTab(tab).catch(() => {}); this._configureActiveWindows(); this._remapTab(tab); @@ -320,6 +325,11 @@ const ContainerService = { // 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) { @@ -362,6 +372,10 @@ const ContainerService = { }); }, + getShieldStudyVariation() { + return this.shieldStudyVariation; + }, + // utility methods _containerTabCount(userContextId) { @@ -922,12 +936,6 @@ const ContainerService = { return this._configureWindows(); }, - _hideAllPanels() { - for (let windowObject of this._windowMap.values()) { // eslint-disable-line prefer-const - windowObject.hidePanel(); - } - }, - _restyleActiveTab(tab) { if (!tab) { return Promise.resolve(null); @@ -1086,22 +1094,50 @@ ContainerWindow.prototype = { _timeoutStore: new Map(), _elementCache: new Map(), _tooltipCache: new Map(), - _plusButton: null, - _overflowPlusButton: null, _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"); - this._style = Style({ uri: self.data.url("usercontext.css") }); + + // 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._configurePlusButtonMenu(), this._configureActiveTab(), this._configureFileMenu(), this._configureAllTabsMenu(), @@ -1114,112 +1150,6 @@ ContainerWindow.prototype = { return this._configureContextMenu(); }, - handleEvent(e) { - let el = e.target; - switch (e.type) { - case "mouseover": - this._createTimeout("show", () => { - this.showPopup(el); - }, SHOW_MENU_TIMEOUT); - break; - case "click": - this.hidePanel(); - break; - case "mouseout": - while(el) { - if (el === this._panelElement || - el === this._plusButton || - el === this._overflowPlusButton) { - // Clear show timeout so we don't hide and reshow - this._cleanTimeout("show"); - this._createTimeout("hidden", () => { - this.hidePanel(); - }, HIDE_MENU_TIMEOUT); - return; - } - el = el.parentElement; - } - break; - } - }, - - showPopup(buttonElement) { - this._cleanAllTimeouts(); - this._panelElement.openPopup(buttonElement); - }, - - _configurePlusButtonMenuElement(buttonElement) { - if (buttonElement) { - // Let's remove the tooltip because it can go over our panel. - this._tooltipCache.set(buttonElement, buttonElement.getAttribute("tooltip")); - buttonElement.setAttribute("tooltip", ""); - this._disableElement(buttonElement); - - buttonElement.addEventListener("mouseover", this); - buttonElement.addEventListener("click", this); - buttonElement.addEventListener("mouseout", this); - } - }, - - async _configurePlusButtonMenu() { - const mainPopupSetElement = this._window.document.getElementById("mainPopupSet"); - - // Let's remove all the previous panels. - if (this._panelElement) { - this._panelElement.remove(); - } - - this._panelElement = this._window.document.createElementNS(XUL_NS, "panel"); - this._panelElement.setAttribute("id", "new-tab-overlay"); - this._panelElement.setAttribute("position", "bottomcenter topleft"); - this._panelElement.setAttribute("side", "top"); - this._panelElement.setAttribute("flip", "side"); - this._panelElement.setAttribute("type", "arrow"); - this._panelElement.setAttribute("animate", "open"); - this._panelElement.setAttribute("consumeoutsideclicks", "never"); - mainPopupSetElement.appendChild(this._panelElement); - - this._configurePlusButtonMenuElement(this._plusButton); - this._configurePlusButtonMenuElement(this._overflowPlusButton); - - this._panelElement.addEventListener("mouseout", this); - - this._panelElement.addEventListener("mouseover", () => { - this._cleanAllTimeouts(); - }); - - try { - const identities = await ContainerService.queryIdentities(); - identities.forEach(identity => { - const menuItemElement = this._window.document.createElementNS(XUL_NS, "menuitem"); - this._panelElement.appendChild(menuItemElement); - menuItemElement.className = "menuitem-iconic"; - menuItemElement.setAttribute("label", identity.name); - menuItemElement.setAttribute("data-usercontextid", identity.userContextId); - menuItemElement.setAttribute("data-identity-icon", identity.icon); - menuItemElement.setAttribute("data-identity-color", identity.color); - - menuItemElement.addEventListener("command", (e) => { - ContainerService.openTab({ - userContextId: identity.userContextId, - source: "tab-bar" - }); - e.stopPropagation(); - }); - - menuItemElement.addEventListener("mouseover", () => { - this._cleanAllTimeouts(); - }); - - menuItemElement.addEventListener("mouseout", this); - - this._panelElement.appendChild(menuItemElement); - }); - } catch (e) { - this.hidePanel(); - } - }, - _configureTabStyle() { const promises = []; for (let tab of modelFor(this._window).tabs) { // eslint-disable-line prefer-const @@ -1389,19 +1319,15 @@ ContainerWindow.prototype = { } }, - hidePanel() { - this._cleanAllTimeouts(); - this._panelElement.hidePopup(); - }, - shutdown() { // CSS must be removed. detachFrom(this._style, this._window); - this._shutdownPlusButtonMenu(); this._shutdownFileMenu(); this._shutdownAllTabsMenu(); this._shutdownContextMenu(); + + this._shutdownContainers(); }, _shutDownPlusButtonMenuElement(buttonElement) { @@ -1415,11 +1341,6 @@ ContainerWindow.prototype = { } }, - _shutdownPlusButtonMenu() { - this._shutDownPlusButtonMenuElement(this._plusButton); - this._shutDownPlusButtonMenuElement(this._overflowPlusButton); - }, - _shutdownFileMenu() { this._shutdownMenu("menu_newUserContext"); }, @@ -1468,6 +1389,36 @@ ContainerWindow.prototype = { 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 --------------------------------------------------- diff --git a/lib/shield/event-target.js b/lib/shield/event-target.js new file mode 100644 index 0000000..4335d8c --- /dev/null +++ b/lib/shield/event-target.js @@ -0,0 +1,55 @@ +/** + * 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 new file mode 100644 index 0000000..9d4ec95 --- /dev/null +++ b/lib/shield/index.js @@ -0,0 +1,428 @@ +"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 new file mode 100644 index 0000000..1b4e98f --- /dev/null +++ b/lib/testpilot/experiment.js @@ -0,0 +1,95 @@ +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 020a9b4..6311822 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.3.0", + "version": "2.4.0", "author": "Andrea Marchesini, Luke Crouch and Jonathan Kingston", "bugs": { "url": "https://github.com/mozilla/testpilot-containers/issues" @@ -16,7 +16,9 @@ "eslint-plugin-promise": "^3.4.0", "htmllint-cli": "^0.0.5", "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", @@ -41,6 +43,7 @@ }, "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", @@ -48,6 +51,7 @@ "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 new file mode 100644 index 0000000..df59555 --- /dev/null +++ b/study.js @@ -0,0 +1,40 @@ +/* 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 index f68aae3..2914884 100644 --- a/testpilot-metrics.js +++ b/testpilot-metrics.js @@ -1,6 +1,9 @@ // 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 diff --git a/webextension/background.js b/webextension/background.js index 8f4278c..2884dfd 100644 --- a/webextension/background.js +++ b/webextension/background.js @@ -1,4 +1,4 @@ -const MAJOR_VERSIONS = ["2.3.0"]; +const MAJOR_VERSIONS = ["2.3.0", "2.4.0"]; const LOOKUP_KEY = "$ref"; const assignManager = { @@ -6,6 +6,7 @@ const assignManager = { MENU_REMOVE_ID: "remove-open-in-this-container", storageArea: { area: browser.storage.local, + exemptedTabs: {}, getSiteStoreKey(pageUrl) { const url = new window.URL(pageUrl); @@ -13,6 +14,27 @@ const assignManager = { 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) => { @@ -27,8 +49,13 @@ const assignManager = { }); }, - set(pageUrl, data) { + set(pageUrl, data, exemptedTabIds) { const siteStoreKey = this.getSiteStoreKey(pageUrl); + if (exemptedTabIds) { + exemptedTabIds.forEach((tabId) => { + this.setExempted(pageUrl, tabId); + }); + } return this.area.set({ [siteStoreKey]: data }); @@ -36,22 +63,30 @@ const assignManager = { 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]); }, - deleteContainer(userContextId) { - const removeKeys = []; - this.area.get().then((siteConfigs) => { - 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)) { - removeKeys.push(key); - } - }); - this.area.remove(removeKeys); - }).catch((e) => { - throw e; + 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; } }, @@ -70,39 +105,16 @@ const assignManager = { } }, + // 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) => { - const userContextId = this.getUserContextIdFromCookieStore(tab); - // Mapping ${URL(info.pageUrl).hostname} to ${userContextId} - if (userContextId) { - let actionName; - let storageAction; - if (info.menuItemId === this.MENU_ASSIGN_ID) { - actionName = "added"; - storageAction = this.storageArea.set(info.pageUrl, { - userContextId, - neverAsk: false - }); - } else { - actionName = "removed"; - storageAction = this.storageArea.remove(info.pageUrl); - } - storageAction.then(() => { - browser.notifications.create({ - type: "basic", - title: "Containers", - message: `Successfully ${actionName} site to always open in this container`, - iconUrl: browser.extension.getURL("/img/onboarding-1.png") - }); - backgroundLogic.sendTelemetryPayload({ - event: `${actionName}-container-assignment`, - userContextId: userContextId, - }); - this.calculateContextMenu(tab); - }).catch((e) => { - throw e; - }); - } + this._onClickedHandler(info, tab); }); // Before a request is handled by the browser we decide if we should route through a different container @@ -110,6 +122,7 @@ const assignManager = { if (options.frameId !== 0 || options.tabId === -1) { return {}; } + this.removeContextMenu(); return Promise.all([ browser.tabs.get(options.tabId), this.storageArea.get(options.url) @@ -117,11 +130,12 @@ const assignManager = { const userContextId = this.getUserContextIdFromCookieStore(tab); if (!siteSettings || userContextId === siteSettings.userContextId - || tab.incognito) { + || tab.incognito + || this.storageArea.isExempted(options.url, tab.id)) { return {}; } - this.reloadPageInContainer(options.url, siteSettings.userContextId, tab.index + 1, siteSettings.neverAsk); + this.reloadPageInContainer(options.url, userContextId, siteSettings.userContextId, tab.index + 1, siteSettings.neverAsk); this.calculateContextMenu(tab); /* Removal of existing tabs: @@ -149,6 +163,21 @@ const assignManager = { },{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); @@ -171,49 +200,112 @@ const assignManager = { // 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; }, - calculateContextMenu(tab) { + 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 - const cookieStore = this.getUserContextIdFromCookieStore(tab); browser.contextMenus.remove(this.MENU_ASSIGN_ID); browser.contextMenus.remove(this.MENU_REMOVE_ID); - // Ensure we have a cookieStore to assign to - if (cookieStore - && this.isTabPermittedAssign(tab)) { - this.storageArea.get(tab.url).then((siteSettings) => { - // ✓ 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; - if (siteSettings) { - prefix = "✓"; - menuId = this.MENU_REMOVE_ID; - } - browser.contextMenus.create({ - id: menuId, - title: `${prefix} Always Open in This Container`, - checked: true, - contexts: ["all"], - }); - }).catch((e) => { - throw e; - }); - } }, - reloadPageInContainer(url, userContextId, index, neverAsk = false) { + 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: backgroundLogic.cookieStoreId(userContextId), index}); + browser.tabs.create({url, cookieStoreId, index}); backgroundLogic.sendTelemetryPayload({ event: "auto-reload-page-in-container", userContextId: userContextId, @@ -223,8 +315,17 @@ const assignManager = { event: "prompt-to-reload-page-in-container", userContextId: userContextId, }); - const confirmUrl = `${loadPage}?url=${url}`; - browser.tabs.create({url: confirmUrl, cookieStoreId: backgroundLogic.cookieStoreId(userContextId), index}).then(() => { + 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) => { @@ -271,7 +372,7 @@ const backgroundLogic = { createOrUpdateContainer(options) { let donePromise; - if (options.userContextId) { + if (options.userContextId !== "new") { donePromise = browser.contextualIdentities.update( this.cookieStoreId(options.userContextId), options.params @@ -354,7 +455,7 @@ const messageHandler = { LAST_CREATED_TAB_TIMER: 2000, init() { - // Handles messages from webextension/js/popup.js + // Handles messages from webextension code browser.runtime.onMessage.addListener((m) => { let response; @@ -372,11 +473,29 @@ const messageHandler = { 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 index.js + // Handles messages from sdk code const port = browser.runtime.connect(); port.onMessage.addListener(m => { switch (m.type) { @@ -408,6 +527,7 @@ const messageHandler = { }); browser.tabs.onActivated.addListener((info) => { + assignManager.removeContextMenu(); browser.tabs.get(info.tabId).then((tab) => { tabPageCounter.initTabCounter(tab); assignManager.calculateContextMenu(tab); @@ -417,6 +537,12 @@ const messageHandler = { }); 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]); @@ -445,6 +571,7 @@ const messageHandler = { if (details.frameId !== 0 || details.tabId === -1) { return {}; } + assignManager.removeContextMenu(); browser.tabs.get(details.tabId).then((tab) => { tabPageCounter.incrementTabCount(tab); diff --git a/webextension/confirm-page.html b/webextension/confirm-page.html index 48e12f9..da213f7 100644 --- a/webextension/confirm-page.html +++ b/webextension/confirm-page.html @@ -8,26 +8,29 @@
-

Should we open this in your container?

+

Open this site in your assigned container?

- Looks like you requested: + You asked Firefox to always open for this site:

-

- You asked Firefox to always open in this type of container. Would you like to proceed?
-

+

Would you still like to open in this current container?



- +
- + +
+ diff --git a/webextension/css/confirm-page.css b/webextension/css/confirm-page.css index a595e4c..3d24c26 100644 --- a/webextension/css/confirm-page.css +++ b/webextension/css/confirm-page.css @@ -4,11 +4,21 @@ } main { - background: url(/img/onboarding-1.png) no-repeat; + background: url(/img/onboarding-4.png) no-repeat; background-position: -10px -15px; - background-size: 285px; - margin-inline-start: -285px; - padding-inline-start: 285px; + background-size: 300px; + margin-inline-start: -350px; + padding-inline-start: 350px; +} + +.container-name { + font-weight: bold; +} + +button .container-name, +#current-container-name { + font-weight: bold; + text-transform: capitalize; } @media only screen and (max-width: 1300px) { @@ -36,6 +46,33 @@ html { word-break: break-all; } +#redirect-url { + background: #efefef; + border-radius: 2px; + line-height: 1.5; + padding-block-end: 0.5rem; + padding-block-start: 0.5rem; + padding-inline-end: 0.5rem; + padding-inline-start: 0.5rem; +} + +#redirect-url img { + block-size: 16px; + inline-size: 16px; + margin-inline-end: 6px; + offset-block-start: 3px; + position: relative; +} + dfn { font-style: normal; } + +.button-container > button { + min-inline-size: 240px; +} + +.check-label { + align-items: center; + display: flex; +} diff --git a/webextension/css/content.css b/webextension/css/content.css new file mode 100644 index 0000000..5681887 --- /dev/null +++ b/webextension/css/content.css @@ -0,0 +1,27 @@ +.container-notification { + align-items: center; + background: #efefef; + color: #003f07; + display: flex; + font: 12px sans-serif; + inline-size: 100vw; + justify-content: start; + offset-block-start: 0; + offset-inline-start: 0; + padding-block-end: 8px; + padding-block-start: 8px; + padding-inline-end: 8px; + padding-inline-start: 8px; + position: fixed; + text-align: start; + transform: translateY(-100%); + transition: transform 0.3s cubic-bezier(0.07, 0.95, 0, 1) 0.3s; + z-index: 999999999999; +} + +.container-notification img { + block-size: 16px; + display: inline-block; + inline-size: 16px; + margin-inline-end: 3px; +} diff --git a/webextension/css/popup.css b/webextension/css/popup.css index d053693..e86a7d1 100644 --- a/webextension/css/popup.css +++ b/webextension/css/popup.css @@ -1,11 +1,55 @@ /* General Rules and Resets */ -body { - inline-size: 300px; - max-inline-size: 300px; +* { + font-size: inherit; + margin-block-end: 0; + margin-block-start: 0; + margin-inline-end: 0; + margin-inline-start: 0; + padding-block-end: 0; + padding-block-start: 0; + padding-inline-end: 0; + padding-inline-start: 0; } html { box-sizing: border-box; + font-size: 12px; +} + +body { + font-family: Roboto, Noto, "San Francisco", Ubuntu, "Segoe UI", "Fira Sans", message-box, Arial, sans-serif; + inline-size: 300px; + max-inline-size: 300px; +} + +:root { + --primary-action-color: #248aeb; + --title-text-color: #000; + --text-normal-color: #4a4a4a; + --text-heading-color: #000; + + /* calculated from 12px */ + --font-size-heading: 1.33rem; /* 16px */ + --block-line-space-size: 0.5rem; /* 6px */ + --inline-item-space-size: 0.5rem; /* 6px */ + --block-line-separation-size: 0.33rem; /* 10px */ + --inline-icon-space-size: 0.833rem; /* 10px */ + + /* Use for url and icon size */ + --block-url-label-size: 2rem; /* 24px */ + --inline-start-size: 1.66rem; /* 20px */ + --inline-button-size: 5.833rem; /* 70px */ + --icon-size: 1.166rem; /* 14px */ + + --small-text-size: 0.833rem; /* 10px */ + --small-radius: 3px; + --icon-button-size: calc(calc(var(--block-line-separation-size) * 2) + 1.66rem); /* 20px */ +} + +@media (min-resolution: 1dppx) { + html { + font-size: 14px; + } } *, @@ -14,6 +58,13 @@ html { box-sizing: inherit; } +form { + margin-block-end: 0; + margin-block-start: 0; + margin-inline-end: 0; + margin-inline-start: 0; +} + table { border: 0; border-spacing: 0; @@ -30,11 +81,27 @@ table { } .scrollable { + border-block-start: 1px solid #f1f1f1; inline-size: 100%; max-block-size: 400px; overflow: auto; } +.offpage { + opacity: 0; +} + +[hidden] { + display: none !important; +} + +/* Effect borrowed from tabs in Firefox, ensure that the element flexes to the full width */ +.truncate-text { + mask-image: linear-gradient(to left, transparent, black 1em); + overflow: hidden; + white-space: nowrap; +} + /* Color and icon helpers */ [data-identity-color="blue"] { --identity-tab-color: #37adff; @@ -51,6 +118,11 @@ table { --identity-icon-color: #51cd00; } +[data-identity-color="grey"] { + /* Only used for the edit panel */ + --identity-icon-color: #616161; +} + [data-identity-color="yellow"] { --identity-tab-color: #ffcb00; --identity-icon-color: #ffcb00; @@ -124,7 +196,16 @@ table { --identity-icon: url("/img/usercontext.svg#chill"); } +#current-tab [data-identity-icon="default-tab"] { + background: center center no-repeat url("/img/blank-tab.svg"); + fill: currentColor; +} + /* Buttons */ +.button { + color: black; +} + .button.primary { background-color: #0996f8; color: white; @@ -140,6 +221,18 @@ table { background-color: rgba(0, 0, 0, 0.05); } +/* Text links with actions */ + +.action-link:link { + color: var(--primary-action-color); + text-decoration: none; +} + +.action-link:active, +.action-link:hover { + text-decoration: underline; +} + /* Panels keep everything togethert */ .panel { display: flex; @@ -183,7 +276,7 @@ table { .column-panel-content .button, .panel-footer .button { align-items: center; - block-size: 54px; + block-size: 100%; display: flex; flex: 1; justify-content: center; @@ -223,7 +316,7 @@ table { .onboarding-title { color: #43484e; - font-size: 16px; + font-size: var(--font-size-heading); margin-block-end: 0; margin-block-start: 0; margin-inline-end: 0; @@ -232,7 +325,7 @@ table { } .onboarding p { - color: #4a4a4a; + color: var(--text-normal-color); font-size: 14px; margin-block-end: 16px; max-inline-size: 84%; @@ -261,9 +354,10 @@ table { manage things like container crud */ .pop-button { align-items: center; - block-size: 48px; + block-size: var(--icon-button-size); + cursor: pointer; display: flex; - flex: 0 0 48px; + flex: 0 0 var(--icon-button-size); justify-content: center; } @@ -288,6 +382,10 @@ manage things like container crud */ .pop-button-image { block-size: 20px; flex: 0 0 20px; + margin-block-end: auto; + margin-block-start: auto; + margin-inline-end: auto; + margin-inline-start: auto; } .pop-button-image-small { @@ -299,20 +397,23 @@ manage things like container crud */ .panel-header { align-items: center; block-size: 48px; - border-block-end: 1px solid #ebebeb; display: flex; justify-content: space-between; } +.panel-header .usercontext-icon { + inline-size: var(--icon-button-size); +} + .column-panel-content .panel-header { flex: 0 0 48px; inline-size: 100%; } .panel-header-text { - color: #4a4a4a; + color: var(--text-normal-color); flex: 1; - font-size: 16px; + font-size: var(--font-size-heading); font-weight: normal; margin-block-end: 0; margin-block-start: 0; @@ -324,6 +425,47 @@ manage things like container crud */ padding-inline-start: 16px; } +#container-panel .panel-header { + background-color: #efefef; + block-size: 26px; + font-size: 14px; +} + +#container-panel .panel-header-text { + color: #727272; + font-size: 14px; + padding-block-end: 0; + padding-block-start: 0; + text-transform: uppercase; +} + +.container-panel-controls { + display: flex; + justify-content: flex-end; + margin-block-end: var(--block-line-space-size); + margin-block-start: var(--block-line-space-size); + margin-inline-end: var(--inline-item-space-size); + margin-inline-start: var(--inline-item-space-size); +} + +#container-panel #sort-containers-link { + align-items: center; + block-size: var(--block-url-label-size); + border: 1px solid #d8d8d8; + border-radius: var(--small-radius); + color: var(--title-text-color); + display: flex; + font-size: var(--small-text-size); + inline-size: var(--inline-button-size); + justify-content: center; + text-decoration: none; +} + +#container-panel #sort-containers-link:hover, +#container-panel #sort-containers-link:focus { + background: #f2f2f2; +} + span ~ .panel-header-text { padding-block-end: 0; padding-block-start: 0; @@ -331,11 +473,92 @@ span ~ .panel-header-text { padding-inline-start: 0; } +#current-tab { + align-items: center; + color: var(--text-normal-color); + display: grid; + font-size: var(--small-text-size); + grid-column-gap: var(--inline-item-space-size); + grid-row-gap: var(--block-line-space-size); + grid-template-columns: var(--icon-size) var(--icon-size) 1fr; + margin-block-end: var(--block-line-space-size); + margin-block-start: var(--block-line-separation-size); + margin-inline-end: var(--inline-start-size); + margin-inline-start: var(--inline-start-size); + max-inline-size: 100%; +} + +#current-tab img { + max-block-size: var(--icon-size); +} + +#current-tab > h3 { + color: var(--text-heading-color); + font-weight: normal; + grid-column: span 3; + margin-block-end: 0; + margin-block-start: 0; + margin-inline-end: 0; + margin-inline-start: 0; +} + +#current-page { + display: contents; +} + +#current-tab .page-title { + font-size: var(--font-size-heading); + grid-column: 2 / 4; +} + +#current-tab > label { + display: contents; + font-size: var(--small-text-size); +} + +#current-tab > label > input { + -moz-appearance: none; + block-size: var(--icon-size); + border: 1px solid #d8d8d8; + border-radius: var(--small-radius); + display: block; + grid-column-start: 2; + inline-size: var(--icon-size); + margin-block-end: 0; + margin-block-start: 0; + margin-inline-end: 0; + margin-inline-start: 0; +} + +#current-tab > label > input[disabled] { + background-color: #efefef; +} + +#current-tab > label > input:checked { + background-image: url("chrome://global/skin/in-content/check.svg#check-native"); + background-position: -1px -1px; + background-size: var(--icon-size); +} + +#current-container { + color: var(--identity-tab-color); + flex: 1; +} + +#current-tab > label > .usercontext-icon { + background-size: 16px; + block-size: 16px; + display: block; + flex: 0 0 20px; + inline-size: 20px; + margin-inline-end: 3px; + margin-inline-start: 3px; +} + /* Rows used when iterating over panels */ .container-panel-row { align-items: center; background-color: #fefefe !important; - block-size: 48px; border-block-end: 1px solid #f1f1f1; box-sizing: border-box; display: flex; @@ -343,12 +566,10 @@ span ~ .panel-header-text { } .container-panel-row .container-name { + flex: 1; max-inline-size: 160px; - overflow: hidden; padding-inline-end: 4px; padding-inline-start: 4px; - text-overflow: ellipsis; - white-space: nowrap; } .edit-containers-panel .userContext-wrapper { @@ -368,8 +589,9 @@ span ~ .panel-header-text { } .userContext-icon-wrapper { - block-size: 48px; - flex: 0 0 48px; + block-size: var(--icon-button-size); + flex: 0 0 var(--icon-button-size); + margin-inline-start: var(--inline-icon-space-size); } /* .userContext-icon is used natively, Bug 1333811 was raised to fix */ @@ -378,24 +600,29 @@ span ~ .panel-header-text { background-position: center center; background-repeat: no-repeat; background-size: 20px 20px; - block-size: 48px; + block-size: 100%; fill: var(--identity-icon-color); filter: url('/img/filters.svg#fill'); - flex: 0 0 48px; } .container-panel-row:hover .clickable .usercontext-icon, -.container-panel-row:focus .clickable .usercontext-icon { +.container-panel-row:focus .clickable .usercontext-icon, +.container-panel-row .clickable:focus .usercontext-icon { background-image: url('/img/container-newtab.svg'); - fill: 'gray'; + fill: #979797; filter: url('/img/filters.svg#fill'); } +.container-panel-row .clickable:hover .usercontext-icon, +.container-panel-row .clickable:focus .usercontext-icon { + fill: #0094fb; +} + /* Panel Footer */ .panel-footer { align-items: center; background: #efefef; - block-size: 54px; + block-size: var(--icon-button-size); border-block-end: 1px solid #d8d8d8; color: #000; display: flex; @@ -404,14 +631,9 @@ span ~ .panel-header-text { justify-content: space-between; } -.panel-footer .pop-button { - block-size: 54px; - flex: 0 0 54px; -} - .edit-containers-text { align-items: center; - block-size: 54px; + block-size: 100%; border-inline-end: solid 1px #d8d8d8; display: flex; flex: 1; @@ -420,7 +642,7 @@ span ~ .panel-header-text { .edit-containers-text a { align-items: center; - block-size: 54px; + block-size: 100%; color: #0a0a0a; display: flex; flex: 1; @@ -428,11 +650,8 @@ span ~ .panel-header-text { } /* Container info list */ -#container-info-name { - margin-inline-end: 0.5rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.container-info-tab-title { + flex: 1; } #container-info-hideorshow { @@ -477,19 +696,19 @@ span ~ .panel-header-text { .container-info-tab-row td { max-inline-size: 200px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } .container-info-list { - border-block-start: 1px solid #ebebeb; display: flex; flex-direction: column; margin-block-start: 4px; padding-block-start: 4px; } +.container-info-list tbody { + display: contents; +} + .clickable { cursor: pointer; } @@ -501,7 +720,7 @@ span ~ .panel-header-text { .edit-containers-exit-text { align-items: center; - background: #248aeb; + background: var(--primary-action-color); block-size: 100%; color: #fff; display: flex; @@ -528,7 +747,7 @@ span ~ .panel-header-text { .delete-container-confirm-title { color: #000; - font-size: 16px; + font-size: var(--font-size-heading); } /* Form info */ @@ -540,36 +759,95 @@ span ~ .panel-header-text { padding-inline-start: 16px; } -.column-panel-content form span { - align-items: center; - block-size: 44px; - display: flex; - flex: 0 0 25%; - justify-content: center; +#edit-sites-assigned { + flex: 1; } -.edit-container-panel label { +#edit-sites-assigned h3 { + font-size: 14px; + font-weight: normal; + padding-block-end: 6px; + padding-block-start: 6px; + padding-inline-end: 16px; + padding-inline-start: 16px; +} + +.assigned-sites-list > div { + display: flex; + padding-block-end: 6px; + padding-block-start: 6px; +} + +.assigned-sites-list > div > .icon { + margin-inline-end: 10px; +} + +.assigned-sites-list > div > .delete-assignment { + display: none; +} + +.assigned-sites-list > div:hover > .delete-assignment { + display: block; +} + +.assigned-sites-list > div > .hostname { + flex: 1; +} + +.radio-choice > .radio-container { + align-items: center; + block-size: 29px; + display: flex; + flex: 0 0 calc(100% / 8); +} + +.radio-choice > .radio-container > label { + background: none; + block-size: 23px; + border: 0; + filter: none; + inline-size: 23px; + margin-block-end: 0; + margin-block-start: 0; + margin-inline-end: 0; + margin-inline-start: 0; + padding-block-end: 0; + padding-block-start: 0; + padding-inline-end: 0; + padding-inline-start: 0; +} + +.radio-choice > .radio-container > label::before { + background-color: unset; background-image: var(--identity-icon); - background-size: 26px 26px; - block-size: 34px; + background-position: center; + background-repeat: no-repeat; + background-size: 16px; + block-size: 23px; + border: none; + content: ""; + display: block; fill: var(--identity-icon-color); filter: url('/img/filters.svg#fill'); - flex: 0 0 34px; + inline-size: 23px; position: relative; } -.edit-container-panel label::before { - opacity: 0 !important; -} - -.edit-container-panel [type="radio"] { +.radio-choice > .radio-container > [type="radio"] { + -moz-appearance: none; display: inline; opacity: 0; } -.edit-container-panel [type="radio"]:checked + label { - outline: 2px solid grey; - -moz-outline-radius: 50px; +.radio-choice > .radio-container > [type="radio"]:checked + label { + background: #d3d3d3; + border-radius: 100%; +} + +/* When focusing the element add a thin blue highlight to match input fields. This gives a distinction to other selected radio items */ +.radio-choice > .radio-container > [type="radio"]:focus + label { + outline: 1px solid #1f9ffc; + -moz-outline-radius: 100%; } .edit-container-panel fieldset { @@ -588,6 +866,10 @@ span ~ .panel-header-text { padding-inline-start: 0; } +.edit-container-panel fieldset:last-of-type { + margin-block-end: 0; +} + .edit-container-panel input[type="text"] { block-size: 36px; border-radius: 3px; @@ -602,5 +884,5 @@ span ~ .panel-header-text { .edit-container-panel legend { flex: 1 0; font-size: 14px !important; - padding-block-end: 5px; + padding-block-end: 6px; } diff --git a/webextension/img/blank-tab.svg b/webextension/img/blank-tab.svg new file mode 100644 index 0000000..351945b --- /dev/null +++ b/webextension/img/blank-tab.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/webextension/img/onboarding-3-security.png b/webextension/img/onboarding-3-security.png new file mode 100644 index 0000000..8bd0205 Binary files /dev/null and b/webextension/img/onboarding-3-security.png differ diff --git a/webextension/js/confirm-page.js b/webextension/js/confirm-page.js index d54dd06..78e427a 100644 --- a/webextension/js/confirm-page.js +++ b/webextension/js/confirm-page.js @@ -1,10 +1,45 @@ -const redirectUrl = new URL(window.location).searchParams.get("url"); -document.getElementById("redirect-url").textContent = redirectUrl; -const redirectSite = new URL(redirectUrl).hostname; -document.getElementById("redirect-site").textContent = redirectSite; +async function load() { + const searchParams = new URL(window.location).searchParams; + const redirectUrl = decodeURIComponent(searchParams.get("url")); + const cookieStoreId = searchParams.get("cookieStoreId"); + const currentCookieStoreId = searchParams.get("currentCookieStoreId"); + const redirectUrlElement = document.getElementById("redirect-url"); + redirectUrlElement.textContent = redirectUrl; + appendFavicon(redirectUrl, redirectUrlElement); -document.getElementById("redirect-form").addEventListener("submit", (e) => { - e.preventDefault(); + const container = await browser.contextualIdentities.get(cookieStoreId); + [...document.querySelectorAll(".container-name")].forEach((containerNameElement) => { + containerNameElement.textContent = container.name; + }); + + // If default container, button will default to normal HTML content + if (currentCookieStoreId) { + const currentContainer = await browser.contextualIdentities.get(currentCookieStoreId); + document.getElementById("current-container-name").textContent = currentContainer.name; + } + + document.getElementById("redirect-form").addEventListener("submit", (e) => { + e.preventDefault(); + const buttonTarget = e.explicitOriginalTarget; + switch (buttonTarget.id) { + case "confirm": + confirmSubmit(redirectUrl, cookieStoreId); + break; + case "deny": + denySubmit(redirectUrl); + break; + } + }); +} + +function appendFavicon(pageUrl, redirectUrlElement) { + const origin = new URL(pageUrl).origin; + const favIconElement = Utils.createFavIconElement(`${origin}/favicon.ico`); + + redirectUrlElement.prepend(favIconElement); +} + +function confirmSubmit(redirectUrl, cookieStoreId) { const neverAsk = document.getElementById("never-ask").checked; // Sending neverAsk message to background to store for next time we see this process if (neverAsk) { @@ -12,20 +47,45 @@ document.getElementById("redirect-form").addEventListener("submit", (e) => { method: "neverAsk", neverAsk: true, pageUrl: redirectUrl - }).then(() => { - redirect(); - }).catch(() => { - // Can't really do much here user will have to click it again }); } browser.runtime.sendMessage({ method: "sendTelemetryPayload", event: "click-to-reload-page-in-container", }); - redirect(); -}); + openInContainer(redirectUrl, cookieStoreId); +} -function redirect() { - const redirectUrl = document.getElementById("redirect-url").textContent; +function getCurrentTab() { + return browser.tabs.query({ + active: true, + windowId: browser.windows.WINDOW_ID_CURRENT + }); +} + +async function denySubmit(redirectUrl) { + const tab = await getCurrentTab(); + await browser.runtime.sendMessage({ + method: "exemptContainerAssignment", + tabId: tab[0].id, + pageUrl: redirectUrl + }); + browser.runtime.sendMessage({ + method: "sendTelemetryPayload", + event: "click-to-reload-page-in-same-container", + }); document.location.replace(redirectUrl); } + +load(); + +async function openInContainer(redirectUrl, cookieStoreId) { + const tab = await getCurrentTab(); + await browser.tabs.create({ + cookieStoreId, + url: redirectUrl + }); + if (tab.length > 0) { + browser.tabs.remove(tab[0].id); + } +} diff --git a/webextension/js/content-script.js b/webextension/js/content-script.js new file mode 100644 index 0000000..8485d30 --- /dev/null +++ b/webextension/js/content-script.js @@ -0,0 +1,42 @@ +async function delayAnimation(delay = 350) { + return new Promise((resolve) => { + setTimeout(resolve, delay); + }); +} + +async function doAnimation(element, property, value) { + return new Promise((resolve) => { + const handler = () => { + resolve(); + element.removeEventListener("transitionend", handler); + }; + element.addEventListener("transitionend", handler); + window.requestAnimationFrame(() => { + element.style[property] = value; + }); + }); +} + +async function addMessage(message) { + const divElement = document.createElement("div"); + divElement.classList.add("container-notification"); + // For the eager eyed, this is an experiment. It is however likely that a website will know it is "contained" anyway + divElement.innerText = message.text; + + const imageElement = document.createElement("img"); + imageElement.src = browser.extension.getURL("/img/container-site-d-24.png"); + divElement.prepend(imageElement); + + document.body.appendChild(divElement); + + await delayAnimation(100); + await doAnimation(divElement, "transform", "translateY(0)"); + await delayAnimation(3000); + await doAnimation(divElement, "transform", "translateY(-100%)"); + + divElement.remove(); +} + +browser.runtime.onMessage.addListener((message) => { + addMessage(message); +}); diff --git a/webextension/js/popup.js b/webextension/js/popup.js index 26afa59..1006dab 100644 --- a/webextension/js/popup.js +++ b/webextension/js/popup.js @@ -7,12 +7,16 @@ const CONTAINER_UNHIDE_SRC = "/img/container-unhide.svg"; const DEFAULT_COLOR = "blue"; const DEFAULT_ICON = "circle"; +const NEW_CONTAINER_ID = "new"; + +const ONBOARDING_STORAGE_KEY = "onboarding-stage"; // List of panels const P_ONBOARDING_1 = "onboarding1"; const P_ONBOARDING_2 = "onboarding2"; const P_ONBOARDING_3 = "onboarding3"; const P_ONBOARDING_4 = "onboarding4"; +const P_ONBOARDING_5 = "onboarding5"; const P_CONTAINERS_LIST = "containersList"; const P_CONTAINERS_EDIT = "containersEdit"; const P_CONTAINER_INFO = "containerInfo"; @@ -69,32 +73,70 @@ const Logic = { _currentPanel: null, _previousPanel: null, _panels: {}, + _onboardingVariation: null, - init() { + async init() { // Remove browserAction "upgraded" badge when opening panel this.clearBrowserActionBadge(); // Retrieve the list of identities. - this.refreshIdentities() + const identitiesPromise = this.refreshIdentities(); + // Get the onboarding variation + const variationPromise = this.getShieldStudyVariation(); + + try { + await Promise.all([identitiesPromise, variationPromise]); + } catch(e) { + throw new Error("Failed to retrieve the identities or variation. We cannot continue. ", e.message); + } // Routing to the correct panel. - .then(() => { - // If localStorage is disabled, we don't show the onboarding. - if (!localStorage || localStorage.getItem("onboarded4")) { - this.showPanel(P_CONTAINERS_LIST); + // If localStorage is disabled, we don't show the onboarding. + const data = await browser.storage.local.get([ONBOARDING_STORAGE_KEY]); + let onboarded = data[ONBOARDING_STORAGE_KEY]; + if (!onboarded) { + // Legacy local storage used before panel 5 + if (localStorage.getItem("onboarded4")) { + onboarded = 4; } else if (localStorage.getItem("onboarded3")) { - this.showPanel(P_ONBOARDING_4); + onboarded = 3; } else if (localStorage.getItem("onboarded2")) { - this.showPanel(P_ONBOARDING_3); + onboarded = 2; } else if (localStorage.getItem("onboarded1")) { - this.showPanel(P_ONBOARDING_2); + onboarded = 1; } else { - this.showPanel(P_ONBOARDING_1); + onboarded = 0; } - }) + this.setOnboardingStage(onboarded); + } - .catch(() => { - throw new Error("Failed to retrieve the identities. We cannot continue."); + switch (onboarded) { + case 5: + this.showPanel(P_CONTAINERS_LIST); + break; + case 4: + this.showPanel(P_ONBOARDING_5); + break; + case 3: + this.showPanel(P_ONBOARDING_4); + break; + case 2: + this.showPanel(P_ONBOARDING_3); + break; + case 1: + this.showPanel(P_ONBOARDING_2); + break; + case 0: + default: + this.showPanel(P_ONBOARDING_1); + break; + } + + }, + + setOnboardingStage(stage) { + return browser.storage.local.set({ + [ONBOARDING_STORAGE_KEY]: stage }); }, @@ -107,8 +149,20 @@ const Logic = { browser.storage.local.set({browserActionBadgesClicked: storage.browserActionBadgesClicked}); }, + async identity(cookieStoreId) { + const identity = await browser.contextualIdentities.get(cookieStoreId); + return identity || { + name: "Default", + cookieStoreId, + icon: "default-tab", + color: "default-tab" + }; + }, + addEnterHandler(element, handler) { - element.addEventListener("click", handler); + element.addEventListener("click", (e) => { + handler(e); + }); element.addEventListener("keydown", (e) => { if (e.keyCode === 13) { handler(e); @@ -121,6 +175,14 @@ const Logic = { return (userContextId !== cookieStoreId) ? Number(userContextId) : false; }, + async currentTab() { + const activeTabs = await browser.tabs.query({active: true, windowId: browser.windows.WINDOW_ID_CURRENT}); + if (activeTabs.length > 0) { + return activeTabs[0]; + } + return false; + }, + refreshIdentities() { return Promise.all([ browser.contextualIdentities.query({}), @@ -139,7 +201,16 @@ const Logic = { }).catch((e) => {throw e;}); }, - showPanel(panel, currentIdentity = null) { + getPanelSelector(panel) { + if (this._onboardingVariation === "securityOnboarding" && + panel.hasOwnProperty("securityPanelSelector")) { + return panel.securityPanelSelector; + } else { + return panel.panelSelector; + } + }, + + async showPanel(panel, currentIdentity = null) { // Invalid panel... ?!? if (!(panel in this._panels)) { throw new Error("Something really bad happened. Unknown panel: " + panel); @@ -151,15 +222,18 @@ const Logic = { this._currentIdentity = currentIdentity; // Initialize the panel before showing it. - this._panels[panel].prepare().then(() => { - for (let panelElement of document.querySelectorAll(".panel")) { // eslint-disable-line prefer-const + await this._panels[panel].prepare(); + Object.keys(this._panels).forEach((panelKey) => { + const panelItem = this._panels[panelKey]; + const panelElement = document.querySelector(this.getPanelSelector(panelItem)); + if (!panelElement.classList.contains("hide")) { panelElement.classList.add("hide"); + if ("unregister" in panelItem) { + panelItem.unregister(); + } } - document.querySelector(this._panels[panel].panelSelector).classList.remove("hide"); - }) - .catch(() => { - throw new Error("Failed to show panel " + panel); }); + document.querySelector(this.getPanelSelector(this._panels[panel])).classList.remove("hide"); }, showPreviousPanel() { @@ -186,6 +260,11 @@ const Logic = { return this._currentIdentity; }, + currentUserContextId() { + const identity = Logic.currentIdentity(); + return Logic.userContextId(identity.cookieStoreId); + }, + sendTelemetryPayload(message = {}) { if (!message.event) { throw new Error("Missing event name for telemetry"); @@ -205,6 +284,38 @@ const Logic = { }); }, + getAssignment(tab) { + return browser.runtime.sendMessage({ + method: "getAssignment", + tabId: tab.id + }); + }, + + getAssignmentObjectByContainer(userContextId) { + return browser.runtime.sendMessage({ + method: "getAssignmentObjectByContainer", + message: {userContextId} + }); + }, + + setOrRemoveAssignment(tabId, url, userContextId, value) { + return browser.runtime.sendMessage({ + method: "setOrRemoveAssignment", + tabId, + url, + userContextId, + value + }); + }, + + getShieldStudyVariation() { + return browser.runtime.sendMessage({ + method: "getShieldStudyVariation" + }).then(variation => { + this._onboardingVariation = variation; + }); + }, + generateIdentityName() { const defaultName = "Container #"; const ids = []; @@ -233,13 +344,16 @@ const Logic = { Logic.registerPanel(P_ONBOARDING_1, { panelSelector: ".onboarding-panel-1", + securityPanelSelector: ".security-onboarding-panel-1", // This method is called when the object is registered. initialize() { // Let's move to the next panel. - Logic.addEnterHandler(document.querySelector("#onboarding-start-button"), () => { - localStorage.setItem("onboarded1", true); - Logic.showPanel(P_ONBOARDING_2); + [...document.querySelectorAll(".onboarding-start-button")].forEach(startElement => { + Logic.addEnterHandler(startElement, async function () { + await Logic.setOnboardingStage(1); + Logic.showPanel(P_ONBOARDING_2); + }); }); }, @@ -254,13 +368,16 @@ Logic.registerPanel(P_ONBOARDING_1, { Logic.registerPanel(P_ONBOARDING_2, { panelSelector: ".onboarding-panel-2", + securityPanelSelector: ".security-onboarding-panel-2", // This method is called when the object is registered. initialize() { // Let's move to the containers list panel. - Logic.addEnterHandler(document.querySelector("#onboarding-next-button"), () => { - localStorage.setItem("onboarded2", true); - Logic.showPanel(P_ONBOARDING_3); + [...document.querySelectorAll(".onboarding-next-button")].forEach(nextElement => { + Logic.addEnterHandler(nextElement, async function () { + await Logic.setOnboardingStage(2); + Logic.showPanel(P_ONBOARDING_3); + }); }); }, @@ -275,13 +392,16 @@ Logic.registerPanel(P_ONBOARDING_2, { Logic.registerPanel(P_ONBOARDING_3, { panelSelector: ".onboarding-panel-3", + securityPanelSelector: ".security-onboarding-panel-3", // This method is called when the object is registered. initialize() { // Let's move to the containers list panel. - Logic.addEnterHandler(document.querySelector("#onboarding-almost-done-button"), () => { - localStorage.setItem("onboarded3", true); - Logic.showPanel(P_ONBOARDING_4); + [...document.querySelectorAll(".onboarding-almost-done-button")].forEach(almostElement => { + Logic.addEnterHandler(almostElement, async function () { + await Logic.setOnboardingStage(3); + Logic.showPanel(P_ONBOARDING_4); + }); }); }, @@ -300,8 +420,29 @@ Logic.registerPanel(P_ONBOARDING_4, { // This method is called when the object is registered. initialize() { // Let's move to the containers list panel. - document.querySelector("#onboarding-done-button").addEventListener("click", () => { - localStorage.setItem("onboarded4", true); + Logic.addEnterHandler(document.querySelector("#onboarding-done-button"), async function () { + await Logic.setOnboardingStage(4); + Logic.showPanel(P_ONBOARDING_5); + }); + }, + + // This method is called when the panel is shown. + prepare() { + return Promise.resolve(null); + }, +}); + +// P_ONBOARDING_5: Fifth page for Onboarding: new tab long-press behavior +// ---------------------------------------------------------------------------- + +Logic.registerPanel(P_ONBOARDING_5, { + panelSelector: ".onboarding-panel-5", + + // This method is called when the object is registered. + initialize() { + // Let's move to the containers list panel. + Logic.addEnterHandler(document.querySelector("#onboarding-longpress-button"), async function () { + await Logic.setOnboardingStage(5); Logic.showPanel(P_CONTAINERS_LIST); }); }, @@ -346,13 +487,13 @@ Logic.registerPanel(P_CONTAINERS_LIST, { function next() { const nextElement = element.nextElementSibling; if (nextElement) { - nextElement.focus(); + nextElement.querySelector("td[tabindex=0]").focus(); } } function previous() { const previousElement = element.previousElementSibling; if (previousElement) { - previousElement.focus(); + previousElement.querySelector("td[tabindex=0]").focus(); } } switch (e.keyCode) { @@ -364,12 +505,72 @@ Logic.registerPanel(P_CONTAINERS_LIST, { break; } }); + + // When the popup is open sometimes the tab will still be updating it's state + this.tabUpdateHandler = (tabId, changeInfo) => { + const propertiesToUpdate = ["title", "favIconUrl"]; + const hasChanged = Object.keys(changeInfo).find((changeInfoKey) => { + if (propertiesToUpdate.includes(changeInfoKey)) { + return true; + } + }); + if (hasChanged) { + this.prepareCurrentTabHeader(); + } + }; + browser.tabs.onUpdated.addListener(this.tabUpdateHandler); + }, + + unregister() { + browser.tabs.onUpdated.removeListener(this.tabUpdateHandler); + }, + + setupAssignmentCheckbox(siteSettings, currentUserContextId) { + const assignmentCheckboxElement = document.getElementById("container-page-assigned"); + let checked = false; + if (siteSettings && Number(siteSettings.userContextId) === currentUserContextId) { + checked = true; + } + assignmentCheckboxElement.checked = checked; + let disabled = false; + if (siteSettings === false) { + disabled = true; + } + assignmentCheckboxElement.disabled = disabled; + }, + + async prepareCurrentTabHeader() { + const currentTab = await Logic.currentTab(); + const currentTabElement = document.getElementById("current-tab"); + const assignmentCheckboxElement = document.getElementById("container-page-assigned"); + const currentTabUserContextId = Logic.userContextId(currentTab.cookieStoreId); + assignmentCheckboxElement.addEventListener("change", () => { + Logic.setOrRemoveAssignment(currentTab.id, currentTab.url, currentTabUserContextId, !assignmentCheckboxElement.checked); + }); + currentTabElement.hidden = !currentTab; + this.setupAssignmentCheckbox(false, currentTabUserContextId); + if (currentTab) { + const identity = await Logic.identity(currentTab.cookieStoreId); + const siteSettings = await Logic.getAssignment(currentTab); + this.setupAssignmentCheckbox(siteSettings, currentTabUserContextId); + const currentPage = document.getElementById("current-page"); + currentPage.innerHTML = escaped`${currentTab.title}`; + const favIconElement = Utils.createFavIconElement(currentTab.favIconUrl || ""); + currentPage.prepend(favIconElement); + + const currentContainer = document.getElementById("current-container"); + currentContainer.innerText = identity.name; + + currentContainer.setAttribute("data-identity-color", identity.color); + } }, // This method is called when the panel is shown. - prepare() { + async prepare() { const fragment = document.createDocumentFragment(); + this.prepareCurrentTabHeader(); + Logic.identities().forEach(identity => { const hasTabs = (identity.hasHiddenTabs || identity.hasOpenTabs); const tr = document.createElement("tr"); @@ -378,10 +579,11 @@ Logic.registerPanel(P_CONTAINERS_LIST, { tr.classList.add("container-panel-row"); - tr.setAttribute("tabindex", "0"); - context.classList.add("userContext-wrapper", "open-newtab", "clickable"); manage.classList.add("show-tabs", "pop-button"); + manage.title = escaped`View ${identity.name} container`; + context.setAttribute("tabindex", "0"); + context.title = escaped`Create ${identity.name} tab`; context.innerHTML = escaped`
-
`; +
`; context.querySelector(".container-name").textContent = identity.name; manage.innerHTML = ""; @@ -422,15 +624,21 @@ Logic.registerPanel(P_CONTAINERS_LIST, { }); }); - const list = document.querySelector(".identities-list"); + const list = document.querySelector(".identities-list tbody"); list.innerHTML = ""; list.appendChild(fragment); /* Not sure why extensions require a focus for the doorhanger, however it allows us to have a tabindex before the first selected item */ - document.addEventListener("focus", () => { - list.querySelector("tr").focus(); + const focusHandler = () => { + list.querySelector("tr .clickable").focus(); + document.removeEventListener("focus", focusHandler); + }; + document.addEventListener("focus", focusHandler); + /* If the user mousedown's first then remove the focus handler */ + document.addEventListener("mousedown", () => { + document.removeEventListener("focus", focusHandler); }); return Promise.resolve(); @@ -453,7 +661,7 @@ Logic.registerPanel(P_CONTAINER_INFO, { const identity = Logic.currentIdentity(); browser.runtime.sendMessage({ method: identity.hasHiddenTabs ? "showTabs" : "hideTabs", - userContextId: Logic.userContextId(identity.cookieStoreId) + userContextId: Logic.currentUserContextId() }).then(() => { window.close(); }).catch(() => { @@ -525,7 +733,7 @@ Logic.registerPanel(P_CONTAINER_INFO, { // Let's retrieve the list of tabs. return browser.runtime.sendMessage({ method: "getTabs", - userContextId: Logic.userContextId(identity.cookieStoreId), + userContextId: Logic.currentUserContextId(), }).then(this.buildInfoTable); }, @@ -537,8 +745,9 @@ Logic.registerPanel(P_CONTAINER_INFO, { fragment.appendChild(tr); tr.classList.add("container-info-tab-row"); tr.innerHTML = escaped` - - ${tab.title}`; + + ${tab.title}`; + tr.querySelector("td").appendChild(Utils.createFavIconElement(tab.favicon)); // On click, we activate this tab. But only if this tab is active. if (tab.active) { @@ -588,22 +797,22 @@ Logic.registerPanel(P_CONTAINERS_EDIT, { data-identity-color="${identity.color}"> -
+
- + `; tr.querySelector(".container-name").textContent = identity.name; - tr.querySelector(".edit-container .pop-button-image").setAttribute("title", `Edit ${identity.name} container`); - tr.querySelector(".remove-container .pop-button-image").setAttribute("title", `Edit ${identity.name} container`); + tr.querySelector(".edit-container").setAttribute("title", `Edit ${identity.name} container`); + tr.querySelector(".remove-container").setAttribute("title", `Delete ${identity.name} container`); Logic.addEnterHandler(tr, e => { @@ -635,7 +844,12 @@ Logic.registerPanel(P_CONTAINER_EDIT, { this.initializeRadioButtons(); Logic.addEnterHandler(document.querySelector("#edit-container-panel-back-arrow"), () => { - Logic.showPreviousPanel(); + const formValues = new FormData(this._editForm); + if (formValues.get("container-id") !== NEW_CONTAINER_ID) { + this._submitForm(); + } else { + Logic.showPreviousPanel(); + } }); Logic.addEnterHandler(document.querySelector("#edit-container-cancel-link"), () => { @@ -644,18 +858,25 @@ Logic.registerPanel(P_CONTAINER_EDIT, { this._editForm = document.getElementById("edit-container-panel-form"); const editLink = document.querySelector("#edit-container-ok-link"); - Logic.addEnterHandler(editLink, this._submitForm.bind(this)); - editLink.addEventListener("submit", this._submitForm.bind(this)); - this._editForm.addEventListener("submit", this._submitForm.bind(this)); + Logic.addEnterHandler(editLink, () => { + this._submitForm(); + }); + editLink.addEventListener("submit", () => { + this._submitForm(); + }); + this._editForm.addEventListener("submit", () => { + this._submitForm(); + }); + + }, _submitForm() { - const identity = Logic.currentIdentity(); const formValues = new FormData(this._editForm); return browser.runtime.sendMessage({ method: "createOrUpdateContainer", message: { - userContextId: Logic.userContextId(identity.cookieStoreId) || false, + 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, @@ -671,6 +892,51 @@ Logic.registerPanel(P_CONTAINER_EDIT, { }); }, + showAssignedContainers(assignments) { + const assignmentPanel = document.getElementById("edit-sites-assigned"); + const assignmentKeys = Object.keys(assignments); + assignmentPanel.hidden = !(assignmentKeys.length > 0); + if (assignments) { + const tableElement = assignmentPanel.querySelector(".assigned-sites-list"); + /* Remove previous assignment list, + after removing one we rerender the list */ + while (tableElement.firstChild) { + tableElement.firstChild.remove(); + } + assignmentKeys.forEach((siteKey) => { + const site = assignments[siteKey]; + const trElement = document.createElement("div"); + /* As we don't have the full or correct path the best we can assume is the path is HTTPS and then replace with a broken icon later if it doesn't load. + This is pending a better solution for favicons from web extensions */ + const assumedUrl = `https://${site.hostname}`; + trElement.innerHTML = escaped` + +
+ ${site.hostname} +
+ `; + const deleteButton = trElement.querySelector(".delete-assignment"); + Logic.addEnterHandler(deleteButton, () => { + 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; + }); + }); + trElement.classList.add("container-info-tab-row", "clickable"); + tableElement.appendChild(trElement); + }); + } + }, + initializeRadioButtons() { const colorRadioTemplate = (containerColor) => { return escaped` @@ -679,7 +945,8 @@ Logic.registerPanel(P_CONTAINER_EDIT, { const colors = ["blue", "turquoise", "green", "yellow", "orange", "red", "pink", "purple" ]; const colorRadioFieldset = document.getElementById("edit-container-panel-choose-color"); colors.forEach((containerColor) => { - const templateInstance = document.createElement("span"); + const templateInstance = document.createElement("div"); + templateInstance.classList.add("radio-container"); // eslint-disable-next-line no-unsanitized/property templateInstance.innerHTML = colorRadioTemplate(containerColor); colorRadioFieldset.appendChild(templateInstance); @@ -692,7 +959,8 @@ Logic.registerPanel(P_CONTAINER_EDIT, { const icons = ["fingerprint", "briefcase", "dollar", "cart", "vacation", "gift", "food", "fruit", "pet", "tree", "chill", "circle"]; const iconRadioFieldset = document.getElementById("edit-container-panel-choose-icon"); icons.forEach((containerIcon) => { - const templateInstance = document.createElement("span"); + const templateInstance = document.createElement("div"); + templateInstance.classList.add("radio-container"); // eslint-disable-next-line no-unsanitized/property templateInstance.innerHTML = iconRadioTemplate(containerIcon); iconRadioFieldset.appendChild(templateInstance); @@ -700,9 +968,16 @@ Logic.registerPanel(P_CONTAINER_EDIT, { }, // This method is called when the panel is shown. - prepare() { + async prepare() { const identity = Logic.currentIdentity(); + + const userContextId = Logic.currentUserContextId(); + const assignments = await Logic.getAssignmentObjectByContainer(userContextId); + this.showAssignedContainers(assignments); + document.querySelector("#edit-container-panel .panel-footer").hidden = !!userContextId; + document.querySelector("#edit-container-panel-name-input").value = identity.name || ""; + document.querySelector("#edit-container-panel-usercontext-input").value = userContextId || NEW_CONTAINER_ID; [...document.querySelectorAll("[name='container-color']")].forEach(colorInput => { colorInput.checked = colorInput.value === identity.color; }); diff --git a/webextension/js/utils.js b/webextension/js/utils.js new file mode 100644 index 0000000..5d5046b --- /dev/null +++ b/webextension/js/utils.js @@ -0,0 +1,23 @@ +const DEFAULT_FAVICON = "moz-icon://goat?size=16"; + +// TODO use export here instead of globals +window.Utils = { + + createFavIconElement(url) { + const imageElement = document.createElement("img"); + imageElement.classList.add("icon", "offpage"); + imageElement.src = url; + const loadListener = (e) => { + e.target.classList.remove("offpage"); + e.target.removeEventListener("load", loadListener); + e.target.removeEventListener("error", errorListener); + }; + const errorListener = (e) => { + e.target.src = DEFAULT_FAVICON; + }; + imageElement.addEventListener("error", errorListener); + imageElement.addEventListener("load", loadListener); + return imageElement; + } + +}; diff --git a/webextension/manifest.json b/webextension/manifest.json index b99a5f3..7722b43 100644 --- a/webextension/manifest.json +++ b/webextension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Containers Experiment", - "version": "2.3.0", + "version": "2.4.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": { @@ -26,7 +26,6 @@ "contextualIdentities", "history", "idle", - "notifications", "storage", "tabs", "webRequestBlocking", @@ -36,7 +35,8 @@ "commands": { "_execute_browser_action": { "suggested_key": { - "default": "Ctrl+Y" + "default": "Ctrl+Period", + "mac": "MacCtrl+Period" }, "description": "Open containers panel" } @@ -54,5 +54,18 @@ "background": { "scripts": ["background.js"] - } + }, + + "content_scripts": [ + { + "matches": [""], + "js": ["js/content-script.js"], + "css": ["css/content.css"], + "run_at": "document_start" + } + ], + + "web_accessible_resources": [ + "/img/container-site-d-24.png" + ] } diff --git a/webextension/popup.html b/webextension/popup.html index 794c9b0..8123f97 100644 --- a/webextension/popup.html +++ b/webextension/popup.html @@ -3,56 +3,96 @@ Containers browserAction Popup + -
+
Container Tabs Overview

A better way to manage all the things you do online

Use containers to organize tasks, manage accounts, and keep your focus where you want it.

- Get Started + Get Started
+
+ Container Tabs Overview +

A simple and secure way to manage your online life

+

+ Use containers to organize tasks, manage accounts, and store sensitive data. +

+ Get Started +
-
+
How Containers Work

Put containers to work for you.

Features like color-coding and separate container tabs help you find things easily, focus your attention, and minimize distractions.

- Next + Next
-
+
+ How Containers Work +

Put containers to work for you.

+

Color-coding helps you categorize your online life, find things easily, and minimize distractions.

+ Next +
+ +
How Containers Work

A place for everything, and everything in its place.

Start with the containers we've created, or create your own.

- Next + Next +
+ +
+ How Containers Work +

Set boundaries for your browsing.

+

Cookies are stored within a container, so you can segment sensitive data and browsing history to stay organized and to limit the impact of online trackers.

+ Next
How to assign sites to containers

Always open sites in the containers you want.

Right-click inside a container tab to assign the site to always open in the container.

- Done + Next +
+ +
+ Long-press the New Tab button to create a new container tab. +

Container tabs when you need them.

+

Long-press the New Tab button to create a new container tab.

+ Done
-
-

Containers

- Sort Containers +
+

Current Tab

+
+ +
+
- - +
+
@@ -66,7 +106,7 @@
-

+

Hide Container icon @@ -104,17 +144,23 @@
+
Name
-
+
Choose a color
-
+
Choose an icon
+
- +