diff --git a/.eslintignore b/.eslintignore index 9f1953c..3a5b5a0 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ lib/testpilot/*.js +coverage \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 037d812..88975d9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -47,7 +47,7 @@ module.exports = { "error", { "escape": { - "taggedTemplates": ["escaped"] + "taggedTemplates": ["Utils.escaped"] } } ], diff --git a/.gitignore b/.gitignore index c745683..ff6bfab 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ src/web-ext-artifacts/* # JetBrains IDE files .idea + +# IstanbulJS +.nyc_output +coverage \ No newline at end of file diff --git a/.stylelintrc b/.stylelintrc index 5acc3e5..d30a871 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -9,7 +9,12 @@ "rules": { "declaration-block-no-duplicate-properties": true, - "order/declaration-block-properties-alphabetical-order": true, + "order/properties-alphabetical-order": true, + "property-no-unknown": [ + true, { + ignoreProperties: + ["inset-block-end", "inset-block-start"] + }], "property-blacklist": [ "/(min[-]|max[-])height/", "/width/", diff --git a/README.md b/README.md index 7574cf0..47dce0c 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,18 @@ For more info, see: ## Development 1. `npm install` -2. `./node_modules/.bin/web-ext run -s src/` +2. `./node_modules/web-ext/bin/web-ext run -s src/` ### Testing -TBD +`npm run test` + +or + +`npm run lint` + +for just the linter + +There is a timeout test that sometimes fails on certain machines, so make sure to run the tests on your clone before you make any changes to see if you have this problem. ### Distributing #### Make the new version @@ -51,6 +59,6 @@ Finally, we also publish the release to GitHub for those followers. Facebook & Twitter icons CC-Attrib https://fairheadcreative.com. -- [Licence](./LICENSE.txt) +- [License](./LICENSE.txt) - [Contributing](./CONTRIBUTING.md) - [Code Of Conduct](./CODE_OF_CONDUCT.md) diff --git a/package.json b/package.json index 172a4f5..47aaeb6 100644 --- a/package.json +++ b/package.json @@ -2,30 +2,31 @@ "name": "testpilot-containers", "title": "Multi-Account Containers", "description": "Containers helps you keep all the parts of your online life contained in different tabs. Custom labels and color-coded tabs help keep different activities — like online shopping, travel planning, or checking work email — separate.", - "version": "6.1.1", + "version": "7.1.0", "author": "Andrea Marchesini, Luke Crouch and Jonathan Kingston", "bugs": { "url": "https://github.com/mozilla/multi-account-containers/issues" }, "dependencies": {}, "devDependencies": { - "ajv": "^6.6.2", "addons-linter": "^1.3.2", - "chai": "^4.1.2", - "eslint": "^3.17.1", + "ajv": "^6.6.3", + "chai": "^4.2.0", + "eslint": "^6.6.0", "eslint-plugin-no-unsanitized": "^2.0.0", "eslint-plugin-promise": "^3.4.0", "htmllint-cli": "0.0.7", - "jsdom": "^11.6.2", "json": "^9.0.6", - "mocha": "^5.0.0", + "mocha": "^6.2.2", "npm-run-all": "^4.0.0", - "sinon": "^4.4.0", - "sinon-chai": "^2.14.0", - "stylelint": "^7.9.0", - "stylelint-config-standard": "^16.0.0", - "stylelint-order": "^0.3.0", - "web-ext": "^2.2.2" + "nyc": "^15.0.0", + "sinon": "^7.5.0", + "sinon-chai": "^3.3.0", + "stylelint-order": "^4.0.0", + "stylelint": "^13.5.0", + "stylelint-config-standard": "^20.0.0", + "web-ext": "^2.9.3", + "webextensions-jsdom": "^1.2.1" }, "homepage": "https://github.com/mozilla/multi-account-containers#readme", "license": "MPL-2.0", @@ -36,13 +37,16 @@ }, "scripts": { "build": "npm test && cd src && web-ext build --overwrite-dest", + "webext": "web-ext run -s src/", "lint": "npm-run-all lint:*", "lint:addon": "addons-linter src --self-hosted", "lint:css": "stylelint src/css/*.css", "lint:html": "htmllint *.html", "lint:js": "eslint .", "package": "rm -rf src/web-ext-artifacts && npm run build && mv src/web-ext-artifacts/firefox_multi-account_containers-*.zip addon.xpi", - "test": "npm run lint && mocha ./test/setup.js test/**/*.test.js", - "test-watch": "mocha ./test/setup.js test/**/*.test.js --watch" + "test": "npm run lint && npm run coverage", + "test:once": "mocha test/**/*.test.js", + "test:watch": "npm run test:once -- --watch", + "coverage": "nyc --reporter=html --reporter=text mocha test/**/*.test.js --timeout 60000" } } diff --git a/src/css/confirm-page.css b/src/css/confirm-page.css index e00eb1c..113ffef 100644 --- a/src/css/confirm-page.css +++ b/src/css/confirm-page.css @@ -60,6 +60,7 @@ html { @media (prefers-color-scheme: dark) { #redirect-url { background: #38383d; /* Grey 70 */ + color: #eee; /* White 20 */ } } /* stylelint-enable */ diff --git a/src/css/options.css b/src/css/options.css new file mode 100644 index 0000000..9346afe --- /dev/null +++ b/src/css/options.css @@ -0,0 +1,29 @@ +body { + background: #fff; + color: #202023; +} + +h3 { + margin-block-start: 2.5rem; +} + +h3:first-of-type { + margin-block-start: 1rem; +} + +p, +label { + color: rgb(74, 74, 79); +} + +@media (prefers-color-scheme: dark) { + body { + background: #202023; + color: #fff; + } + + p, + label { + color: rgb(177, 177, 179); + } +} diff --git a/src/css/popup.css b/src/css/popup.css index 5f45f10..543936e 100644 --- a/src/css/popup.css +++ b/src/css/popup.css @@ -18,9 +18,18 @@ html { } body { + color: #000; font-family: Roboto, Noto, "San Francisco", Ubuntu, "Segoe UI", "Fira Sans", message-box, Arial, sans-serif; + font-size: 13px; inline-size: calc(var(--overflow-size) + 299px); + + /* inline-size: 320px; */ + letter-spacing: -0.1px; max-inline-size: calc(var(--overflow-size) + 299px); + + --highlight-blue: #1296f8; + --hr-grey: #e3e3e3; + --text-grey: #737373; } html, @@ -46,7 +55,6 @@ body { --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 */ @@ -91,7 +99,7 @@ table { } .scrollable { - border-block-start: 1px solid #f1f1f1; + flex: 1; inline-size: 100%; max-block-size: 400px; overflow: auto; @@ -107,11 +115,28 @@ table { /* 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); + inline-size: 100%; overflow: hidden; + position: relative; white-space: nowrap; } +.truncate-text::after { + background: white; + content: " "; + height: 100%; + inline-size: 50px; + inset-inline-end: 0; + mask-image: linear-gradient(to right, transparent, white 70%); + position: absolute; +} + +.hover-highlight:hover .truncate-text::after, +.hover-highlight:focus .truncate-text::after { + background: var(--highlight-blue); + mask-image: linear-gradient(to right, transparent, var(--highlight-blue) 50%); +} + /* Color and icon helpers */ [data-identity-color="blue"] { --identity-tab-color: #37adff; @@ -206,6 +231,10 @@ table { --identity-icon: url("/img/usercontext.svg#chill"); } +[data-identity-icon="fence"] { + --identity-icon: url("/img/usercontext.svg#fence"); +} + #current-tab [data-identity-icon="default-tab"] { background: center center no-repeat url("/img/blank-tab.svg"); fill: currentColor; @@ -231,18 +260,6 @@ 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 together */ .panel { display: flex; @@ -262,29 +279,11 @@ table { min-block-size: 360px; } -.panel .columns { - display: flex; - flex: 1; -} - .panel-content { flex: 1; + padding-block-start: 16px; } -/* Column panels for edit screens */ -.column-panel-content { - display: flex; - flex-direction: column; - inline-size: var(--column-panel-inline-size); -} - -.column-panel-content .panel-footer { - align-items: center; - display: flex; - justify-content: center; -} - -.column-panel-content .button, .panel-footer .button { align-items: center; block-size: 100%; @@ -293,28 +292,6 @@ table { justify-content: center; } -/* Column panels have a special back arrow */ -.panel-back-arrow { - align-items: center; - background: #ebebeb; - box-shadow: inset -2px 0 4px -2px rgba(0, 0, 0, 0.4); - display: flex; - flex: 0 0 32px; - flex-direction: column; - justify-content: center; -} - -.panel-back-arrow:hover, -.panel-back-arrow:focus { - background: #dedede; -} - -.back-arrow-img { - block-size: 16px; - inline-size: 16px; - transform: rotate(180deg); -} - /* Onboarding styles */ .onboarding * { text-align: center; @@ -356,11 +333,45 @@ table { transition: background-color 75ms; } +.half-button-wrapper { + align-items: center; + display: flex; + flex-direction: row; + height: 44px; + inline-size: 100%; +} + +.half-onboarding-button { + align-items: center; + background-color: #0996f8; + border-radius: 3px; + color: white; + display: flex; + flex: 1 0 auto; + font-size: 14px; + height: 44px; + inline-size: 50%; + justify-content: center; + margin-inline-end: 4px; + text-decoration: none; + transition: background-color 75ms; +} + +.grey-button { + background-color: #e3e3e3; + color: #000; +} + .onboarding-button:hover, .onboarding-button:active { background-color: #0675d3; } +.onboarding-button:focus, +.half-onboarding-button:focus { + box-shadow: 0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 0 0 0 4px rgba(10, 132, 255, 0.3); +} + /* Pop buttons are the square shaped buttons used to manage things like container crud */ .pop-button { @@ -372,226 +383,10 @@ manage things like container crud */ justify-content: center; } -.pop-button:hover, -.pop-button:focus, -.panel-footer-secondary:focus, -.panel-footer-secondary:hover { - background-color: rgba(0, 0, 0, 0.05); -} - -.pop-button:focus, -.panel-footer-secondary:focus { - background-color: rgba(0, 0, 0, 0.08); -} - -.pop-button a, -.panel-footer a, -.panel-footer-secondary a { +.panel-footer a { text-decoration: none; } -.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 { - block-size: 12px; - flex: 0 0 12px; -} - -/* Panel Header */ -.panel-header { - align-items: center; - block-size: 48px; - 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: var(--text-normal-color); - flex: 1; - font-size: var(--font-size-heading); - font-weight: normal; - margin-block-end: 0; - margin-block-start: 0; - margin-inline-end: 0; - margin-inline-start: 0; - padding-block-end: 16px; - padding-block-start: 16px; - padding-inline-end: 16px; - 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; - padding-inline-end: 0; - 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("/img/check.svg"); - 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; - border-block-end: 1px solid #f1f1f1; - box-sizing: border-box; - display: flex; - justify-content: space-between; -} - -.container-panel-row .container-name { - flex: 1; - max-inline-size: 160px; - padding-inline-end: 4px; - padding-inline-start: 4px; -} - -.edit-containers-panel .userContext-wrapper { - max-inline-size: calc(var(--overflow-size) + 203px); -} - -.disable-edit-containers { - opacity: var(--inactive-opacity); - pointer-events: none; -} - .userContext-wrapper { align-items: center; display: flex; @@ -599,9 +394,13 @@ span ~ .panel-header-text { transition: background-color 75ms; } -.container-panel-row:hover .clickable.userContext-wrapper, -.container-panel-row:focus .clickable.userContext-wrapper { - background: #f2f2f2; +.edit-containers-panel .userContext-wrapper { + max-inline-size: calc(var(--overflow-size) + 203px); +} + +.disable-edit-containers { + opacity: var(--inactive-opacity); + pointer-events: none; } .userContext-icon-wrapper { @@ -615,12 +414,20 @@ span ~ .panel-header-text { background-image: var(--identity-icon); background-position: center center; background-repeat: no-repeat; - background-size: 20px 20px; + background-size: 16px; block-size: 100%; fill: var(--identity-icon-color); filter: url('/img/filters.svg#fill'); } +.mac-icon { + background-image: url('/img/multiaccountcontainer-16.svg'); + background-position: center center; + background-repeat: no-repeat; + background-size: 16px; + block-size: 100%; +} + .container-panel-row:hover .clickable .usercontext-icon, .container-panel-row:focus .clickable .usercontext-icon, .container-panel-row .clickable:focus .usercontext-icon { @@ -647,62 +454,6 @@ span ~ .panel-header-text { justify-content: space-between; } -.edit-containers-text { - align-items: center; - block-size: 100%; - border-inline-end: solid 1px #d8d8d8; - display: flex; - flex: 1; - justify-content: center; -} - -.edit-containers-text a { - align-items: center; - block-size: 100%; - color: #0a0a0a; - display: flex; - flex: 1; - justify-content: center; -} - -/* Container info list */ -.container-info-tab-title { - display: flex; -} - -.container-info-tab-row:hover .container-info-tab-title .truncate-text { - inline-size: calc(var(--column-panel-inline-size) - 58px); -} - -#container-info-hideorshow { - margin-block-start: 4px; -} - -#container-info-movetabs-incompat { - font-size: 10px; - opacity: 0.3; -} - -.container-info-tab-row:not(.clickable), -.select-row:not(.clickable) { - opacity: 0.3; -} - -.container-close-tab { - transform: scale(0.7); - visibility: collapse; -} - -.container-info-tab-row:hover .container-close-tab { - opacity: 0.5; - visibility: visible; -} - -.container-info-tab-row .container-close-tab:hover { - opacity: 1; - visibility: visible; -} - .container-info-has-tabs, .container-info-tab-row { align-items: center; @@ -729,48 +480,6 @@ span ~ .panel-header-text { margin-inline-end: 0; } -.container-info-list { - display: flex; - flex-direction: column; - margin-block-start: 4px; - padding-block-start: 4px; -} - -.container-info-list tbody { - display: contents; -} - -.clickable { - cursor: pointer; -} - -.clickable:hover, -.clickable:focus { - background-color: #ebebeb; -} - -.edit-containers-exit-text { - align-items: center; - background: var(--primary-action-color); - block-size: 100%; - color: #fff; - display: flex; - flex: 1; - justify-content: center; -} - -.exit-edit-mode-link::before { - background: url('/img/container-arrow.svg') no-repeat; - block-size: 16px; - content: ""; - display: block; - filter: grayscale(100%) brightness(5); - float: left; - inline-size: 16px; - margin-inline-end: 5px; - transform: scaleX(-1); -} - .delete-container-confirm { padding-inline-end: 20px; padding-inline-start: 20px; @@ -781,23 +490,6 @@ span ~ .panel-header-text { font-size: var(--font-size-heading); } -/* Form info */ -.column-panel-content form { - flex: 1; - padding-block-end: 16px; - padding-block-start: 16px; - padding-inline-end: 16px; - padding-inline-start: 16px; -} - -.edit-container-panel .columns { - overflow: hidden; /* Bugfix: issue 948 */ -} - -#edit-sites-assigned { - flex: 1000; /* Bugfix: issue 948 */ -} - #edit-sites-assigned h3 { font-size: 14px; font-weight: normal; @@ -817,21 +509,13 @@ span ~ .panel-header-text { 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; + block-size: 25px; display: flex; flex: 0 0 calc(100% / var(--icon-fit)); } @@ -891,7 +575,7 @@ span ~ .panel-header-text { display: flex; flex-direction: row; flex-wrap: wrap; - inline-size: 100%; + inline-size: 80%; margin-block-end: 10px; margin-inline-end: 0; margin-inline-start: 0; @@ -916,10 +600,17 @@ span ~ .panel-header-text { padding-inline-start: 5px; } -.edit-container-panel legend { +.edit-container-panel legend, +.options-header { flex: 1 0; font-size: 14px !important; - padding-block-end: 6px; + margin-block-end: 4px; + margin-block-start: -6px; +} + +.options-header { + margin-block-end: 8px; + margin-block-start: 6px; } /* Achievement panel elements */ @@ -971,3 +662,272 @@ span ~ .panel-header-text { .amo-rate-cta { background: #0f1126; } + +h3.title { + block-size: 40px; + color: #000; + font-size: 13px; + font-weight: bold; + inline-size: 100%; + letter-spacing: -0.1px; + line-height: 40px; + text-align: center; +} + +.menu { + border-style: none; + inline-size: 100%; +} + +.menu-item { + cursor: pointer; + height: 24px; + inline-size: 100%; + line-height: 24px; +} + +.menu-item td { + display: flex; + max-inline-size: 300px; +} + +.disabled-menu-item { + color: grey; + cursor: default; + font-style: italic; +} + +.hover-highlight:hover, +.hover-highlight:focus { + background: var(--highlight-blue); + color: #fff; +} + +.menu-item-name { + display: flex; + inline-size: calc(100% - 40px); + max-inline-size: 260px; +} + +.menu-text { + line-height: 24px; +} + +.menu-icon { + display: block; + height: 16px; + inline-size: 23px; + margin-block-end: 4px; + margin-block-start: 4px; + margin-inline-end: 8px; + margin-inline-start: 16px; + text-align: center; +} + +/* Maintain 1:1 square ratio for Favicons of websites added to a specific container */ +#edit-sites-assigned .menu-icon, +#container-info-table .menu-icon { + inline-size: 16px; +} + +.menu-right-float { + height: 24px; + inline-size: 60px; + text-align: right; +} + +.container-count { + opacity: 0.6; + padding-block-end: 0; + padding-block-start: 0; + padding-inline-end: 6px; + padding-inline-start: 0; + text-align: right; +} + +.menu-arrow { + display: inline-block; + float: right; + height: 24px; + inline-size: 18px; + padding-block-end: 6px; + padding-block-start: 6px; + padding-inline-end: 12px; + padding-inline-start: 0; + text-align: center; +} + +.menu-arrow img { + height: 12px; + inline-size: 12px; + padding-block-end: 2px; + padding-block-start: 2px; + padding-inline-end: 2px; + padding-inline-start: 2px; +} + +hr { + border: 0; + border-block-start: 1px solid var(--hr-grey); + display: block; + margin-block-end: 0; + margin-block-start: 6px; + margin-inline-end: 0; + margin-inline-start: 0; + padding-block-end: 6px; + padding-block-start: 0; + padding-inline-end: 0; + padding-inline-start: 0; +} + +.sub-header { + color: var(--text-grey); + height: 24px; + line-height: 24px; + padding-block-end: 0; + padding-block-start: 0; + padding-inline-end: 16px; + padding-inline-start: 16px; +} + +.edit-form { + color: var(--text-grey); + flex: 1; + padding-block-end: 0; + padding-block-start: 0; + padding-inline-end: 16px; + padding-inline-start: 16px; +} + +.identities-list { + margin-block-end: 41px; + margin-block-start: 0; + margin-inline-end: 0; + margin-inline-start: 0; +} + +.bottom-btn { + background-color: var(--hr-grey); + border: solid 1px #d9d9d9; + cursor: pointer; + height: 41px; + inline-size: 100%; + inset-block-end: 0; + line-height: 41px; + padding-block-end: 0; + padding-block-start: 0; + padding-inline-end: 16px; + padding-inline-start: 16px; + position: fixed; +} + +.delete-container { + background-color: #fff; + border-block-start: solid 1px var(--hr-grey); + cursor: default; + display: flex; + height: 65px; + inline-size: 100%; + justify-content: space-between; + padding-block-end: 27px; + padding-block-start: 9px; + padding-inline-end: 18px; + padding-inline-start: 17px; +} + +.delete-btn { + background-color: rgba(12, 12, 13, 0.1); + border: 0; + border-radius: 2px; + cursor: pointer; + height: 30px; + inline-size: 100%; + line-height: 30px; + text-align: center; +} + +.btn-return.arrow-left { + background-color: rgba(255, 255, 255, 1); + background-image: url("/img/arrow-icon-left.svg"); + border: 0; + cursor: pointer; + height: 1.2rem; + inline-size: 1.2rem; + inset-block-start: 15px; + left: 15px; + position: absolute; +} + +input { + border: solid 1px #bebebe; + border-radius: 2px; +} + +.form-header { + height: 23px; + line-height: 23px; + padding-block-end: 0; + padding-block-start: 0; + padding-inline-end: 0; + padding-inline-start: 0; +} + +.edit-container-panel-name-input { + height: 29px; +} + +.container-options { + height: 23px; +} + +.site-isolation { + inset-block-end: auto; + position: fixed; +} + +.options-label { + cursor: pointer; + padding-inline-start: 25px; +} + +.manage-assigned-sites-list { + color: var(--highlight-blue); +} + +.info-icon { + cursor: pointer; + height: 16px; + inline-size: 16px; + inset-block-start: 13px; + position: absolute; + right: 13px; + text-align: center; + text-decoration: none; +} + +.delete-warning { + padding-block-end: 8px; + padding-block-start: 8px; + padding-inline-end: 0; + padding-inline-start: 0; +} + +.trash-button { + display: inline-block; + float: right; + height: 16px; + inline-size: 16px; + margin-block-end: 4px; + margin-block-start: 4px; + margin-inline-end: 10px; + margin-inline-start: 0; + text-align: center; +} + +tr > td > .trash-button { + display: none; +} + +tr:hover > td > .trash-button { + display: block; +} diff --git a/src/img/Account.svg b/src/img/Account.svg new file mode 100644 index 0000000..27d6146 --- /dev/null +++ b/src/img/Account.svg @@ -0,0 +1,3 @@ +account \ No newline at end of file diff --git a/src/img/Sync.svg b/src/img/Sync.svg new file mode 100644 index 0000000..d67f786 --- /dev/null +++ b/src/img/Sync.svg @@ -0,0 +1,3 @@ +Sync \ No newline at end of file diff --git a/src/img/amo-icon.svg b/src/img/amo-icon.svg index 69357cf..bafd00e 100644 --- a/src/img/amo-icon.svg +++ b/src/img/amo-icon.svg @@ -1 +1,3 @@ - Created with Sketch. \ No newline at end of file + Created with Sketch. \ No newline at end of file diff --git a/src/img/arrow-icon-left.svg b/src/img/arrow-icon-left.svg new file mode 100644 index 0000000..2060238 --- /dev/null +++ b/src/img/arrow-icon-left.svg @@ -0,0 +1,3 @@ + diff --git a/src/img/arrow-icon-right.svg b/src/img/arrow-icon-right.svg new file mode 100644 index 0000000..fa99e25 --- /dev/null +++ b/src/img/arrow-icon-right.svg @@ -0,0 +1,24 @@ + + + + + Arrow + Created with Sketch. + + + + + + + + + + + + + + + + diff --git a/src/img/blank-tab.svg b/src/img/blank-tab.svg index 351945b..bb3d5eb 100644 --- a/src/img/blank-tab.svg +++ b/src/img/blank-tab.svg @@ -1,3 +1,5 @@ - + \ No newline at end of file diff --git a/src/img/container-add.svg b/src/img/container-add.svg deleted file mode 100644 index 2c18378..0000000 --- a/src/img/container-add.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - -firefox - - - - diff --git a/src/img/container-arrow.svg b/src/img/container-arrow.svg deleted file mode 100644 index 2a4e2ba..0000000 --- a/src/img/container-arrow.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - diff --git a/src/img/container-close-tab.svg b/src/img/container-close-tab.svg index a36fa01..4c8aebc 100644 --- a/src/img/container-close-tab.svg +++ b/src/img/container-close-tab.svg @@ -1,3 +1,5 @@ - - + + \ No newline at end of file diff --git a/src/img/container-delete.svg b/src/img/container-delete.svg index 1e685d5..1e67c8e 100644 --- a/src/img/container-delete.svg +++ b/src/img/container-delete.svg @@ -1,10 +1,7 @@ - - - - + diff --git a/src/img/container-edit.svg b/src/img/container-edit.svg deleted file mode 100644 index 52c1df4..0000000 --- a/src/img/container-edit.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - diff --git a/src/img/container-hide.svg b/src/img/container-hide.svg deleted file mode 100644 index a684eaf..0000000 --- a/src/img/container-hide.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/img/container-newtab.svg b/src/img/container-newtab.svg index 477ace6..f41e140 100644 --- a/src/img/container-newtab.svg +++ b/src/img/container-newtab.svg @@ -1,4 +1,7 @@ + diff --git a/src/img/container-openin-16.svg b/src/img/container-openin-16.svg new file mode 100644 index 0000000..6786b5e --- /dev/null +++ b/src/img/container-openin-16.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/src/img/container-site-d-192.png b/src/img/container-site-d-192.png deleted file mode 100644 index f1b56c8..0000000 Binary files a/src/img/container-site-d-192.png and /dev/null differ diff --git a/src/img/container-site-light.svg b/src/img/container-site-light.svg deleted file mode 100644 index 9d5e4c3..0000000 --- a/src/img/container-site-light.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/img/container-site-w-192.png b/src/img/container-site-w-192.png deleted file mode 100644 index 3a96317..0000000 Binary files a/src/img/container-site-w-192.png and /dev/null differ diff --git a/src/img/container-site-w-24.png b/src/img/container-site-w-24.png deleted file mode 100644 index 95c9d75..0000000 Binary files a/src/img/container-site-w-24.png and /dev/null differ diff --git a/src/img/container-site-w-48.png b/src/img/container-site-w-48.png deleted file mode 100644 index d3cfc6f..0000000 Binary files a/src/img/container-site-w-48.png and /dev/null differ diff --git a/src/img/container-site-w-96.png b/src/img/container-site-w-96.png deleted file mode 100644 index e137c22..0000000 Binary files a/src/img/container-site-w-96.png and /dev/null differ diff --git a/src/img/container-site.svg b/src/img/container-site.svg deleted file mode 100644 index 2464a00..0000000 --- a/src/img/container-site.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/img/container-sort.svg b/src/img/container-sort.svg deleted file mode 100644 index 3d14a48..0000000 --- a/src/img/container-sort.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - diff --git a/src/img/container-unhide.svg b/src/img/container-unhide.svg deleted file mode 100644 index d12b9a3..0000000 --- a/src/img/container-unhide.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - diff --git a/src/img/info-thin-16.svg b/src/img/info-thin-16.svg new file mode 100644 index 0000000..c3edf49 --- /dev/null +++ b/src/img/info-thin-16.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/src/img/movetowindow-16.svg b/src/img/movetowindow-16.svg new file mode 100644 index 0000000..80181a3 --- /dev/null +++ b/src/img/movetowindow-16.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/img/multiaccountcontainer-16-dark.svg b/src/img/multiaccountcontainer-16-dark.svg new file mode 100644 index 0000000..3c1e24c --- /dev/null +++ b/src/img/multiaccountcontainer-16-dark.svg @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/src/img/multiaccountcontainer-16.svg b/src/img/multiaccountcontainer-16.svg new file mode 100644 index 0000000..d6a13d1 --- /dev/null +++ b/src/img/multiaccountcontainer-16.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/img/check.svg b/src/img/new-16.svg similarity index 63% rename from src/img/check.svg rename to src/img/new-16.svg index bcbcfc0..b759168 100644 --- a/src/img/check.svg +++ b/src/img/new-16.svg @@ -2,5 +2,5 @@ - 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/. --> - + diff --git a/src/img/password-hide.svg b/src/img/password-hide.svg new file mode 100644 index 0000000..af7818d --- /dev/null +++ b/src/img/password-hide.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/src/img/refresh-16.svg b/src/img/refresh-16.svg new file mode 100644 index 0000000..2e40ef6 --- /dev/null +++ b/src/img/refresh-16.svg @@ -0,0 +1,6 @@ + + + + diff --git a/src/img/sort-16_1.svg b/src/img/sort-16_1.svg new file mode 100644 index 0000000..83ae0ee --- /dev/null +++ b/src/img/sort-16_1.svg @@ -0,0 +1,6 @@ + + + + diff --git a/src/img/tab-new-16.svg b/src/img/tab-new-16.svg new file mode 100644 index 0000000..d8c7ba6 --- /dev/null +++ b/src/img/tab-new-16.svg @@ -0,0 +1,6 @@ + + + + diff --git a/src/img/usercontext.svg b/src/img/usercontext.svg index f58067a..fb48e4a 100644 --- a/src/img/usercontext.svg +++ b/src/img/usercontext.svg @@ -13,6 +13,7 @@ display: none; } + + + + + + + + + diff --git a/src/img/webicon-facebook.svg b/src/img/webicon-facebook.svg index 39a3cae..c87adaf 100755 --- a/src/img/webicon-facebook.svg +++ b/src/img/webicon-facebook.svg @@ -1,4 +1,7 @@ + diff --git a/src/img/webicon-twitter.svg b/src/img/webicon-twitter.svg index 6b05f3f..6636eaa 100755 --- a/src/img/webicon-twitter.svg +++ b/src/img/webicon-twitter.svg @@ -1,4 +1,7 @@ + diff --git a/src/js/.eslintrc.js b/src/js/.eslintrc.js index f78079f..4941e75 100644 --- a/src/js/.eslintrc.js +++ b/src/js/.eslintrc.js @@ -7,6 +7,8 @@ module.exports = { "badge": true, "backgroundLogic": true, "identityState": true, - "messageHandler": true + "messageHandler": true, + "sync": true, + "Utils": true } }; diff --git a/src/js/background/assignManager.js b/src/js/background/assignManager.js index d24ed50..1ac5bc5 100644 --- a/src/js/background/assignManager.js +++ b/src/js/background/assignManager.js @@ -1,16 +1,17 @@ -const assignManager = { +window.assignManager = { MENU_ASSIGN_ID: "open-in-this-container", MENU_REMOVE_ID: "remove-open-in-this-container", MENU_SEPARATOR_ID: "separator", MENU_HIDE_ID: "hide-container", MENU_MOVE_ID: "move-to-new-window-container", - + OPEN_IN_CONTAINER: "open-bookmark-in-container-tab", storageArea: { area: browser.storage.local, exemptedTabs: {}, - getSiteStoreKey(pageUrl) { - const url = new window.URL(pageUrl); + getSiteStoreKey(pageUrlorUrlKey) { + if (pageUrlorUrlKey.includes("siteContainerMap@@_")) return pageUrlorUrlKey; + const url = new window.URL(pageUrlorUrlKey); const storagePrefix = "siteContainerMap@@_"; if (url.port === "80" || url.port === "443") { return `${storagePrefix}${url.hostname}`; @@ -19,29 +20,43 @@ const assignManager = { } }, - setExempted(pageUrl, tabId) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); + setExempted(pageUrlorUrlKey, tabId) { + const siteStoreKey = this.getSiteStoreKey(pageUrlorUrlKey); if (!(siteStoreKey in this.exemptedTabs)) { this.exemptedTabs[siteStoreKey] = []; } this.exemptedTabs[siteStoreKey].push(tabId); }, - removeExempted(pageUrl) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); + removeExempted(pageUrlorUrlKey) { + const siteStoreKey = this.getSiteStoreKey(pageUrlorUrlKey); this.exemptedTabs[siteStoreKey] = []; }, - isExempted(pageUrl, tabId) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); + isExempted(pageUrlorUrlKey, tabId) { + const siteStoreKey = this.getSiteStoreKey(pageUrlorUrlKey); if (!(siteStoreKey in this.exemptedTabs)) { return false; } return this.exemptedTabs[siteStoreKey].includes(tabId); }, - get(pageUrl) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); + get(pageUrlorUrlKey) { + const siteStoreKey = this.getSiteStoreKey(pageUrlorUrlKey); + return this.getByUrlKey(siteStoreKey); + }, + + async getSyncEnabled() { + const { syncEnabled } = await browser.storage.local.get("syncEnabled"); + return !!syncEnabled; + }, + + async getReplaceTabEnabled() { + const { replaceTabEnabled } = await browser.storage.local.get("replaceTabEnabled"); + return !!replaceTabEnabled; + }, + + getByUrlKey(siteStoreKey) { return new Promise((resolve, reject) => { this.area.get([siteStoreKey]).then((storageResponse) => { if (storageResponse && siteStoreKey in storageResponse) { @@ -54,51 +69,103 @@ const assignManager = { }); }, - set(pageUrl, data, exemptedTabIds) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); + async set(pageUrlorUrlKey, data, exemptedTabIds, backup = true) { + const siteStoreKey = this.getSiteStoreKey(pageUrlorUrlKey); if (exemptedTabIds) { exemptedTabIds.forEach((tabId) => { - this.setExempted(pageUrl, tabId); + this.setExempted(pageUrlorUrlKey, tabId); }); } - return this.area.set({ + // eslint-disable-next-line require-atomic-updates + data.identityMacAddonUUID = + await identityState.lookupMACaddonUUID(data.userContextId); + await this.area.set({ [siteStoreKey]: data }); + const syncEnabled = await this.getSyncEnabled(); + if (backup && syncEnabled) { + await sync.storageArea.backup({undeleteSiteStoreKey: siteStoreKey}); + } + return; }, - remove(pageUrl) { - const siteStoreKey = this.getSiteStoreKey(pageUrl); + async remove(pageUrlorUrlKey) { + const siteStoreKey = this.getSiteStoreKey(pageUrlorUrlKey); // When we remove an assignment we should clear all the exemptions - this.removeExempted(pageUrl); - return this.area.remove([siteStoreKey]); + this.removeExempted(pageUrlorUrlKey); + await this.area.remove([siteStoreKey]); + const syncEnabled = await this.getSyncEnabled(); + if (syncEnabled) await sync.storageArea.backup({siteStoreKey}); + return; }, async deleteContainer(userContextId) { - const sitesByContainer = await this.getByContainer(userContextId); + const sitesByContainer = await this.getAssignedSites(userContextId); this.area.remove(Object.keys(sitesByContainer)); }, - async getByContainer(userContextId) { + async getAssignedSites(userContextId = null) { 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]; + for(const urlKey of Object.keys(siteConfigs)) { + if (urlKey.includes("siteContainerMap@@_")) { + // For some reason this is stored as string... lets check + // them both as that + if (!!userContextId && + String(siteConfigs[urlKey].userContextId) + !== String(userContextId)) { + continue; + } + const site = siteConfigs[urlKey]; // 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; + site.hostname = urlKey.replace(/^siteContainerMap@@_/, ""); + sites[urlKey] = site; } - }); + } return sites; + }, + + /* + * Looks for abandoned site assignments. If there is no identity with + * the site assignment's userContextId (cookieStoreId), then the assignment + * is removed. + */ + async upgradeData() { + const identitiesList = await browser.contextualIdentities.query({}); + const macConfigs = await this.area.get(); + for(const configKey of Object.keys(macConfigs)) { + if (configKey.includes("siteContainerMap@@_")) { + const cookieStoreId = + "firefox-container-" + macConfigs[configKey].userContextId; + const match = identitiesList.find( + localIdentity => localIdentity.cookieStoreId === cookieStoreId + ); + if (!match) { + await this.remove(configKey); + continue; + } + const updatedSiteAssignment = macConfigs[configKey]; + updatedSiteAssignment.identityMacAddonUUID = + await identityState.lookupMACaddonUUID(match.cookieStoreId); + await this.set( + configKey, + updatedSiteAssignment, + false, + false + ); + } + } + } + }, _neverAsk(m) { const pageUrl = m.pageUrl; if (m.neverAsk === true) { - // If we have existing data and for some reason it hasn't been deleted etc lets update it + // If we have existing data and for some reason it hasn't been + // deleted etc lets update it this.storageArea.get(pageUrl).then((siteSettings) => { if (siteSettings) { siteSettings.neverAsk = true; @@ -113,12 +180,11 @@ 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); + await this.storageArea.setExempted(pageUrl, m.tabId); return true; }, async handleProxifiedRequest(requestInfo) { - // The following blocks potentially dangerous requests for privacy that come without a tabId if(requestInfo.tabId === -1) return Utils.getBogusProxy(); @@ -129,7 +195,8 @@ const assignManager = { return proxy; }, - // Before a request is handled by the browser we decide if we should route through a different container + // Before a request is handled by the browser we decide if we should + // route through a different container async onBeforeRequest(options) { if (options.frameId !== 0 || options.tabId === -1) { return {}; @@ -141,31 +208,57 @@ const assignManager = { ]); let container; try { - container = await browser.contextualIdentities.get(backgroundLogic.cookieStoreId(siteSettings.userContextId)); + container = await browser.contextualIdentities + .get(backgroundLogic.cookieStoreId(siteSettings.userContextId)); } catch (e) { container = false; } - // The container we have in the assignment map isn't present any more so lets remove it - // then continue the existing load + // The container we have in the assignment map isn't present any + // more so lets remove it then continue the existing load if (siteSettings && !container) { this.deleteContainer(siteSettings.userContextId); return {}; } const userContextId = this.getUserContextIdFromCookieStore(tab); - if (!siteSettings - || userContextId === siteSettings.userContextId - || tab.incognito - || this.storageArea.isExempted(options.url, tab.id)) { - return {}; + + // https://github.com/mozilla/multi-account-containers/issues/847 + // + // Handle the case where this request's URL is not assigned to any particular + // container. We must do the following check: + // + // If the current tab's container is "unlocked", we can just go ahead + // and open the URL in the current tab, since an "unlocked" container accepts + // any-and-all sites. + // + // But if the current tab's container has been "locked" by the user, then we must + // re-open the page in the default container, because the user doesn't want random + // sites polluting their locked container. + // + // For example: + // - the current tab's container is locked and only allows "www.google.com" + // - the incoming request is for "www.amazon.com", which has no specific container assignment + // - in this case, we must re-open "www.amazon.com" in a new tab in the default container + const siteIsolatedReloadInDefault = + await this._maybeSiteIsolatedReloadInDefault(siteSettings, tab); + + if (!siteIsolatedReloadInDefault) { + if (!siteSettings + || userContextId === siteSettings.userContextId + || this.storageArea.isExempted(options.url, tab.id)) { + return {}; + } } + const replaceTabEnabled = await this.storageArea.getReplaceTabEnabled(); const removeTab = backgroundLogic.NEW_TAB_PAGES.has(tab.url) || (messageHandler.lastCreatedTab - && messageHandler.lastCreatedTab.id === tab.id); + && messageHandler.lastCreatedTab.id === tab.id) + || replaceTabEnabled; const openTabId = removeTab ? tab.openerTabId : tab.id; if (!this.canceledRequests[tab.id]) { - // we decided to cancel the request at this point, register canceled request + // we decided to cancel the request at this point, register + // canceled request this.canceledRequests[tab.id] = { requestIds: { [options.requestId]: true @@ -175,8 +268,10 @@ const assignManager = { } }; - // since webRequest onCompleted and onErrorOccurred are not 100% reliable (see #1120) - // we register a timer here to cleanup canceled requests, just to make sure we don't + // since webRequest onCompleted and onErrorOccurred are not 100% + // reliable (see #1120) + // we register a timer here to cleanup canceled requests, just to + // make sure we don't // end up in a situation where certain urls in a tab.id stay canceled setTimeout(() => { if (this.canceledRequests[tab.id]) { @@ -188,10 +283,12 @@ const assignManager = { if (this.canceledRequests[tab.id].requestIds[options.requestId] || this.canceledRequests[tab.id].urls[options.url]) { // same requestId or url from the same tab - // this is a redirect that we have to cancel early to prevent opening two tabs + // this is a redirect that we have to cancel early to prevent + // opening two tabs cancelEarly = true; } - // we decided to cancel the request at this point, register canceled request + // we decided to cancel the request at this point, register canceled + // request this.canceledRequests[tab.id].requestIds[options.requestId] = true; this.canceledRequests[tab.id].urls[options.url] = true; if (cancelEarly) { @@ -201,27 +298,48 @@ const assignManager = { } } - this.reloadPageInContainer( - options.url, - userContextId, - siteSettings.userContextId, - tab.index + 1, - tab.active, - siteSettings.neverAsk, - openTabId - ); + if (siteIsolatedReloadInDefault) { + this.reloadPageInDefaultContainer( + options.url, + tab.index + 1, + tab.active, + openTabId + ); + } else { + this.reloadPageInContainer( + options.url, + userContextId, + siteSettings.userContextId, + tab.index + 1, + tab.active, + siteSettings.neverAsk, + openTabId + ); + } this.calculateContextMenu(tab); /* Removal of existing tabs: - We aim to open the new assigned container tab / warning prompt in it's own tab: - - As the history won't span from one container to another it seems most sane to not try and reopen a tab on history.back() - - When users open a new tab themselves we want to make sure we don't end up with three tabs as per: https://github.com/mozilla/testpilot-containers/issues/421 - If we are coming from an internal url that are used for the new tab page (NEW_TAB_PAGES), we can safely close as user is unlikely losing history - Detecting redirects on "new tab" opening actions is pretty hard as we don't get tab history: - - Redirects happen from Short URLs and tracking links that act as a gateway - - Extensions don't provide a way to history crawl for tabs, we could inject content scripts to do this - however they don't run on about:blank so this would likely be just as hacky. - We capture the time the tab was created and close if it was within the timeout to try to capture pages which haven't had user interaction or history. + We aim to open the new assigned container tab / warning prompt in + it's own tab: + - As the history won't span from one container to another it + seems most sane to not try and reopen a tab on history.back() + - When users open a new tab themselves we want to make sure we + don't end up with three tabs as per: + https://github.com/mozilla/testpilot-containers/issues/421 + If we are coming from an internal url that are used for the new + tab page (NEW_TAB_PAGES), we can safely close as user is unlikely + losing history + Detecting redirects on "new tab" opening actions is pretty hard + as we don't get tab history: + - Redirects happen from Short URLs and tracking links that act as + a gateway + - Extensions don't provide a way to history crawl for tabs, we + could inject content scripts to do this + however they don't run on about:blank so this would likely be + just as hacky. + We capture the time the tab was created and close if it was within + the timeout to try to capture pages which haven't had user + interaction or history. */ if (removeTab) { browser.tabs.remove(tab.id); @@ -231,15 +349,41 @@ const assignManager = { }; }, + async _maybeSiteIsolatedReloadInDefault(siteSettings, tab) { + // Tab doesn't support cookies, so containers not supported either. + if (!("cookieStoreId" in tab)) { + return false; + } + + // Requested page has been assigned to a specific container. + // I.e. it will be opened in that container anyway, so we don't need to check if the + // current tab's container is locked or not. + if (siteSettings) { + return false; + } + + //tab is alredy reopening in the default container + if (tab.cookieStoreId === "firefox-default") { + return false; + } + // Requested page is not assigned to a specific container. If the current tab's container + // is locked, then the page must be reloaded in the default container. + const currentContainerState = await identityState.storageArea.get(tab.cookieStoreId); + return currentContainerState && currentContainerState.isIsolated; + }, + init() { browser.contextMenus.onClicked.addListener((info, tab) => { - this._onClickedHandler(info, tab); + info.bookmarkId ? + this._onClickedBookmark(info) : + this._onClickedHandler(info, tab); }); // Before anything happens we decide if the request should be proxified browser.proxy.onRequest.addListener(this.handleProxifiedRequest, {urls: [""]}); - // Before a request is handled by the browser we decide if we should route through a different container + // Before a request is handled by the browser we decide if we should + // route through a different container this.canceledRequests = {}; browser.webRequest.onBeforeRequest.addListener((options) => { return this.onBeforeRequest(options); @@ -257,6 +401,60 @@ const assignManager = { } },{urls: [""], types: ["main_frame"]}); + this.resetBookmarksMenuItem(); + }, + + async resetBookmarksMenuItem() { + const hasPermission = await browser.permissions.contains({ + permissions: ["bookmarks"] + }); + if (this.hadBookmark === hasPermission) { + return; + } + this.hadBookmark = hasPermission; + if (hasPermission) { + this.initBookmarksMenu(); + browser.contextualIdentities.onCreated + .addListener(this.contextualIdentityCreated); + browser.contextualIdentities.onUpdated + .addListener(this.contextualIdentityUpdated); + browser.contextualIdentities.onRemoved + .addListener(this.contextualIdentityRemoved); + } else { + this.removeBookmarksMenu(); + browser.contextualIdentities.onCreated + .removeListener(this.contextualIdentityCreated); + browser.contextualIdentities.onUpdated + .removeListener(this.contextualIdentityUpdated); + browser.contextualIdentities.onRemoved + .removeListener(this.contextualIdentityRemoved); + } + }, + + contextualIdentityCreated(changeInfo) { + browser.contextMenus.create({ + parentId: assignManager.OPEN_IN_CONTAINER, + id: changeInfo.contextualIdentity.cookieStoreId, + title: changeInfo.contextualIdentity.name, + icons: { "16": `img/usercontext.svg#${ + changeInfo.contextualIdentity.icon + }` } + }); + }, + + contextualIdentityUpdated(changeInfo) { + browser.contextMenus.update( + changeInfo.contextualIdentity.cookieStoreId, { + title: changeInfo.contextualIdentity.name, + icons: { "16": `img/usercontext.svg#${ + changeInfo.contextualIdentity.icon}` } + }); + }, + + contextualIdentityRemoved(changeInfo) { + browser.contextMenus.remove( + changeInfo.contextualIdentity.cookieStoreId + ); }, async _onClickedHandler(info, tab) { @@ -272,7 +470,9 @@ const assignManager = { } else { remove = true; } - await this._setOrRemoveAssignment(tab.id, info.pageUrl, userContextId, remove); + await this._setOrRemoveAssignment( + tab.id, info.pageUrl, userContextId, remove + ); break; case this.MENU_MOVE_ID: backgroundLogic.moveTabsToWindow({ @@ -290,6 +490,41 @@ const assignManager = { } }, + async _onClickedBookmark(info) { + + async function _getBookmarksFromInfo(info) { + const [bookmarkTreeNode] = + await browser.bookmarks.get(info.bookmarkId); + if (bookmarkTreeNode.type === "folder") { + return browser.bookmarks.getChildren(bookmarkTreeNode.id); + } + return [bookmarkTreeNode]; + } + + const bookmarks = await _getBookmarksFromInfo(info); + for (const bookmark of bookmarks) { + // Some checks on the urls from + // https://github.com/Rob--W/bookmark-container-tab/ thanks! + if ( !/^(javascript|place):/i.test(bookmark.url) && + bookmark.type !== "folder") { + const openInReaderMode = bookmark.url.startsWith("about:reader"); + if(openInReaderMode) { + try { + const parsed = new URL(bookmark.url); + bookmark.url = parsed.searchParams.get("url") + parsed.hash; + } catch (err) { + return err.message; + } + } + browser.tabs.create({ + cookieStoreId: info.menuItemId, + url: bookmark.url, + openInReaderMode: openInReaderMode + }); + } + } + }, + deleteContainer(userContextId) { this.storageArea.deleteContainer(userContextId); @@ -299,16 +534,16 @@ const assignManager = { if (!("cookieStoreId" in tab)) { return false; } - return backgroundLogic.getUserContextIdFromCookieStoreId(tab.cookieStoreId); + return backgroundLogic.getUserContextIdFromCookieStoreId( + tab.cookieStoreId + ); }, isTabPermittedAssign(tab) { // Ensure we are not an important about url - // Ensure we are not in incognito mode const url = new URL(tab.url); if (url.protocol === "about:" - || url.protocol === "moz-extension:" - || tab.incognito) { + || url.protocol === "moz-extension:") { return false; } return true; @@ -316,7 +551,6 @@ const assignManager = { 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 @@ -341,16 +575,40 @@ const assignManager = { userContextId, neverAsk: false }, exemptedTabIds); - actionName = "added"; + actionName = "assigned site to always open in this container"; } else { + // Remove assignment await this.storageArea.remove(pageUrl); - actionName = "removed"; + + actionName = "removed from assigned sites list"; + + // remove site isolation if now empty + await this._maybeRemoveSiteIsolation(userContextId); } - browser.tabs.sendMessage(tabId, { - text: `Successfully ${actionName} site to always open in this container` - }); - const tab = await browser.tabs.get(tabId); - this.calculateContextMenu(tab); + + if (tabId) { + const tab = await browser.tabs.get(tabId); + setTimeout(function(){ + browser.tabs.sendMessage(tabId, { + text: `Successfully ${actionName}` + }); + }, 1000); + + + this.calculateContextMenu(tab); + } + }, + + async _maybeRemoveSiteIsolation(userContextId) { + const assignments = await this.storageArea.getByContainer(userContextId); + const hasAssignments = assignments && Object.keys(assignments).length > 0; + if (hasAssignments) { + return; + } + await backgroundLogic.addRemoveSiteIsolation( + backgroundLogic.cookieStoreId(userContextId), + true + ); }, async _getAssignment(tab) { @@ -358,13 +616,13 @@ const assignManager = { // Ensure we have a cookieStore to assign to if (cookieStore && this.isTabPermittedAssign(tab)) { - return await this.storageArea.get(tab.url); + return this.storageArea.get(tab.url); } return false; }, _getByContainer(userContextId) { - return this.storageArea.getByContainer(userContextId); + return this.storageArea.getAssignedSites(userContextId); }, removeContextMenu() { @@ -430,13 +688,35 @@ const assignManager = { }); }, + reloadPageInDefaultContainer(url, index, active, openerTabId) { + // To create a new tab in the default container, it is easiest just to omit the + // cookieStoreId entirely. + // + // Unfortunately, if you create a new tab WITHOUT a cookieStoreId but WITH an openerTabId, + // then the new tab automatically inherits the opener tab's cookieStoreId. + // I.e. it opens in the wrong container! + // + // So we have to explicitly pass in a cookieStoreId when creating the tab, since we + // are specifying the openerTabId. There doesn't seem to be any way + // to look up the default container's cookieStoreId programatically, so sadly + // we have to hardcode it here as "firefox-default". This is potentially + // not cross-browser compatible. + // + // Note that we could have just omitted BOTH cookieStoreId and openerTabId. But the + // drawback then is that if the user later closes the newly-created tab, the browser + // does not automatically return to the original opener tab. To get this desired behaviour, + // we MUST specify the openerTabId when creating the new tab. + const cookieStoreId = "firefox-default"; + browser.tabs.create({url, cookieStoreId, index, active, openerTabId}); + }, + reloadPageInContainer(url, currentUserContextId, userContextId, index, active, neverAsk = false, openerTabId = null) { const cookieStoreId = backgroundLogic.cookieStoreId(userContextId); const loadPage = browser.extension.getURL("confirm-page.html"); // False represents assignment is not permitted // If the user has explicitly checked "Never Ask Again" on the warning page we will send them straight there if (neverAsk) { - browser.tabs.create({url, cookieStoreId, index, active, openerTabId}); + return browser.tabs.create({url, cookieStoreId, index, active, openerTabId}); } else { let confirmUrl = `${loadPage}?url=${this.encodeURLProperty(url)}&cookieStoreId=${cookieStoreId}`; let currentCookieStoreId; @@ -444,7 +724,7 @@ const assignManager = { currentCookieStoreId = backgroundLogic.cookieStoreId(currentUserContextId); confirmUrl += `¤tCookieStoreId=${currentCookieStoreId}`; } - browser.tabs.create({ + return browser.tabs.create({ url: confirmUrl, cookieStoreId: currentCookieStoreId, openerTabId, @@ -457,7 +737,33 @@ const assignManager = { throw e; }); } - } + }, + + async initBookmarksMenu() { + browser.contextMenus.create({ + id: this.OPEN_IN_CONTAINER, + title: "Open Bookmark in Container Tab", + contexts: ["bookmark"], + }); + + const identities = await browser.contextualIdentities.query({}); + for (const identity of identities) { + browser.contextMenus.create({ + parentId: this.OPEN_IN_CONTAINER, + id: identity.cookieStoreId, + title: identity.name, + icons: { "16": `img/usercontext.svg#${identity.icon}` } + }); + } + }, + + async removeBookmarksMenu() { + browser.contextMenus.remove(this.OPEN_IN_CONTAINER); + const identities = await browser.contextualIdentities.query({}); + for (const identity of identities) { + browser.contextMenus.remove(identity.cookieStoreId); + } + }, }; assignManager.init(); diff --git a/src/js/background/backgroundLogic.js b/src/js/background/backgroundLogic.js index 30d0d55..f3ef957 100644 --- a/src/js/background/backgroundLogic.js +++ b/src/js/background/backgroundLogic.js @@ -6,7 +6,20 @@ const backgroundLogic = { "about:home", "about:blank" ]), + NUMBER_OF_KEYBOARD_SHORTCUTS: 10, unhideQueue: [], + init() { + browser.commands.onCommand.addListener(function (command) { + for (let i=0; i < backgroundLogic.NUMBER_OF_KEYBOARD_SHORTCUTS; i++) { + const key = "open_container_" + i; + const cookieStoreId = identityState.keyboardShortcut[key]; + if (command === key) { + if (cookieStoreId === "none") return; + browser.tabs.create({cookieStoreId}); + } + } + }); + }, async getExtensionInfo() { const manifestPath = browser.extension.getURL("manifest.json"); @@ -59,15 +72,13 @@ const backgroundLogic = { }); } await donePromise; - browser.runtime.sendMessage({ - method: "refreshNeeded" - }); }, async openNewTab(options) { let url = options.url || undefined; const userContextId = ("userContextId" in options) ? options.userContextId : 0; const active = ("nofocus" in options) ? options.nofocus : true; + const discarded = ("noload" in options) ? options.noload : false; const cookieStoreId = backgroundLogic.cookieStoreId(userContextId); // Autofocus url bar will happen in 54: https://bugzilla.mozilla.org/show_bug.cgi?id=1295072 @@ -84,6 +95,7 @@ const backgroundLogic = { return browser.tabs.create({ url, active, + discarded, pinned: options.pinned || false, cookieStoreId }); @@ -126,16 +138,31 @@ const backgroundLogic = { return list.concat(containerState.hiddenTabs); }, - async unhideContainer(cookieStoreId) { + async unhideContainer(cookieStoreId, alreadyShowingUrl) { if (!this.unhideQueue.includes(cookieStoreId)) { this.unhideQueue.push(cookieStoreId); await this.showTabs({ - cookieStoreId + cookieStoreId, + alreadyShowingUrl }); this.unhideQueue.splice(this.unhideQueue.indexOf(cookieStoreId), 1); } }, + // https://github.com/mozilla/multi-account-containers/issues/847 + async addRemoveSiteIsolation(cookieStoreId, remove = false) { + const containerState = await identityState.storageArea.get(cookieStoreId); + try { + if ("isIsolated" in containerState || remove) { + delete containerState.isIsolated; + } else { + containerState.isIsolated = "locked"; + } + return await identityState.storageArea.set(cookieStoreId, containerState); + } catch (error) { + console.error(`No container: ${cookieStoreId}`); + } + }, async moveTabsToWindow(options) { const requiredArguments = ["cookieStoreId", "windowId"]; @@ -242,7 +269,8 @@ const backgroundLogic = { hasHiddenTabs: !!containerState.hiddenTabs.length, hasOpenTabs: !!openTabs.length, numberOfHiddenTabs: containerState.hiddenTabs.length, - numberOfOpenTabs: openTabs.length + numberOfOpenTabs: openTabs.length, + isIsolated: !!containerState.isIsolated }; return; }); @@ -322,21 +350,30 @@ const backgroundLogic = { const containerState = await identityState.storageArea.get(options.cookieStoreId); for (let object of containerState.hiddenTabs) { // eslint-disable-line prefer-const - promises.push(this.openNewTab({ - userContextId: userContextId, - url: object.url, - nofocus: options.nofocus || false, - pinned: object.pinned, - })); + // do not show already opened url + const noload = !object.pinned; + if (object.url !== options.alreadyShowingUrl) { + promises.push(this.openNewTab({ + userContextId: userContextId, + url: object.url, + nofocus: options.nofocus || false, + noload: noload, + pinned: object.pinned, + })); + } } containerState.hiddenTabs = []; await Promise.all(promises); - return await identityState.storageArea.set(options.cookieStoreId, containerState); + return identityState.storageArea.set(options.cookieStoreId, containerState); }, cookieStoreId(userContextId) { + if(userContextId === 0) return "firefox-default"; return `firefox-container-${userContextId}`; } }; + + +backgroundLogic.init(); \ No newline at end of file diff --git a/src/js/background/badge.js b/src/js/background/badge.js index 78cd9f1..7b6ccf3 100644 --- a/src/js/background/badge.js +++ b/src/js/background/badge.js @@ -1,23 +1,18 @@ -const MAJOR_VERSIONS = ["2.3.0", "2.4.0"]; +const MAJOR_VERSIONS = ["2.3.0", "2.4.0", "6.2.0"]; const badge = { async init() { const currentWindow = await browser.windows.getCurrent(); - this.displayBrowserActionBadge(currentWindow.incognito); - }, - - disableAddon(tabId) { - browser.browserAction.disable(tabId); - browser.browserAction.setTitle({ tabId, title: "Containers disabled in Private Browsing Mode" }); + this.displayBrowserActionBadge(currentWindow); }, async displayBrowserActionBadge() { const extensionInfo = await backgroundLogic.getExtensionInfo(); - const storage = await browser.storage.local.get({browserActionBadgesClicked: []}); + const storage = await browser.storage.local.get({ browserActionBadgesClicked: [] }); if (MAJOR_VERSIONS.indexOf(extensionInfo.version) > -1 && - storage.browserActionBadgesClicked.indexOf(extensionInfo.version) < 0) { - browser.browserAction.setBadgeBackgroundColor({color: "rgba(0,217,0,255)"}); - browser.browserAction.setBadgeText({text: "NEW"}); + storage.browserActionBadgesClicked.indexOf(extensionInfo.version) < 0) { + browser.browserAction.setBadgeBackgroundColor({ color: "rgba(0,217,0,255)" }); + browser.browserAction.setBadgeText({ text: "NEW" }); } } }; diff --git a/src/js/background/identityState.js b/src/js/background/identityState.js index fbb0020..9114240 100644 --- a/src/js/background/identityState.js +++ b/src/js/background/identityState.js @@ -1,4 +1,5 @@ -const identityState = { +window.identityState = { + keyboardShortcut: {}, storageArea: { area: browser.storage.local, @@ -11,12 +12,23 @@ const identityState = { const storeKey = this.getContainerStoreKey(cookieStoreId); const storageResponse = await this.area.get([storeKey]); if (storageResponse && storeKey in storageResponse) { + if (!storageResponse[storeKey].macAddonUUID){ + storageResponse[storeKey].macAddonUUID = uuidv4(); + await this.set(cookieStoreId, storageResponse[storeKey]); + } return storageResponse[storeKey]; } - const defaultContainerState = identityState._createIdentityState(); - await this.set(cookieStoreId, defaultContainerState); - - return defaultContainerState; + // If local storage doesn't have an entry, look it up to make sure it's + // an in-use identity. + const identities = await browser.contextualIdentities.query({}); + const match = identities.find( + (identity) => identity.cookieStoreId === cookieStoreId); + if (match) { + const defaultContainerState = identityState._createIdentityState(); + await this.set(cookieStoreId, defaultContainerState); + return defaultContainerState; + } + return false; }, set(cookieStoreId, data) { @@ -26,16 +38,82 @@ const identityState = { }); }, - remove(cookieStoreId) { + async remove(cookieStoreId) { const storeKey = this.getContainerStoreKey(cookieStoreId); return this.area.remove([storeKey]); - } + }, + + async setKeyboardShortcut(shortcutId, cookieStoreId) { + identityState.keyboardShortcut[shortcutId] = cookieStoreId; + return this.area.set({[shortcutId]: cookieStoreId}); + }, + + async loadKeyboardShortcuts () { + const identities = await browser.contextualIdentities.query({}); + for (let i=0; i < backgroundLogic.NUMBER_OF_KEYBOARD_SHORTCUTS; i++) { + const key = "open_container_" + i; + const storageObject = await this.area.get(key); + if (storageObject[key]){ + identityState.keyboardShortcut[key] = storageObject[key]; + continue; + } + if (identities[i]) { + identityState.keyboardShortcut[key] = identities[i].cookieStoreId; + continue; + } + identityState.keyboardShortcut[key] = "none"; + } + return identityState.keyboardShortcut; + }, + + /* + * Looks for abandoned identity keys in local storage, and makes sure all + * identities registered in the browser are also in local storage. (this + * appears to not always be the case based on how this.get() is written) + */ + async upgradeData() { + const identitiesList = await browser.contextualIdentities.query({}); + + for (const identity of identitiesList) { + // ensure all identities have an entry in local storage + await identityState.addUUID(identity.cookieStoreId); + } + + const macConfigs = await this.area.get(); + for(const configKey of Object.keys(macConfigs)) { + if (configKey.includes("identitiesState@@_")) { + const cookieStoreId = String(configKey).replace(/^identitiesState@@_/, ""); + const match = identitiesList.find( + localIdentity => localIdentity.cookieStoreId === cookieStoreId + ); + if (cookieStoreId === "firefox-default") continue; + if (!match) { + await this.remove(cookieStoreId); + continue; + } + if (!macConfigs[configKey].macAddonUUID) { + await identityState.storageArea.get(cookieStoreId); + } + } + } + }, + }, _createTabObject(tab) { return Object.assign({}, tab); }, + async getCookieStoreIDuuidMap() { + const containers = {}; + const identities = await browser.contextualIdentities.query({}); + for(const identity of identities) { + const containerInfo = await this.storageArea.get(identity.cookieStoreId); + containers[identity.cookieStoreId] = containerInfo.macAddonUUID; + } + return containers; + }, + async storeHidden(cookieStoreId, windowId) { const containerState = await this.storageArea.get(cookieStoreId); const tabsByContainer = await browser.tabs.query({cookieStoreId, windowId}); @@ -54,9 +132,63 @@ const identityState = { return this.storageArea.set(cookieStoreId, containerState); }, + async updateUUID(cookieStoreId, uuid) { + if (!cookieStoreId || !uuid) { + throw new Error ("cookieStoreId or uuid missing"); + } + const containerState = await this.storageArea.get(cookieStoreId); + containerState.macAddonUUID = uuid; + await this.storageArea.set(cookieStoreId, containerState); + return uuid; + }, + + async addUUID(cookieStoreId) { + await this.storageArea.get(cookieStoreId); + }, + + async lookupMACaddonUUID(cookieStoreId) { + // This stays a lookup, because if the cookieStoreId doesn't + // exist, this.get() will create it, which is not what we want. + const cookieStoreIdKey = cookieStoreId.includes("firefox-container-") ? + cookieStoreId : "firefox-container-" + cookieStoreId; + const macConfigs = await this.storageArea.area.get(); + for(const configKey of Object.keys(macConfigs)) { + if (configKey === this.storageArea.getContainerStoreKey(cookieStoreIdKey)) { + return macConfigs[configKey].macAddonUUID; + } + } + return false; + }, + + async lookupCookieStoreId(macAddonUUID) { + const macConfigs = await this.storageArea.area.get(); + for(const configKey of Object.keys(macConfigs)) { + if (configKey.includes("identitiesState@@_")) { + if(macConfigs[configKey].macAddonUUID === macAddonUUID) { + return String(configKey).replace(/^identitiesState@@_/, ""); + } + } + } + return false; + }, + _createIdentityState() { return { - hiddenTabs: [] + hiddenTabs: [], + macAddonUUID: uuidv4() }; }, + + init() { + this.storageArea.loadKeyboardShortcuts(); + } }; + +identityState.init(); + +function uuidv4() { + // https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript + return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); +} diff --git a/src/js/background/index.html b/src/js/background/index.html index cdbd82e..b29b062 100644 --- a/src/js/background/index.html +++ b/src/js/background/index.html @@ -20,5 +20,6 @@ + diff --git a/src/js/background/messageHandler.js b/src/js/background/messageHandler.js index 9fbe88e..b3270e5 100644 --- a/src/js/background/messageHandler.js +++ b/src/js/background/messageHandler.js @@ -6,10 +6,23 @@ const messageHandler = { init() { // Handles messages from webextension code - browser.runtime.onMessage.addListener((m) => { + browser.runtime.onMessage.addListener(async (m) => { let response; + let tab; switch (m.method) { + case "getShortcuts": + response = identityState.storageArea.loadKeyboardShortcuts(); + break; + case "setShortcut": + identityState.storageArea.setKeyboardShortcut(m.shortcut, m.cookieStoreId); + break; + case "resetSync": + response = sync.resetSync(); + break; + case "resetBookmarksContext": + response = assignManager.resetBookmarksMenuItem(); + break; case "deleteContainer": response = backgroundLogic.deleteContainer(m.message.userContextId); break; @@ -19,6 +32,9 @@ const messageHandler = { case "neverAsk": assignManager._neverAsk(m); break; + case "addRemoveSiteIsolation": + response = backgroundLogic.addRemoveSiteIsolation(m.cookieStoreId); + break; case "getAssignment": response = browser.tabs.get(m.tabId).then((tab) => { return assignManager._getAssignment(tab); @@ -30,9 +46,7 @@ const messageHandler = { 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); - }); + response = assignManager._setOrRemoveAssignment(m.tabId, m.url, m.userContextId, m.value); break; case "sortTabs": backgroundLogic.sortTabs(); @@ -67,6 +81,31 @@ const messageHandler = { case "exemptContainerAssignment": response = assignManager._exemptTab(m); break; + case "reloadInContainer": + response = assignManager.reloadPageInContainer( + m.url, + m.currentUserContextId, + m.newUserContextId, + m.tabIndex, + m.active, + true + ); + break; + case "assignAndReloadInContainer": + tab = await assignManager.reloadPageInContainer( + m.url, + m.currentUserContextId, + m.newUserContextId, + m.tabIndex, + m.active, + true + ); + // 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(tab.id).then((tab) => { + return assignManager._setOrRemoveAssignment(tab.id, m.url, m.newUserContextId, m.value); + }); + break; } return response; }); @@ -79,6 +118,7 @@ const messageHandler = { if (!extensionInfo.permissions.includes("contextualIdentities")) { throw new Error("Missing contextualIdentities permission"); } + // eslint-disable-next-line require-atomic-updates externalExtensionAllowed[sender.id] = true; } let response; @@ -141,9 +181,6 @@ const messageHandler = { }, {urls: [""], types: ["main_frame"]}); browser.tabs.onCreated.addListener((tab) => { - if (tab.incognito) { - badge.disableAddon(tab.id); - } // lets remember the last tab created so we can close it if it looks like a redirect this.lastCreatedTab = tab; if (tab.cookieStoreId) { @@ -153,9 +190,26 @@ const messageHandler = { !tab.url.startsWith("moz-extension")) { // increment the counter of container tabs opened this.incrementCountOfContainerTabsOpened(); - } - backgroundLogic.unhideContainer(tab.cookieStoreId); + this.tabUpdateHandler = (tabId, changeInfo) => { + if (tabId === tab.id && changeInfo.status === "complete") { + // get current tab's url to not open the same one from hidden tabs + browser.tabs.get(tabId).then(loadedTab => { + backgroundLogic.unhideContainer(tab.cookieStoreId, loadedTab.url); + }).catch((e) => { + throw e; + }); + + browser.tabs.onUpdated.removeListener(this.tabUpdateHandler); + } + }; + + // if it's a container tab wait for it to complete and + // unhide other tabs from this container + if (tab.cookieStoreId.startsWith("firefox-container")) { + browser.tabs.onUpdated.addListener(this.tabUpdateHandler); + } + } } setTimeout(() => { this.lastCreatedTab = null; diff --git a/src/js/background/sync.js b/src/js/background/sync.js new file mode 100644 index 0000000..b34ea00 --- /dev/null +++ b/src/js/background/sync.js @@ -0,0 +1,580 @@ +const SYNC_DEBUG = false; + +const sync = { + storageArea: { + area: browser.storage.sync, + + async get(){ + return this.area.get(); + }, + + async set(options) { + return this.area.set(options); + }, + + async deleteIdentity(deletedIdentityUUID) { + const deletedIdentityList = + await sync.storageArea.getDeletedIdentityList(); + if ( + ! deletedIdentityList.find(element => element === deletedIdentityUUID) + ) { + deletedIdentityList.push(deletedIdentityUUID); + await sync.storageArea.set({ deletedIdentityList }); + } + await this.removeIdentityKeyFromSync(deletedIdentityUUID); + }, + + async removeIdentityKeyFromSync(deletedIdentityUUID) { + await sync.storageArea.area.remove( "identity@@_" + deletedIdentityUUID); + }, + + async deleteSite(siteStoreKey) { + const deletedSiteList = + await sync.storageArea.getDeletedSiteList(); + if (deletedSiteList.find(element => element === siteStoreKey)) return; + deletedSiteList.push(siteStoreKey); + await sync.storageArea.set({ deletedSiteList }); + await sync.storageArea.area.remove(siteStoreKey); + }, + + async getDeletedIdentityList() { + const storedArray = await this.getStoredItem("deletedIdentityList"); + return storedArray || []; + }, + + async getIdentities() { + const allSyncStorage = await this.get(); + const identities = []; + for (const storageKey of Object.keys(allSyncStorage)) { + if (storageKey.includes("identity@@_")) { + identities.push(allSyncStorage[storageKey]); + } + } + return identities; + }, + + async getDeletedSiteList() { + const storedArray = await this.getStoredItem("deletedSiteList"); + return (storedArray) ? storedArray : []; + }, + + async getAssignedSites() { + const allSyncStorage = await this.get(); + const sites = {}; + for (const storageKey of Object.keys(allSyncStorage)) { + if (storageKey.includes("siteContainerMap@@_")) { + sites[storageKey] = allSyncStorage[storageKey]; + } + } + return sites; + }, + + async getStoredItem(objectKey) { + const outputObject = await this.get(objectKey); + if (outputObject && outputObject[objectKey]) + return outputObject[objectKey]; + return false; + }, + + async getAllInstanceInfo() { + const instanceList = {}; + const allSyncInfo = await this.get(); + for (const objectKey of Object.keys(allSyncInfo)) { + if (objectKey.includes("MACinstance")) { + instanceList[objectKey] = allSyncInfo[objectKey]; } + } + return instanceList; + }, + + getInstanceKey() { + return browser.runtime.getURL("") + .replace(/moz-extension:\/\//, "MACinstance:") + .replace(/\//, ""); + }, + async removeInstance(installUUID) { + if (SYNC_DEBUG) console.log("removing", installUUID); + await this.area.remove(installUUID); + return; + }, + + async removeThisInstanceFromSync() { + const installUUID = this.getInstanceKey(); + await this.removeInstance(installUUID); + return; + }, + + async hasSyncStorage(){ + const inSync = await this.get(); + return !(Object.entries(inSync).length === 0); + }, + + async backup(options) { + // remove listeners to avoid an infinite loop! + await sync.checkForListenersMaybeRemove(); + + const identities = await updateSyncIdentities(); + const siteAssignments = await updateSyncSiteAssignments(); + await updateInstanceInfo(identities, siteAssignments); + if (options && options.uuid) + await this.deleteIdentity(options.uuid); + if (options && options.undeleteUUID) + await removeFromDeletedIdentityList(options.undeleteUUID); + if (options && options.siteStoreKey) + await this.deleteSite(options.siteStoreKey); + if (options && options.undeleteSiteStoreKey) + await removeFromDeletedSitesList(options.undeleteSiteStoreKey); + + if (SYNC_DEBUG) console.log("Backed up!"); + await sync.checkForListenersMaybeAdd(); + + async function updateSyncIdentities() { + const identities = await browser.contextualIdentities.query({}); + + for (const identity of identities) { + delete identity.colorCode; + delete identity.iconUrl; + identity.macAddonUUID = await identityState.lookupMACaddonUUID(identity.cookieStoreId); + if(identity.macAddonUUID) { + const storageKey = "identity@@_" + identity.macAddonUUID; + await sync.storageArea.set({ [storageKey]: identity }); + } + } + //await sync.storageArea.set({ identities }); + return identities; + } + + async function updateSyncSiteAssignments() { + const assignedSites = + await assignManager.storageArea.getAssignedSites(); + for (const siteKey of Object.keys(assignedSites)) { + await sync.storageArea.set({ [siteKey]: assignedSites[siteKey] }); + } + return assignedSites; + } + + async function updateInstanceInfo(identitiesInput, siteAssignmentsInput) { + const date = new Date(); + const timestamp = date.getTime(); + const installUUID = sync.storageArea.getInstanceKey(); + if (SYNC_DEBUG) console.log("adding", installUUID); + const identities = []; + const siteAssignments = []; + for (const identity of identitiesInput) { + identities.push(identity.macAddonUUID); + } + for (const siteAssignmentKey of Object.keys(siteAssignmentsInput)) { + siteAssignments.push(siteAssignmentKey); + } + await sync.storageArea.set({ [installUUID]: { timestamp, identities, siteAssignments } }); + } + + async function removeFromDeletedIdentityList(identityUUID) { + const deletedIdentityList = + await sync.storageArea.getDeletedIdentityList(); + const newDeletedIdentityList = deletedIdentityList + .filter(element => element !== identityUUID); + await sync.storageArea.set({ deletedIdentityList: newDeletedIdentityList }); + } + + async function removeFromDeletedSitesList(siteStoreKey) { + const deletedSiteList = + await sync.storageArea.getDeletedSiteList(); + const newDeletedSiteList = deletedSiteList + .filter(element => element !== siteStoreKey); + await sync.storageArea.set({ deletedSiteList: newDeletedSiteList }); + } + }, + + onChangedListener(changes, areaName) { + if (areaName === "sync") sync.errorHandledRunSync(); + }, + + async addToDeletedList(changeInfo) { + const identity = changeInfo.contextualIdentity; + const deletedUUID = + await identityState.lookupMACaddonUUID(identity.cookieStoreId); + await identityState.storageArea.remove(identity.cookieStoreId); + sync.storageArea.backup({uuid: deletedUUID}); + } + }, + + async init() { + const syncEnabled = await assignManager.storageArea.getSyncEnabled(); + if (syncEnabled) { + // Add listener to sync storage and containers. + // Works for all installs that have any sync storage. + // Waits for sync storage change before kicking off the restore/backup + // initial sync must be kicked off by user. + this.checkForListenersMaybeAdd(); + return; + } + this.checkForListenersMaybeRemove(); + + }, + + async errorHandledRunSync () { + await sync.runSync().catch( async (error)=> { + if (SYNC_DEBUG) console.error("Error from runSync", error); + await sync.checkForListenersMaybeAdd(); + }); + }, + + async checkForListenersMaybeAdd() { + const hasStorageListener = + await browser.storage.onChanged.hasListener( + sync.storageArea.onChangedListener + ); + + const hasCIListener = await sync.hasContextualIdentityListeners(); + + if (!hasCIListener) { + await sync.addContextualIdentityListeners(); + } + + if (!hasStorageListener) { + await browser.storage.onChanged.addListener( + sync.storageArea.onChangedListener); + } + }, + + async checkForListenersMaybeRemove() { + const hasStorageListener = + await browser.storage.onChanged.hasListener( + sync.storageArea.onChangedListener + ); + + const hasCIListener = await sync.hasContextualIdentityListeners(); + + if (hasCIListener) { + await sync.removeContextualIdentityListeners(); + } + + if (hasStorageListener) { + await browser.storage.onChanged.removeListener( + sync.storageArea.onChangedListener); + } + }, + + async runSync() { + if (SYNC_DEBUG) { + const syncInfo = await sync.storageArea.get(); + const localInfo = await browser.storage.local.get(); + const idents = await browser.contextualIdentities.query({}); + console.log("Initial State:", {syncInfo, localInfo, idents}); + } + await sync.checkForListenersMaybeRemove(); + if (SYNC_DEBUG) console.log("runSync"); + + await identityState.storageArea.upgradeData(); + await assignManager.storageArea.upgradeData(); + + const hasSyncStorage = await sync.storageArea.hasSyncStorage(); + if (hasSyncStorage) await restore(); + + await sync.storageArea.backup(); + await removeOldDeletedItems(); + return; + }, + + async addContextualIdentityListeners() { + await browser.contextualIdentities.onCreated.addListener(sync.storageArea.backup); + await browser.contextualIdentities.onRemoved.addListener(sync.storageArea.addToDeletedList); + await browser.contextualIdentities.onUpdated.addListener(sync.storageArea.backup); + }, + + async removeContextualIdentityListeners() { + await browser.contextualIdentities.onCreated.removeListener(sync.storageArea.backup); + await browser.contextualIdentities.onRemoved.removeListener(sync.storageArea.addToDeletedList); + await browser.contextualIdentities.onUpdated.removeListener(sync.storageArea.backup); + }, + + async hasContextualIdentityListeners() { + return ( + await browser.contextualIdentities.onCreated.hasListener(sync.storageArea.backup) && + await browser.contextualIdentities.onRemoved.hasListener(sync.storageArea.addToDeletedList) && + await browser.contextualIdentities.onUpdated.hasListener(sync.storageArea.backup) + ); + }, + + async resetSync() { + const syncEnabled = await assignManager.storageArea.getSyncEnabled(); + if (syncEnabled) { + this.errorHandledRunSync(); + return; + } + await this.checkForListenersMaybeRemove(); + await this.storageArea.removeThisInstanceFromSync(); + } + +}; + +// attaching to window for use in mocha tests +window.sync = sync; + +sync.init(); + +async function restore() { + if (SYNC_DEBUG) console.log("restore"); + await reconcileIdentities(); + await reconcileSiteAssignments(); + return; +} + +/* + * Checks for the container name. If it exists, they are assumed to be the + * same container, and the color and icon are overwritten from sync, if + * different. + */ +async function reconcileIdentities(){ + if (SYNC_DEBUG) console.log("reconcileIdentities"); + + // first delete any from the deleted list + const deletedIdentityList = + await sync.storageArea.getDeletedIdentityList(); + // first remove any deleted identities + for (const deletedUUID of deletedIdentityList) { + const deletedCookieStoreId = + await identityState.lookupCookieStoreId(deletedUUID); + if (deletedCookieStoreId){ + try{ + await browser.contextualIdentities.remove(deletedCookieStoreId); + } catch (error) { + // if the identity we are deleting is not there, that's fine. + console.error("Error deleting contextualIdentity", deletedCookieStoreId); + continue; + } + } + } + const localIdentities = await browser.contextualIdentities.query({}); + const syncIdentitiesRemoveDupes = + await sync.storageArea.getIdentities(); + // find any local dupes created on sync storage and delete from sync storage + for (const localIdentity of localIdentities) { + const syncIdentitiesOfName = syncIdentitiesRemoveDupes + .filter(identity => identity.name === localIdentity.name); + if (syncIdentitiesOfName.length > 1) { + const identityMatchingContextId = syncIdentitiesOfName + .find(identity => identity.cookieStoreId === localIdentity.cookieStoreId); + if (identityMatchingContextId) + await sync.storageArea.removeIdentityKeyFromSync(identityMatchingContextId.macAddonUUID); + } + } + const syncIdentities = + await sync.storageArea.getIdentities(); + // now compare all containers for matching names. + for (const syncIdentity of syncIdentities) { + if (syncIdentity.macAddonUUID){ + const localMatch = localIdentities.find( + localIdentity => localIdentity.name === syncIdentity.name + ); + if (!localMatch) { + // if there's no name match found, check on uuid, + const localCookieStoreID = + await identityState.lookupCookieStoreId(syncIdentity.macAddonUUID); + if (localCookieStoreID) { + await ifUUIDMatch(syncIdentity, localCookieStoreID); + continue; + } + await ifNoMatch(syncIdentity); + continue; + } + + // Names match, so use the info from Sync + await updateIdentityWithSyncInfo(syncIdentity, localMatch); + continue; + } + // if no macAddonUUID, there is a problem with the sync info and it needs to be ignored. + } + + await updateSiteAssignmentUUIDs(); + + async function updateSiteAssignmentUUIDs(){ + const sites = assignManager.storageArea.getAssignedSites(); + for (const siteKey of Object.keys(sites)) { + await assignManager.storageArea.set(siteKey, sites[siteKey]); + } + } +} + +async function updateIdentityWithSyncInfo(syncIdentity, localMatch) { + // Sync is truth. if there is a match, compare data and update as needed + if (syncIdentity.color !== localMatch.color + || syncIdentity.icon !== localMatch.icon) { + await browser.contextualIdentities.update( + localMatch.cookieStoreId, { + name: syncIdentity.name, + color: syncIdentity.color, + icon: syncIdentity.icon + }); + + if (SYNC_DEBUG) { + if (localMatch.color !== syncIdentity.color) { + console.log(localMatch.name, "Change color: ", syncIdentity.color); + } + if (localMatch.icon !== syncIdentity.icon) { + console.log(localMatch.name, "Change icon: ", syncIdentity.icon); + } + } + } + // Sync is truth. If all is the same, update the local uuid to match sync + if (localMatch.macAddonUUID !== syncIdentity.macAddonUUID) { + await identityState.updateUUID( + localMatch.cookieStoreId, + syncIdentity.macAddonUUID + ); + } + // TODOkmw: update any site assignment UUIDs +} + +async function ifUUIDMatch(syncIdentity, localCookieStoreID) { + // if there's an identical local uuid, it's the same container. Sync is truth + const identityInfo = { + name: syncIdentity.name, + color: syncIdentity.color, + icon: syncIdentity.icon + }; + if (SYNC_DEBUG) { + try { + const getIdent = + await browser.contextualIdentities.get(localCookieStoreID); + if (getIdent.name !== identityInfo.name) { + console.log(getIdent.name, "Change name: ", identityInfo.name); + } + if (getIdent.color !== identityInfo.color) { + console.log(getIdent.name, "Change color: ", identityInfo.color); + } + if (getIdent.icon !== identityInfo.icon) { + console.log(getIdent.name, "Change icon: ", identityInfo.icon); + } + } catch (error) { + //if this fails, there is probably differing sync info. + console.error("Error getting info on CI", error); + } + } + try { + // update the local container with the sync data + await browser.contextualIdentities + .update(localCookieStoreID, identityInfo); + return; + } catch (error) { + // If this fails, sync info is off. + console.error("Error udpating CI", error); + } +} + +async function ifNoMatch(syncIdentity){ + // if no uuid match either, make new identity + if (SYNC_DEBUG) console.log("create new ident: ", syncIdentity.name); + const newIdentity = + await browser.contextualIdentities.create({ + name: syncIdentity.name, + color: syncIdentity.color, + icon: syncIdentity.icon + }); + await identityState.updateUUID( + newIdentity.cookieStoreId, + syncIdentity.macAddonUUID + ); + return; +} +/* + * Checks for site previously assigned. If it exists, and has the same + * container assignment, the assignment is kept. If it exists, but has + * a different assignment, the user is prompted (not yet implemented). + * If it does not exist, it is created. + */ +async function reconcileSiteAssignments() { + if (SYNC_DEBUG) console.log("reconcileSiteAssignments"); + const assignedSitesLocal = + await assignManager.storageArea.getAssignedSites(); + const assignedSitesFromSync = + await sync.storageArea.getAssignedSites(); + const deletedSiteList = + await sync.storageArea.getDeletedSiteList(); + for(const siteStoreKey of deletedSiteList) { + if (Object.prototype.hasOwnProperty.call(assignedSitesLocal,siteStoreKey)) { + assignManager + .storageArea + .remove(siteStoreKey); + } + } + + for(const urlKey of Object.keys(assignedSitesFromSync)) { + const assignedSite = assignedSitesFromSync[urlKey]; + try{ + if (assignedSite.identityMacAddonUUID) { + // Sync is truth. + // Not even looking it up. Just overwrite + if (SYNC_DEBUG){ + const isInStorage = await assignManager.storageArea.getByUrlKey(urlKey); + if (!isInStorage) + console.log("new assignment ", assignedSite); + } + + await setAssignmentWithUUID(assignedSite, urlKey); + continue; + } + } catch (error) { + // this is probably old or incorrect site info in Sync + // skip and move on. + } + } +} + +const MILISECONDS_IN_THIRTY_DAYS = 2592000000; + +async function removeOldDeletedItems() { + const instanceList = await sync.storageArea.getAllInstanceInfo(); + const deletedSiteList = await sync.storageArea.getDeletedSiteList(); + const deletedIdentityList = await sync.storageArea.getDeletedIdentityList(); + + for (const instanceKey of Object.keys(instanceList)) { + const date = new Date(); + const currentTimestamp = date.getTime(); + if (instanceList[instanceKey].timestamp < currentTimestamp - MILISECONDS_IN_THIRTY_DAYS) { + delete instanceList[instanceKey]; + sync.storageArea.removeInstance(instanceKey); + continue; + } + } + for (const siteStoreKey of deletedSiteList) { + let hasMatch = false; + for (const instance of Object.values(instanceList)) { + const match = instance.siteAssignments.find(element => element === siteStoreKey); + if (!match) continue; + hasMatch = true; + } + if (!hasMatch) { + await sync.storageArea.backup({undeleteSiteStoreKey: siteStoreKey}); + } + } + for (const identityUUID of deletedIdentityList) { + let hasMatch = false; + for (const instance of Object.values(instanceList)) { + const match = instance.identities.find(element => element === identityUUID); + if (!match) continue; + hasMatch = true; + } + if (!hasMatch) { + await sync.storageArea.backup({undeleteUUID: identityUUID}); + } + } +} + +async function setAssignmentWithUUID(assignedSite, urlKey) { + const uuid = assignedSite.identityMacAddonUUID; + const cookieStoreId = await identityState.lookupCookieStoreId(uuid); + if (cookieStoreId) { + // eslint-disable-next-line require-atomic-updates + assignedSite.userContextId = cookieStoreId + .replace(/^firefox-container-/, ""); + await assignManager.storageArea.set( + urlKey, + assignedSite, + false, + false + ); + return; + } + throw new Error (`No cookieStoreId found for: ${uuid}, ${urlKey}`); +} diff --git a/src/js/confirm-page.js b/src/js/confirm-page.js index 9f6eb77..5ca5323 100644 --- a/src/js/confirm-page.js +++ b/src/js/confirm-page.js @@ -1,6 +1,6 @@ async function load() { const searchParams = new URL(window.location).searchParams; - const redirectUrl = decodeURIComponent(searchParams.get("url")); + const redirectUrl = searchParams.get("url"); const cookieStoreId = searchParams.get("cookieStoreId"); const currentCookieStoreId = searchParams.get("currentCookieStoreId"); const redirectUrlElement = document.getElementById("redirect-url"); @@ -17,25 +17,14 @@ async function load() { const currentContainer = await browser.contextualIdentities.get(currentCookieStoreId); document.getElementById("current-container-name").textContent = currentContainer.name; } - - document.getElementById("redirect-form").addEventListener("submit", (e) => { + document.getElementById("deny").addEventListener("click", (e) => { e.preventDefault(); - let button = "confirm"; // Confirm is the form default. - let buttonTarget = e.explicitOriginalTarget; - if (buttonTarget.tagName !== "BUTTON") { - buttonTarget = buttonTarget.closest("button"); - } - if (buttonTarget && buttonTarget.id) { - button = buttonTarget.id; - } - switch (button) { - case "deny": - denySubmit(redirectUrl); - break; - case "confirm": - confirmSubmit(redirectUrl, cookieStoreId); - break; - } + denySubmit(redirectUrl); + }); + + document.getElementById("confirm").addEventListener("click", (e) => { + e.preventDefault(); + confirmSubmit(redirectUrl, cookieStoreId); }); } diff --git a/src/js/options.js b/src/js/options.js new file mode 100644 index 0000000..025ae99 --- /dev/null +++ b/src/js/options.js @@ -0,0 +1,90 @@ +const NUMBER_OF_KEYBOARD_SHORTCUTS = 10; + +async function requestPermissions() { + const checkbox = document.querySelector("#bookmarksPermissions"); + if (checkbox.checked) { + const granted = await browser.permissions.request({permissions: ["bookmarks"]}); + if (!granted) { + checkbox.checked = false; + return; + } + } else { + await browser.permissions.remove({permissions: ["bookmarks"]}); + } + browser.runtime.sendMessage({ method: "resetBookmarksContext" }); +} + +async function enableDisableSync() { + const checkbox = document.querySelector("#syncCheck"); + await browser.storage.local.set({syncEnabled: !!checkbox.checked}); + browser.runtime.sendMessage({ method: "resetSync" }); +} + +async function enableDisableReplaceTab() { + const checkbox = document.querySelector("#replaceTabCheck"); + await browser.storage.local.set({replaceTabEnabled: !!checkbox.checked}); +} + +async function setupOptions() { + const hasPermission = await browser.permissions.contains({permissions: ["bookmarks"]}); + const { syncEnabled } = await browser.storage.local.get("syncEnabled"); + const { replaceTabEnabled } = await browser.storage.local.get("replaceTabEnabled"); + if (hasPermission) { + document.querySelector("#bookmarksPermissions").checked = true; + } + document.querySelector("#syncCheck").checked = !!syncEnabled; + document.querySelector("#replaceTabCheck").checked = !!replaceTabEnabled; + setupContainerShortcutSelects(); +} + +async function setupContainerShortcutSelects () { + const keyboardShortcut = await browser.runtime.sendMessage({method: "getShortcuts"}); + const identities = await browser.contextualIdentities.query({}); + const fragment = document.createDocumentFragment(); + const noneOption = document.createElement("option"); + noneOption.value = "none"; + noneOption.id = "none"; + noneOption.textContent = "None"; + fragment.append(noneOption); + + for (const identity of identities) { + const option = document.createElement("option"); + option.value = identity.cookieStoreId; + option.id = identity.cookieStoreId; + option.textContent = identity.name; + fragment.append(option); + } + + for (let i=0; i < NUMBER_OF_KEYBOARD_SHORTCUTS; i++) { + const shortcutKey = "open_container_"+i; + const shortcutSelect = document.getElementById(shortcutKey); + shortcutSelect.appendChild(fragment.cloneNode(true)); + if (keyboardShortcut && keyboardShortcut[shortcutKey]) { + const cookieStoreId = keyboardShortcut[shortcutKey]; + shortcutSelect.querySelector("#" + cookieStoreId).selected = true; + } + } +} + +function storeShortcutChoice (event) { + browser.runtime.sendMessage({ + method: "setShortcut", + shortcut: event.target.id, + cookieStoreId: event.target.value + }); +} + +function resetOnboarding() { + browser.storage.local.set({"onboarding-stage": 0}); +} + +document.addEventListener("DOMContentLoaded", setupOptions); +document.querySelector("#bookmarksPermissions").addEventListener( "change", requestPermissions); +document.querySelector("#syncCheck").addEventListener( "change", enableDisableSync); +document.querySelector("#replaceTabCheck").addEventListener( "change", enableDisableReplaceTab); +document.querySelector("button").addEventListener("click", resetOnboarding); + +for (let i=0; i < NUMBER_OF_KEYBOARD_SHORTCUTS; i++) { + document.querySelector("#open_container_"+i) + .addEventListener("change", storeShortcutChoice); +} \ No newline at end of file diff --git a/src/js/pageAction.js b/src/js/pageAction.js new file mode 100644 index 0000000..91445ce --- /dev/null +++ b/src/js/pageAction.js @@ -0,0 +1,35 @@ +async function init() { + const fragment = document.createDocumentFragment(); + + const identities = await browser.contextualIdentities.query({}); + + identities.forEach(identity => { + const tr = document.createElement("tr"); + tr.classList.add("menu-item", "hover-highlight"); + const td = document.createElement("td"); + + td.innerHTML = Utils.escaped` + + ${identity.name}`; + + tr.appendChild(td); + fragment.appendChild(tr); + + Utils.addEnterHandler(tr, async () => { + Utils.alwaysOpenInContainer(identity); + window.close(); + }); + }); + + const list = document.querySelector("#picker-identities-list"); + + list.innerHTML = ""; + list.appendChild(fragment); +} + +init(); diff --git a/src/js/popup.js b/src/js/popup.js index f1b97a4..79a28d4 100644 --- a/src/js/popup.js +++ b/src/js/popup.js @@ -1,8 +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 CONTAINER_HIDE_SRC = "/img/container-hide.svg"; -const CONTAINER_UNHIDE_SRC = "/img/container-unhide.svg"; + +const CONTAINER_HIDE_SRC = "/img/password-hide.svg"; +const CONTAINER_UNHIDE_SRC = "/img/password-hide.svg"; const DEFAULT_COLOR = "blue"; const DEFAULT_ICON = "circle"; @@ -11,52 +12,30 @@ 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"; -const P_CONTAINER_EDIT = "containerEdit"; +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_ONBOARDING_6 = "onboarding6"; +const P_ONBOARDING_7 = "onboarding7"; +const P_CONTAINERS_LIST = "containersList"; +const OPEN_NEW_CONTAINER_PICKER = "new-tab"; +const MANAGE_CONTAINERS_PICKER = "manage"; +const REOPEN_IN_CONTAINER_PICKER = "reopen-in"; +const ALWAYS_OPEN_IN_PICKER = "always-open-in"; +const P_CONTAINER_INFO = "containerInfo"; +const P_CONTAINER_EDIT = "containerEdit"; const P_CONTAINER_DELETE = "containerDelete"; const P_CONTAINERS_ACHIEVEMENT = "containersAchievement"; +const P_CONTAINER_ASSIGNMENTS = "containerAssignments"; -/** - * Escapes any occurances of &, ", <, > or / with XML entities. - * - * @param {string} str - * The string to escape. - * @return {string} The escaped string. - */ -function escapeXML(str) { - const replacements = {"&": "&", "\"": """, "'": "'", "<": "<", ">": ">", "/": "/"}; - return String(str).replace(/[&"'<>/]/g, m => replacements[m]); -} - -/** - * A tagged template function which escapes any XML metacharacters in - * interpolated values. - * - * @param {Array} strings - * An array of literal strings extracted from the templates. - * @param {Array} values - * An array of interpolated values extracted from the template. - * @returns {string} - * The result of the escaped values interpolated with the literal - * strings. - */ -function escaped(strings, ...values) { - const result = []; - - for (const [i, string] of strings.entries()) { - result.push(string); - if (i < values.length) - result.push(escapeXML(values[i])); - } - - return result.join(""); +function addRemoveSiteIsolation() { + const identity = Logic.currentIdentity(); + browser.runtime.sendMessage({ + method: "addRemoveSiteIsolation", + cookieStoreId: identity.cookieStoreId + }); } async function getExtensionInfo() { @@ -71,7 +50,7 @@ const Logic = { _identities: [], _currentIdentity: null, _currentPanel: null, - _previousPanel: null, + _previousPanelPath: [], _panels: {}, _onboardingVariation: null, @@ -84,7 +63,7 @@ const Logic = { try { await identitiesPromise; - } catch(e) { + } catch (e) { throw new Error("Failed to retrieve the identities or variation. We cannot continue. ", e.message); } @@ -98,9 +77,15 @@ const Logic = { } switch (onboarded) { - case 5: + case 7: this.showAchievementOrContainersListPanel(); break; + case 6: + this.showPanel(P_ONBOARDING_7); + break; + case 5: + this.showPanel(P_ONBOARDING_6); + break; case 4: this.showPanel(P_ONBOARDING_5); break; @@ -124,7 +109,7 @@ const Logic = { async showAchievementOrContainersListPanel() { // Do we need to show an achievement panel? let showAchievements = false; - const achievementsStorage = await browser.storage.local.get({achievements: []}); + const achievementsStorage = await browser.storage.local.get({ achievements: [] }); for (const achievement of achievementsStorage.achievements) { if (!achievement.done) { showAchievements = true; @@ -141,7 +126,7 @@ const Logic = { // they have to click the "Done" button to stop the panel // from showing async setAchievementDone(achievementName) { - const achievementsStorage = await browser.storage.local.get({achievements: []}); + const achievementsStorage = await browser.storage.local.get({ achievements: [] }); const achievements = achievementsStorage.achievements; achievements.forEach((achievement, index, achievementsArray) => { if (achievement.name === achievementName) { @@ -149,7 +134,7 @@ const Logic = { achievementsArray[index] = achievement; } }); - browser.storage.local.set({achievements}); + browser.storage.local.set({ achievements }); }, setOnboardingStage(stage) { @@ -160,9 +145,9 @@ const Logic = { async clearBrowserActionBadge() { const extensionInfo = await getExtensionInfo(); - const storage = await browser.storage.local.get({browserActionBadgesClicked: []}); - browser.browserAction.setBadgeBackgroundColor({color: null}); - browser.browserAction.setBadgeText({text: ""}); + const storage = await browser.storage.local.get({ browserActionBadgesClicked: [] }); + browser.browserAction.setBadgeBackgroundColor({ color: null }); + browser.browserAction.setBadgeText({ text: "" }); storage.browserActionBadgesClicked.push(extensionInfo.version); // use set and spread to create a unique array const browserActionBadgesClicked = [...new Set(storage.browserActionBadgesClicked)]; @@ -183,34 +168,28 @@ const Logic = { // Handle old style rejection with null and also Promise.reject new style try { return await browser.contextualIdentities.get(cookieStoreId) || defaultContainer; - } catch(e) { + } catch (e) { return defaultContainer; } }, - addEnterHandler(element, handler) { - element.addEventListener("click", (e) => { - handler(e); - }); - element.addEventListener("keydown", (e) => { - if (e.keyCode === 13) { - e.preventDefault(); - handler(e); - } - }); + async numTabs() { + const activeTabs = await browser.tabs.query({ windowId: browser.windows.WINDOW_ID_CURRENT }); + return activeTabs.length; }, - userContextId(cookieStoreId = "") { - const userContextId = cookieStoreId.replace("firefox-container-", ""); - return (userContextId !== cookieStoreId) ? Number(userContextId) : false; + _disableMenuItem(message, elementToDisable = document.querySelector("#move-to-new-window")) { + elementToDisable.setAttribute("title", message); + elementToDisable.removeAttribute("tabindex"); + elementToDisable.classList.remove("hover-highlight"); + elementToDisable.classList.add("disabled-menu-item"); }, - 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; + _enableMenuItems(elementToEnable = document.querySelector("#move-to-new-window")) { + elementToEnable.removeAttribute("title"); + elementToEnable.setAttribute("tabindex", "0"); + elementToEnable.classList.add("hover-highlight"); + elementToEnable.classList.remove("disabled-menu-item"); }, async refreshIdentities() { @@ -230,6 +209,7 @@ const Logic = { identity.hasHiddenTabs = stateObject.hasHiddenTabs; identity.numberOfHiddenTabs = stateObject.numberOfHiddenTabs; identity.numberOfOpenTabs = stateObject.numberOfOpenTabs; + identity.isIsolated = stateObject.isIsolated; } return identity; }); @@ -237,20 +217,25 @@ const Logic = { getPanelSelector(panel) { if (this._onboardingVariation === "securityOnboarding" && - panel.hasOwnProperty("securityPanelSelector")) { + // eslint-disable-next-line no-prototype-builtins + 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); + async showPanel(panel, currentIdentity = null, backwards = false) { + if (!backwards || !this._currentPanel) { + this._previousPanelPath.push(this._currentPanel); + } + + // If invalid panel, reset panels. + if (!(panel in this._panels)) { + panel = P_CONTAINERS_LIST; + this._previousPanelPath = []; } - this._previousPanel = this._currentPanel; this._currentPanel = panel; this._currentIdentity = currentIdentity; @@ -267,15 +252,20 @@ const Logic = { } } }); - document.querySelector(this.getPanelSelector(this._panels[panel])).classList.remove("hide"); + const panelEl = document.querySelector(this.getPanelSelector(this._panels[panel])); + panelEl.classList.remove("hide"); + + const focusEl = panelEl.querySelector(".firstTabindex"); + if(focusEl) { + focusEl.focus(); + } }, showPreviousPanel() { - if (!this._previousPanel) { + if (!this._previousPanelPath) { throw new Error("Current panel not set!"); } - - this.showPanel(this._previousPanel, this._currentIdentity); + this.showPanel(this._previousPanelPath.pop(), this._currentIdentity, true); }, registerPanel(panelName, panelObject) { @@ -296,7 +286,7 @@ const Logic = { currentUserContextId() { const identity = Logic.currentIdentity(); - return Logic.userContextId(identity.cookieStoreId); + return Utils.userContextId(identity.cookieStoreId); }, cookieStoreId(userContextId) { @@ -315,7 +305,7 @@ const Logic = { return browser.runtime.sendMessage({ method: "deleteContainer", - message: {userContextId} + message: { userContextId } }); }, @@ -327,19 +317,12 @@ const Logic = { }, getAssignmentObjectByContainer(userContextId) { + if (!userContextId) { + return {}; + } return browser.runtime.sendMessage({ method: "getAssignmentObjectByContainer", - message: {userContextId} - }); - }, - - setOrRemoveAssignment(tabId, url, userContextId, value) { - return browser.runtime.sendMessage({ - method: "setOrRemoveAssignment", - tabId, - url, - userContextId, - value + message: { userContextId } }); }, @@ -358,12 +341,98 @@ const Logic = { }); // Here we find the first valid id. - for (let id = 1;; ++id) { + for (let id = 1; ; ++id) { if (ids.indexOf(id) === -1) { return defaultName + (id < 10 ? "0" : "") + id; } } }, + + getCurrentPanelElement() { + const panelItem = this._panels[this._currentPanel]; + return document.querySelector(this.getPanelSelector(panelItem)); + }, + + listenToPickerBackButton() { + const closeContEl = document.querySelector("#close-container-picker-panel"); + if (!this._listenerSet) { + Utils.addEnterHandler(closeContEl, () => { + Logic.showPreviousPanel(); + }); + this._listenerSet = true; + } + }, + + shortcutListener(e){ + function openNewContainerTab(identity) { + try { + browser.tabs.create({ + cookieStoreId: identity.cookieStoreId + }); + window.close(); + } catch (e) { + window.close(); + } + } + const identities = Logic.identities(); + if ((e.keyCode >= 49 && e.keyCode <= 57) && + Logic._currentPanel === "containersList") { + const identity = identities[e.keyCode - 49]; + if (identity) { + openNewContainerTab(identity); + } + } + }, + + keyboardNavListener(e){ + const panelSelector = Logic.getPanelSelector(Logic._panels[Logic._currentPanel]); + const selectables = [...document.querySelectorAll(`${panelSelector} .keyboard-nav[tabindex='0']`)]; + const element = document.activeElement; + const backButton = document.querySelector(`${panelSelector} .keyboard-nav-back`); + const index = selectables.indexOf(element) || 0; + function next() { + const nextElement = selectables[index + 1]; + if (nextElement) { + nextElement.focus(); + } + } + function previous() { + const previousElement = selectables[index - 1]; + if (previousElement) { + previousElement.focus(); + } + } + switch (e.keyCode) { + case 40: + next(); + break; + case 38: + previous(); + break; + case 39: + { + if(element){ + element.click(); + } + + // If one Container is highlighted, + if (element.classList.contains("keyboard-right-arrow-override")) { + element.querySelector(".menu-right-float").click(); + } + + break; + } + case 37: + { + if(backButton){ + backButton.click(); + } + break; + } + default: + break; + } + } }; // P_ONBOARDING_1: First page for Onboarding. @@ -377,7 +446,7 @@ Logic.registerPanel(P_ONBOARDING_1, { initialize() { // Let's move to the next panel. [...document.querySelectorAll(".onboarding-start-button")].forEach(startElement => { - Logic.addEnterHandler(startElement, async () => { + Utils.addEnterHandler(startElement, async () => { await Logic.setOnboardingStage(1); Logic.showPanel(P_ONBOARDING_2); }); @@ -401,7 +470,7 @@ Logic.registerPanel(P_ONBOARDING_2, { initialize() { // Let's move to the containers list panel. [...document.querySelectorAll(".onboarding-next-button")].forEach(nextElement => { - Logic.addEnterHandler(nextElement, async () => { + Utils.addEnterHandler(nextElement, async () => { await Logic.setOnboardingStage(2); Logic.showPanel(P_ONBOARDING_3); }); @@ -425,7 +494,7 @@ Logic.registerPanel(P_ONBOARDING_3, { initialize() { // Let's move to the containers list panel. [...document.querySelectorAll(".onboarding-almost-done-button")].forEach(almostElement => { - Logic.addEnterHandler(almostElement, async () => { + Utils.addEnterHandler(almostElement, async () => { await Logic.setOnboardingStage(3); Logic.showPanel(P_ONBOARDING_4); }); @@ -447,7 +516,7 @@ Logic.registerPanel(P_ONBOARDING_4, { // This method is called when the object is registered. initialize() { // Let's move to the containers list panel. - Logic.addEnterHandler(document.querySelector("#onboarding-done-button"), async () => { + Utils.addEnterHandler(document.querySelector("#onboarding-done-button"), async () => { await Logic.setOnboardingStage(4); Logic.showPanel(P_ONBOARDING_5); }); @@ -468,8 +537,41 @@ Logic.registerPanel(P_ONBOARDING_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 () => { + Utils.addEnterHandler(document.querySelector("#onboarding-longpress-button"), async () => { await Logic.setOnboardingStage(5); + Logic.showPanel(P_ONBOARDING_6); + }); + }, + + // This method is called when the panel is shown. + prepare() { + return Promise.resolve(null); + }, +}); + +// P_ONBOARDING_6: Sixth page for Onboarding: new tab long-press behavior +// ---------------------------------------------------------------------------- + +Logic.registerPanel(P_ONBOARDING_6, { + panelSelector: ".onboarding-panel-6", + + // This method is called when the object is registered. + initialize() { + // Let's move to the containers list panel. + Utils.addEnterHandler(document.querySelector("#start-sync-button"), async () => { + await Logic.setOnboardingStage(6); + await browser.storage.local.set({syncEnabled: true}); + await browser.runtime.sendMessage({ + method: "resetSync" + }); + Logic.showPanel(P_ONBOARDING_7); + }); + Utils.addEnterHandler(document.querySelector("#no-sync"), async () => { + await Logic.setOnboardingStage(7); + await browser.storage.local.set({syncEnabled: false}); + await browser.runtime.sendMessage({ + method: "resetSync" + }); Logic.showPanel(P_CONTAINERS_LIST); }); }, @@ -480,6 +582,33 @@ Logic.registerPanel(P_ONBOARDING_5, { }, }); +// P_ONBOARDING_6: Sixth page for Onboarding: new tab long-press behavior +// ---------------------------------------------------------------------------- + +Logic.registerPanel(P_ONBOARDING_7, { + panelSelector: ".onboarding-panel-7", + + // This method is called when the object is registered. + initialize() { + // Let's move to the containers list panel. + Utils.addEnterHandler(document.querySelector("#sign-in"), async () => { + browser.tabs.create({ + url: "https://accounts.firefox.com/?service=sync&action=email&context=fx_desktop_v3&entrypoint=multi-account-containers&utm_source=addon&utm_medium=panel&utm_campaign=container-sync", + }); + await Logic.setOnboardingStage(7); + Logic.showPanel(P_CONTAINERS_LIST); + }); + Utils.addEnterHandler(document.querySelector("#no-sign-in"), async () => { + await Logic.setOnboardingStage(7); + Logic.showPanel(P_CONTAINERS_LIST); + }); + }, + + // This method is called when the panel is shown. + prepare() { + return Promise.resolve(null); + }, +}); // P_CONTAINERS_LIST: The list of containers. The main page. // ---------------------------------------------------------------------------- @@ -488,17 +617,24 @@ Logic.registerPanel(P_CONTAINERS_LIST, { // This method is called when the object is registered. async initialize() { - Logic.addEnterHandler(document.querySelector("#container-add-link"), () => { - Logic.showPanel(P_CONTAINER_EDIT, { name: Logic.generateIdentityName() }); - }); - - Logic.addEnterHandler(document.querySelector("#edit-containers-link"), (e) => { - if (!e.target.classList.contains("disable-edit-containers")){ - Logic.showPanel(P_CONTAINERS_EDIT); + Utils.addEnterHandler(document.querySelector("#manage-containers-link"), (e) => { + if (!e.target.classList.contains("disable-edit-containers")) { + Logic.showPanel(MANAGE_CONTAINERS_PICKER); } }); - - Logic.addEnterHandler(document.querySelector("#sort-containers-link"), async () => { + Utils.addEnterHandler(document.querySelector("#open-new-tab-in"), () => { + Logic.showPanel(OPEN_NEW_CONTAINER_PICKER); + }); + Utils.addEnterHandler(document.querySelector("#reopen-site-in"), () => { + Logic.showPanel(REOPEN_IN_CONTAINER_PICKER); + }); + Utils.addEnterHandler(document.querySelector("#always-open-in"), () => { + Logic.showPanel(ALWAYS_OPEN_IN_PICKER); + }); + Utils.addEnterHandler(document.querySelector("#info-icon"), () => { + browser.runtime.openOptionsPage(); + }); + Utils.addEnterHandler(document.querySelector("#sort-containers-link"), async () => { try { await browser.runtime.sendMessage({ method: "sortTabs" @@ -509,180 +645,83 @@ Logic.registerPanel(P_CONTAINERS_LIST, { } }); - document.addEventListener("keydown", (e) => { - const selectables = [...document.querySelectorAll("[tabindex='0'], [tabindex='-1']")]; - const element = document.activeElement; - const index = selectables.indexOf(element) || 0; - function next() { - const nextElement = selectables[index + 1]; - if (nextElement) { - nextElement.focus(); - } - } - function previous() { - const previousElement = selectables[index - 1]; - if (previousElement) { - previousElement.focus(); - } - } - switch (e.keyCode) { - case 40: - next(); - break; - case 38: - previous(); - break; - default: - if ((e.keyCode >= 49 && e.keyCode <= 57) && - Logic._currentPanel === "containersList") { - const element = selectables[e.keyCode - 48]; - if (element) { - element.click(); - } - } - 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. async prepare() { const fragment = document.createDocumentFragment(); - this.prepareCurrentTabHeader(); - Logic.identities().forEach(identity => { - const hasTabs = (identity.hasHiddenTabs || identity.hasOpenTabs); const tr = document.createElement("tr"); - const context = document.createElement("td"); - const manage = document.createElement("td"); + tr.classList.add("menu-item", "hover-highlight", "keyboard-nav", "keyboard-right-arrow-override"); + tr.setAttribute("tabindex", "0"); + const td = document.createElement("td"); + const openTabs = identity.numberOfOpenTabs || "" ; - tr.classList.add("container-panel-row"); - - 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` -
-
+ td.innerHTML = Utils.escaped` + -
`; - context.querySelector(".container-name").textContent = identity.name; - manage.innerHTML = ""; + + ${openTabs} + + Container Info + + `; fragment.appendChild(tr); - tr.appendChild(context); + tr.appendChild(td); - if (hasTabs) { - tr.appendChild(manage); - } - - Logic.addEnterHandler(tr, async (e) => { - if (e.target.matches(".open-newtab") - || e.target.parentNode.matches(".open-newtab") - || e.type === "keydown") { - try { - browser.tabs.create({ - cookieStoreId: identity.cookieStoreId - }); - window.close(); - } catch (e) { - window.close(); - } - } else if (hasTabs) { - Logic.showPanel(P_CONTAINER_INFO, identity); + const openInThisContainer = tr.querySelector(".menu-item-name"); + Utils.addEnterHandler(openInThisContainer, () => { + try { + browser.tabs.create({ + cookieStoreId: identity.cookieStoreId + }); + window.close(); + } catch (e) { + window.close(); } }); + + Utils.addEnterOnlyHandler(tr, () => { + try { + browser.tabs.create({ + cookieStoreId: identity.cookieStoreId + }); + window.close(); + } catch (e) { + window.close(); + } + }); + + // Select only the ">" from the container list + const showPanelButton = tr.querySelector(".menu-right-float"); + + Utils.addEnterHandler(showPanelButton, () => { + Logic.showPanel(P_CONTAINER_INFO, identity); + }); + + }); - const list = document.querySelector(".identities-list tbody"); + const list = document.querySelector("#identities-list"); 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 - */ - 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); - }); - /* If no container is present disable the Edit Containers button */ - const editContainer = document.querySelector("#edit-containers-link"); - if (Logic.identities().length === 0) { - editContainer.classList.add("disable-edit-containers"); - } else { - editContainer.classList.remove("disable-edit-containers"); - } + document.addEventListener("keydown", Logic.keyboardNavListener); + document.addEventListener("keydown", Logic.shortcutListener); return Promise.resolve(); }, }); @@ -695,25 +734,14 @@ Logic.registerPanel(P_CONTAINER_INFO, { // This method is called when the object is registered. async initialize() { - Logic.addEnterHandler(document.querySelector("#close-container-info-panel"), () => { + const closeContEl = document.querySelector("#close-container-info-panel"); + Utils.addEnterHandler(closeContEl, () => { Logic.showPreviousPanel(); }); - Logic.addEnterHandler(document.querySelector("#container-info-hideorshow"), async () => { - const identity = Logic.currentIdentity(); - try { - browser.runtime.sendMessage({ - method: identity.hasHiddenTabs ? "showTabs" : "hideTabs", - windowId: browser.windows.WINDOW_ID_CURRENT, - cookieStoreId: Logic.currentCookieStoreId() - }); - window.close(); - } catch (e) { - window.close(); - } - }); - // Check if the user has incompatible add-ons installed + // Note: this is not implemented in messageHandler.js + let incompatible = false; try { const incompatible = await browser.runtime.sendMessage({ method: "checkIncompatibleAddons" @@ -745,16 +773,18 @@ Logic.registerPanel(P_CONTAINER_INFO, { } catch (e) { throw new Error("Could not check for incompatible add-ons."); } - const moveTabsEl = document.querySelector("#container-info-movetabs"); + + const moveTabsEl = document.querySelector("#move-to-new-window"); const numTabs = await Logic.numTabs(); if (incompatible) { - Logic._disableMoveTabs("Moving container tabs is incompatible with Pulse, PageShot, and SnoozeTabs."); + Logic._disableMenuItem("Moving container tabs is incompatible with Pulse, PageShot, and SnoozeTabs."); return; } else if (numTabs === 1) { - Logic._disableMoveTabs("Cannot move a tab from a single-tab window."); - return; + Logic._disableMenuItem("Cannot move a tab from a single-tab window."); + return; } - Logic.addEnterHandler(moveTabsEl, async () => { + + Utils.addEnterHandler(moveTabsEl, async () => { await browser.runtime.sendMessage({ method: "moveTabsToWindow", windowId: browser.windows.WINDOW_ID_CURRENT, @@ -762,6 +792,425 @@ Logic.registerPanel(P_CONTAINER_INFO, { }); window.close(); }); + + + }, + + // This method is called when the panel is shown. + async prepare() { + const identity = Logic.currentIdentity(); + + const newTab = document.querySelector("#open-new-tab-in-info"); + Utils.addEnterHandler(newTab, () => { + try { + browser.tabs.create({ + cookieStoreId: identity.cookieStoreId + }); + window.close(); + } catch (e) { + window.close(); + } + }); + // Populating the panel: name and icon + document.getElementById("container-info-title").textContent = identity.name; + + const alwaysOpen = document.querySelector("#always-open-in-info-panel"); + Utils.addEnterHandler(alwaysOpen, async () => { + Utils.alwaysOpenInContainer(identity); + window.close(); + }); + // Show or not the has-tabs section. + for (let trHasTabs of document.getElementsByClassName("container-info-has-tabs")) { // eslint-disable-line prefer-const + trHasTabs.style.display = !identity.hasHiddenTabs && !identity.hasOpenTabs ? "none" : ""; + } + + if (identity.numberOfOpenTabs === 0) { + Logic._disableMenuItem("No tabs available for this container"); + } else { + Logic._enableMenuItems(); + } + + this.intializeShowHide(identity); + + // Let's retrieve the list of tabs. + const tabs = await browser.runtime.sendMessage({ + method: "getTabs", + windowId: browser.windows.WINDOW_ID_CURRENT, + cookieStoreId: Logic.currentIdentity().cookieStoreId + }); + const manageContainer = document.querySelector("#manage-container-link"); + Utils.addEnterHandler(manageContainer, async () => { + Logic.showPanel(P_CONTAINER_EDIT, identity); + }); + return this.buildOpenTabTable(tabs); + }, + + intializeShowHide(identity) { + const hideContEl = document.querySelector("#hideorshow-container"); + if (identity.numberOfOpenTabs === 0 && !identity.hasHiddenTabs) { + return Logic._disableMenuItem("No tabs available for this container", hideContEl); + } else { + Logic._enableMenuItems(hideContEl); + } + + Utils.addEnterHandler(hideContEl, async () => { + try { + browser.runtime.sendMessage({ + method: identity.hasHiddenTabs ? "showTabs" : "hideTabs", + windowId: browser.windows.WINDOW_ID_CURRENT, + cookieStoreId: Logic.currentCookieStoreId() + }); + window.close(); + } catch (e) { + window.close(); + } + }); + + const hideShowIcon = document.getElementById("container-info-hideorshow-icon"); + hideShowIcon.src = identity.hasHiddenTabs ? CONTAINER_UNHIDE_SRC : CONTAINER_HIDE_SRC; + + const hideShowLabel = document.getElementById("container-info-hideorshow-label"); + hideShowLabel.textContent = identity.hasHiddenTabs ? "Show this container" : "Hide this container"; + return; + }, + + buildOpenTabTable(tabs) { + // Let's remove all the previous tabs. + const table = document.getElementById("container-info-table"); + while (table.firstChild) { + table.firstChild.remove(); + } + + // For each one, let's create a new line. + const fragment = document.createDocumentFragment(); + for (let tab of tabs) { // eslint-disable-line prefer-const + const tr = document.createElement("tr"); + fragment.appendChild(tr); + tr.classList.add("menu-item", "hover-highlight", "keyboard-nav"); + tr.setAttribute("tabindex", "0"); + tr.innerHTML = Utils.escaped` + +
+ ${tab.title} + + `; + tr.querySelector(".favicon").appendChild(Utils.createFavIconElement(tab.favIconUrl)); + tr.setAttribute("tabindex", "0"); + table.appendChild(fragment); + + // On click, we activate this tab. But only if this tab is active. + if (!tab.hiddenState) { + Utils.addEnterHandler(tr, async () => { + await browser.tabs.update(tab.id, { active: true }); + window.close(); + }); + + const closeTab = tr.querySelector(".trash-button"); + if (closeTab) { + Utils.addEnterHandler(closeTab, async (e) => { + await browser.tabs.remove(Number(e.target.id)); + window.close(); + }); + } + } + } + }, +}); + +// OPEN_NEW_CONTAINER_PICKER: Opens a new container tab. +// ---------------------------------------------------------------------------- + +Logic.registerPanel(OPEN_NEW_CONTAINER_PICKER, { + panelSelector: "#container-picker-panel", + + // This method is called when the object is registered. + initialize() { + }, + + // This method is called when the panel is shown. + prepare() { + Logic.listenToPickerBackButton(); + document.getElementById("picker-title").textContent = "Open a New Tab in"; + const fragment = document.createDocumentFragment(); + const pickedFunction = function (identity) { + try { + browser.tabs.create({ + cookieStoreId: identity.cookieStoreId + }); + window.close(); + } catch (e) { + window.close(); + } + }; + + document.getElementById("new-container-div").innerHTML = ""; + + Logic.identities().forEach(identity => { + const tr = document.createElement("tr"); + tr.classList.add("menu-item", "hover-highlight", "keyboard-nav"); + tr.setAttribute("tabindex", "0"); + const td = document.createElement("td"); + + td.innerHTML = Utils.escaped` + + ${identity.name}`; + + fragment.appendChild(tr); + + tr.appendChild(td); + + Utils.addEnterHandler(tr, () => { + pickedFunction(identity); + }); + }); + + const list = document.querySelector("#picker-identities-list"); + + list.innerHTML = ""; + list.appendChild(fragment); + + return Promise.resolve(null); + } +}); + +// MANAGE_CONTAINERS_PICKER: Makes the list editable. +// ---------------------------------------------------------------------------- + +Logic.registerPanel(MANAGE_CONTAINERS_PICKER, { + panelSelector: "#container-picker-panel", + + // This method is called when the object is registered. + initialize() { + }, + + // This method is called when the panel is shown. + prepare() { + Logic.listenToPickerBackButton(); + const closeContEl = document.querySelector("#close-container-picker-panel"); + if (!this._listenerSet) { + Utils.addEnterHandler(closeContEl, () => { + Logic.showPreviousPanel(); + }); + this._listenerSet = true; + } + document.getElementById("picker-title").textContent = "Manage Containers"; + const fragment = document.createDocumentFragment(); + const pickedFunction = function (identity) { + Logic.showPanel(P_CONTAINER_EDIT, identity); + }; + + document.getElementById("new-container-div").innerHTML = Utils.escaped` + + + + + +
+ `; + + Utils.addEnterHandler(document.querySelector("#new-container"), () => { + Logic.showPanel(P_CONTAINER_EDIT, { name: Logic.generateIdentityName() }); + }); + + Logic.identities().forEach(identity => { + const tr = document.createElement("tr"); + tr.classList.add("menu-item", "hover-highlight", "keyboard-nav"); + tr.setAttribute("tabindex", "0"); + const td = document.createElement("td"); + + td.innerHTML = Utils.escaped` + + ${identity.name}`; + + fragment.appendChild(tr); + + tr.appendChild(td); + + Utils.addEnterHandler(tr, () => { + pickedFunction(identity); + }); + }); + + const list = document.querySelector("#picker-identities-list"); + + list.innerHTML = ""; + list.appendChild(fragment); + + return Promise.resolve(null); + } +}); + +// REOPEN_IN_CONTAINER_PICKER: Makes the list editable. +// ---------------------------------------------------------------------------- + +Logic.registerPanel(REOPEN_IN_CONTAINER_PICKER, { + panelSelector: "#container-picker-panel", + + // This method is called when the object is registered. + initialize() { + }, + + // This method is called when the panel is shown. + async prepare() { + Logic.listenToPickerBackButton(); + document.getElementById("picker-title").textContent = "Reopen This Site in"; + const fragment = document.createDocumentFragment(); + const currentTab = await Utils.currentTab(); + const pickedFunction = function (identity) { + const newUserContextId = Utils.userContextId(identity.cookieStoreId); + Utils.reloadInContainer( + currentTab.url, + false, + newUserContextId, + currentTab.index + 1, + currentTab.active + ); + window.close(); + }; + + document.getElementById("new-container-div").innerHTML = ""; + + if (currentTab.cookieStoreId !== "firefox-default") { + const tr = document.createElement("tr"); + tr.classList.add("menu-item", "hover-highlight", "keyboard-nav"); + const td = document.createElement("td"); + + td.innerHTML = Utils.escaped` + + Default Container`; + + fragment.appendChild(tr); + + tr.appendChild(td); + + Utils.addEnterHandler(tr, () => { + Utils.reloadInContainer( + currentTab.url, + false, + 0, + currentTab.index + 1, + currentTab.active + ); + window.close(); + }); + } + + Logic.identities().forEach(identity => { + if (currentTab.cookieStoreId !== identity.cookieStoreId) { + const tr = document.createElement("tr"); + tr.classList.add("menu-item", "hover-highlight", "keyboard-nav"); + tr.setAttribute("tabindex", "0"); + const td = document.createElement("td"); + + td.innerHTML = Utils.escaped` + + ${identity.name}`; + + fragment.appendChild(tr); + + tr.appendChild(td); + + Utils.addEnterHandler(tr, () => { + pickedFunction(identity); + }); + } + }); + + const list = document.querySelector("#picker-identities-list"); + + list.innerHTML = ""; + list.appendChild(fragment); + + return Promise.resolve(null); + } +}); + +// ALWAYS_OPEN_IN_PICKER: Makes the list editable. +// ---------------------------------------------------------------------------- + +Logic.registerPanel(ALWAYS_OPEN_IN_PICKER, { + panelSelector: "#container-picker-panel", + + // This method is called when the object is registered. + initialize() { + }, + + // This method is called when the panel is shown. + prepare() { + Logic.listenToPickerBackButton(); + document.getElementById("picker-title").textContent = "Reopen This Site in"; + const fragment = document.createDocumentFragment(); + + document.getElementById("new-container-div").innerHTML = ""; + + Logic.identities().forEach(identity => { + const tr = document.createElement("tr"); + tr.classList.add("menu-item", "hover-highlight", "keyboard-nav"); + tr.setAttribute("tabindex", "0"); + const td = document.createElement("td"); + + td.innerHTML = Utils.escaped` + + ${identity.name}`; + + fragment.appendChild(tr); + + tr.appendChild(td); + + Utils.addEnterHandler(tr, () => { + Utils.alwaysOpenInContainer(identity); + window.close(); + }); + }); + + const list = document.querySelector("#picker-identities-list"); + + list.innerHTML = ""; + list.appendChild(fragment); + + return Promise.resolve(null); + } +}); + +// P_CONTAINER_ASSIGNMENTS: Shows Site Assignments and allows editing. +// ---------------------------------------------------------------------------- + +Logic.registerPanel(P_CONTAINER_ASSIGNMENTS, { + panelSelector: "#edit-container-assignments", + + // This method is called when the object is registered. + initialize() { + const closeContEl = document.querySelector("#close-container-assignment-panel"); + Utils.addEnterHandler(closeContEl, () => { + Logic.showPreviousPanel(); + }); }, // This method is called when the panel is shown. @@ -769,150 +1218,53 @@ Logic.registerPanel(P_CONTAINER_INFO, { const identity = Logic.currentIdentity(); // Populating the panel: name and icon - document.getElementById("container-info-name").textContent = identity.name; + document.getElementById("edit-assignments-title").textContent = identity.name; - const icon = document.getElementById("container-info-icon"); - icon.setAttribute("data-identity-icon", identity.icon); - icon.setAttribute("data-identity-color", identity.color); - - // Show or not the has-tabs section. - for (let trHasTabs of document.getElementsByClassName("container-info-has-tabs")) { // eslint-disable-line prefer-const - trHasTabs.style.display = !identity.hasHiddenTabs && !identity.hasOpenTabs ? "none" : ""; - } - - const hideShowIcon = document.getElementById("container-info-hideorshow-icon"); - hideShowIcon.src = identity.hasHiddenTabs ? CONTAINER_UNHIDE_SRC : CONTAINER_HIDE_SRC; - - const hideShowLabel = document.getElementById("container-info-hideorshow-label"); - hideShowLabel.textContent = identity.hasHiddenTabs ? "Show this container" : "Hide this container"; - - // Let's remove all the previous tabs. - const table = document.getElementById("container-info-table"); - while (table.firstChild) { - table.firstChild.remove(); - } - - // Let's retrieve the list of tabs. - const tabs = await browser.runtime.sendMessage({ - method: "getTabs", - windowId: browser.windows.WINDOW_ID_CURRENT, - cookieStoreId: Logic.currentIdentity().cookieStoreId - }); - return this.buildInfoTable(tabs); - }, - - buildInfoTable(tabs) { - // For each one, let's create a new line. - const fragment = document.createDocumentFragment(); - for (let tab of tabs) { // eslint-disable-line prefer-const - const tr = document.createElement("tr"); - fragment.appendChild(tr); - tr.classList.add("container-info-tab-row"); - tr.innerHTML = escaped` - -
${tab.title}
`; - tr.querySelector("td").appendChild(Utils.createFavIconElement(tab.favIconUrl)); - document.getElementById("container-info-table").appendChild(fragment); - - // On click, we activate this tab. But only if this tab is active. - if (!tab.hiddenState) { - const closeImage = document.createElement("img"); - closeImage.src = "/img/container-close-tab.svg"; - closeImage.className = "container-close-tab"; - closeImage.title = "Close tab"; - closeImage.id = tab.id; - const tabTitle = tr.querySelector(".container-info-tab-title"); - tabTitle.appendChild(closeImage); - - // On hover, we add truncate-text class to add close-tab-image after tab title truncates - const tabTitleHoverEvent = () => { - tabTitle.classList.toggle("truncate-text"); - tr.querySelector(".container-tab-title").classList.toggle("truncate-text"); - }; - - tr.addEventListener("mouseover", tabTitleHoverEvent); - tr.addEventListener("mouseout", tabTitleHoverEvent); - - tr.classList.add("clickable"); - Logic.addEnterHandler(tr, async () => { - await browser.tabs.update(tab.id, {active: true}); - window.close(); - }); - - const closeTab = document.getElementById(tab.id); - if (closeTab) { - Logic.addEnterHandler(closeTab, async (e) => { - await browser.tabs.remove(Number(e.target.id)); - window.close(); - }); - } - } - } - }, -}); - -// P_CONTAINERS_EDIT: Makes the list editable. -// ---------------------------------------------------------------------------- - -Logic.registerPanel(P_CONTAINERS_EDIT, { - panelSelector: "#edit-containers-panel", - - // This method is called when the object is registered. - initialize() { - Logic.addEnterHandler(document.querySelector("#exit-edit-mode-link"), () => { - Logic.showPanel(P_CONTAINERS_LIST); - }); - }, - - // This method is called when the panel is shown. - prepare() { - const fragment = document.createDocumentFragment(); - Logic.identities().forEach(identity => { - const tr = document.createElement("tr"); - fragment.appendChild(tr); - tr.classList.add("container-panel-row"); - tr.innerHTML = escaped` - -
-
-
-
-
- - - - - - - `; - tr.querySelector(".container-name").textContent = identity.name; - tr.querySelector(".edit-container").setAttribute("title", `Edit ${identity.name} container`); - tr.querySelector(".remove-container").setAttribute("title", `Remove ${identity.name} container`); - - - Logic.addEnterHandler(tr, e => { - if (e.target.matches(".edit-container-icon") || e.target.parentNode.matches(".edit-container-icon")) { - Logic.showPanel(P_CONTAINER_EDIT, identity); - } else if (e.target.matches(".delete-container-icon") || e.target.parentNode.matches(".delete-container-icon")) { - Logic.showPanel(P_CONTAINER_DELETE, identity); - } - }); - }); - - const list = document.querySelector("#edit-identities-list"); - - list.innerHTML = ""; - list.appendChild(fragment); + const userContextId = Logic.currentUserContextId(); + const assignments = await Logic.getAssignmentObjectByContainer(userContextId); + this.showAssignedContainers(assignments); return Promise.resolve(null); }, + + showAssignedContainers(assignments) { + const assignmentPanel = document.getElementById("edit-sites-assigned"); + const assignmentKeys = Object.keys(assignments); + assignmentPanel.hidden = !(assignmentKeys.length > 0); + if (assignments) { + const tableElement = document.querySelector("#edit-sites-assigned"); + /* 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("tr"); + /* 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}/favicon.ico`; + trElement.innerHTML = Utils.escaped` + +
+ ${site.hostname} + + `; + trElement.getElementsByClassName("favicon")[0].appendChild(Utils.createFavIconElement(assumedUrl)); + const deleteButton = trElement.querySelector(".trash-button"); + Utils.addEnterHandler(deleteButton, async () => { + const userContextId = Logic.currentUserContextId(); + // Lets show the message to the current tab + // const currentTab = await Utils.currentTab(); + Utils.setOrRemoveAssignment(false, assumedUrl, userContextId, true); + delete assignments[siteKey]; + this.showAssignedContainers(assignments); + }); + trElement.classList.add("menu-item", "hover-highlight", "keyboard-nav"); + tableElement.appendChild(trElement); + }); + } + }, }); // P_CONTAINER_EDIT: Editor for a container. @@ -924,8 +1276,10 @@ Logic.registerPanel(P_CONTAINER_EDIT, { // This method is called when the object is registered. initialize() { this.initializeRadioButtons(); - - Logic.addEnterHandler(document.querySelector("#edit-container-panel-back-arrow"), () => { + Utils.addEnterHandler(document.querySelector("#close-container-edit-panel"), () => { + // Resets listener from siteIsolation checkbox to keep the update queue to 0. + const siteIsolation = document.querySelector("#site-isolation"); + siteIsolation.removeEventListener("change", addRemoveSiteIsolation, false); const formValues = new FormData(this._editForm); if (formValues.get("container-id") !== NEW_CONTAINER_ID) { this._submitForm(); @@ -934,19 +1288,15 @@ Logic.registerPanel(P_CONTAINER_EDIT, { } }); - Logic.addEnterHandler(document.querySelector("#edit-container-cancel-link"), () => { + this._editForm = document.getElementById("edit-container-panel-form"); + this._editForm.addEventListener("submit", () => { + this._submitForm(); + }); + Utils.addEnterHandler(document.querySelector("#create-container-cancel-link"), () => { Logic.showPreviousPanel(); }); - this._editForm = document.getElementById("edit-container-panel-form"); - const editLink = document.querySelector("#edit-container-ok-link"); - Logic.addEnterHandler(editLink, () => { - this._submitForm(); - }); - editLink.addEventListener("submit", () => { - this._submitForm(); - }); - this._editForm.addEventListener("submit", () => { + Utils.addEnterHandler(document.querySelector("#create-container-ok-link"), () => { this._submitForm(); }); }, @@ -973,56 +1323,12 @@ 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}/favicon.ico`; - trElement.innerHTML = escaped` -
-
- ${site.hostname} -
- `; - trElement.getElementsByClassName("favicon")[0].appendChild(Utils.createFavIconElement(assumedUrl)); - const deleteButton = trElement.querySelector(".delete-assignment"); - const that = this; - Logic.addEnterHandler(deleteButton, async () => { - const userContextId = Logic.currentUserContextId(); - // Lets show the message to the current tab - // TODO remove then when firefox supports arrow fn async - const currentTab = await Logic.currentTab(); - Logic.setOrRemoveAssignment(currentTab.id, assumedUrl, userContextId, true); - delete assignments[siteKey]; - that.showAssignedContainers(assignments); - }); - trElement.classList.add("container-info-tab-row", "clickable"); - tableElement.appendChild(trElement); - }); - } - }, - initializeRadioButtons() { const colorRadioTemplate = (containerColor) => { - return escaped` + return Utils.escaped`
@@ -22,49 +22,69 @@

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

- Get Started + 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 + 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 + 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.

- Next + 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 + Next +
+ +
+ Syncing Containers is now Available! +

Syncing Containers is now Available!

+

Turn on Sync to share container and site assignments with any computer connected to your Firefox Account.

+ +
+ +
+ Firefox Account is required to sync +

Firefox Account is required to sync.

+

Click Sign In to confirm that your Firefox Account is active.

+
@@ -92,123 +112,247 @@

- Done + Done
-
-
-

Current Tab

-
- +