diff --git a/README.md b/README.md index e85c870..47dce0c 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ 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 `npm run test` diff --git a/package.json b/package.json index 753f292..1258571 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "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": "6.2.0", "author": "Andrea Marchesini, Luke Crouch and Jonathan Kingston", "bugs": { "url": "https://github.com/mozilla/multi-account-containers/issues" @@ -10,7 +10,7 @@ "dependencies": {}, "devDependencies": { "addons-linter": "^1.3.2", - "ajv": "^6.6.2", + "ajv": "^6.6.3", "chai": "^4.2.0", "eslint": "^6.6.0", "eslint-plugin-no-unsanitized": "^2.0.0", @@ -19,14 +19,14 @@ "json": "^9.0.6", "mocha": "^6.2.2", "npm-run-all": "^4.0.0", - "nyc": "^14.1.1", + "nyc": "^15.0.0", "sinon": "^7.5.0", "sinon-chai": "^3.3.0", "stylelint": "^7.9.0", "stylelint-config-standard": "^16.0.0", "stylelint-order": "^0.3.0", "web-ext": "^2.9.3", - "webextensions-jsdom": "^1.1.0" + "webextensions-jsdom": "^1.2.1" }, "homepage": "https://github.com/mozilla/multi-account-containers#readme", "license": "MPL-2.0", @@ -44,7 +44,8 @@ "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 && npm run coverage", - "test-watch": "mocha ./test/setup.js test/**/*.test.js --watch", - "coverage": "nyc --reporter=html --reporter=text mocha ./test/setup.js test/**/*.test.js --timeout 60000" + "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/popup.css b/src/css/popup.css index d5f3295..2ffbce5 100644 --- a/src/css/popup.css +++ b/src/css/popup.css @@ -356,6 +356,35 @@ 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; diff --git a/src/img/Account.svg b/src/img/Account.svg new file mode 100644 index 0000000..4d049d4 --- /dev/null +++ b/src/img/Account.svg @@ -0,0 +1 @@ +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..d52b768 --- /dev/null +++ b/src/img/Sync.svg @@ -0,0 +1 @@ +Sync \ No newline at end of file diff --git a/src/js/.eslintrc.js b/src/js/.eslintrc.js index f78079f..11cd71a 100644 --- a/src/js/.eslintrc.js +++ b/src/js/.eslintrc.js @@ -7,6 +7,7 @@ module.exports = { "badge": true, "backgroundLogic": true, "identityState": true, - "messageHandler": true + "messageHandler": true, + "sync": true } }; diff --git a/src/js/background/assignManager.js b/src/js/background/assignManager.js index b48db75..9dd6e88 100644 --- a/src/js/background/assignManager.js +++ b/src/js/background/assignManager.js @@ -1,4 +1,4 @@ -const assignManager = { +window.assignManager = { MENU_ASSIGN_ID: "open-in-this-container", MENU_REMOVE_ID: "remove-open-in-this-container", MENU_SEPARATOR_ID: "separator", @@ -9,8 +9,9 @@ const assignManager = { 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,38 @@ 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; + }, + + getByUrlKey(siteStoreKey) { return new Promise((resolve, reject) => { this.area.get([siteStoreKey]).then((storageResponse) => { if (storageResponse && siteStoreKey in storageResponse) { @@ -54,51 +64,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,11 +175,12 @@ 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; }, - // 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 {}; @@ -129,13 +192,14 @@ 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 {}; @@ -152,7 +216,8 @@ const assignManager = { 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 @@ -162,8 +227,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]) { @@ -175,10 +242,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) { @@ -200,15 +269,27 @@ const assignManager = { 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); @@ -220,10 +301,13 @@ const assignManager = { init() { browser.contextMenus.onClicked.addListener((info, tab) => { - info.bookmarkId ? this._onClickedBookmark(info) : this._onClickedHandler(info, tab); + info.bookmarkId ? + this._onClickedBookmark(info) : + this._onClickedHandler(info, tab); }); - // 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); @@ -240,25 +324,34 @@ const assignManager = { delete this.canceledRequests[options.tabId]; } },{urls: [""], types: ["main_frame"]}); + this.resetBookmarksMenuItem(); }, async resetBookmarksMenuItem() { - const hasPermission = await browser.permissions.contains({permissions: ["bookmarks"]}); + 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); + 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); + browser.contextualIdentities.onCreated + .removeListener(this.contextualIdentityCreated); + browser.contextualIdentities.onUpdated + .removeListener(this.contextualIdentityUpdated); + browser.contextualIdentities.onRemoved + .removeListener(this.contextualIdentityRemoved); } }, @@ -267,19 +360,25 @@ const assignManager = { parentId: assignManager.OPEN_IN_CONTAINER, id: changeInfo.contextualIdentity.cookieStoreId, title: changeInfo.contextualIdentity.name, - icons: { "16": `img/usercontext.svg#${changeInfo.contextualIdentity.icon}` } + 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}` } - }); + 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); + browser.contextMenus.remove( + changeInfo.contextualIdentity.cookieStoreId + ); }, async _onClickedHandler(info, tab) { @@ -295,7 +394,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({ @@ -316,17 +417,20 @@ const assignManager = { async _onClickedBookmark(info) { async function _getBookmarksFromInfo(info) { - const [bookmarkTreeNode] = await browser.bookmarks.get(info.bookmarkId); + const [bookmarkTreeNode] = + await browser.bookmarks.get(info.bookmarkId); if (bookmarkTreeNode.type === "folder") { - return await browser.bookmarks.getChildren(bookmarkTreeNode.id); + 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") { + // 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 { @@ -354,7 +458,9 @@ const assignManager = { if (!("cookieStoreId" in tab)) { return false; } - return backgroundLogic.getUserContextIdFromCookieStoreId(tab.cookieStoreId); + return backgroundLogic.getUserContextIdFromCookieStoreId( + tab.cookieStoreId + ); }, isTabPermittedAssign(tab) { @@ -411,13 +517,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() { @@ -536,7 +642,7 @@ const assignManager = { 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 5d71fec..96e7939 100644 --- a/src/js/background/backgroundLogic.js +++ b/src/js/background/backgroundLogic.js @@ -324,7 +324,7 @@ const backgroundLogic = { containerState.hiddenTabs = []; await Promise.all(promises); - return await identityState.storageArea.set(options.cookieStoreId, containerState); + return identityState.storageArea.set(options.cookieStoreId, containerState); }, cookieStoreId(userContextId) { diff --git a/src/js/background/badge.js b/src/js/background/badge.js index 7d532ac..7b6ccf3 100644 --- a/src/js/background/badge.js +++ b/src/js/background/badge.js @@ -1,4 +1,4 @@ -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(); diff --git a/src/js/background/identityState.js b/src/js/background/identityState.js index fbb0020..8c01eb7 100644 --- a/src/js/background/identityState.js +++ b/src/js/background/identityState.js @@ -1,4 +1,4 @@ -const identityState = { +window.identityState = { storageArea: { area: browser.storage.local, @@ -11,12 +11,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; + } + throw new Error (`${cookieStoreId} not found`); }, set(cookieStoreId, data) { @@ -26,9 +37,41 @@ const identityState = { }); }, - remove(cookieStoreId) { + async remove(cookieStoreId) { const storeKey = this.getContainerStoreKey(cookieStoreId); return this.area.remove([storeKey]); + }, + + /* + * 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); + } + } + } } }, @@ -36,6 +79,16 @@ const identityState = { 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 +107,57 @@ 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() }; }, }; + +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 e167f0b..da380ba 100644 --- a/src/js/background/index.html +++ b/src/js/background/index.html @@ -18,5 +18,6 @@ + diff --git a/src/js/background/messageHandler.js b/src/js/background/messageHandler.js index 9578e6e..f4236f1 100644 --- a/src/js/background/messageHandler.js +++ b/src/js/background/messageHandler.js @@ -10,6 +10,9 @@ const messageHandler = { let response; switch (m.method) { + case "resetSync": + response = sync.resetSync(); + break; case "resetBookmarksContext": response = assignManager.resetBookmarksMenuItem(); break; 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/options.js b/src/js/options.js index 222a5b0..c225e63 100644 --- a/src/js/options.js +++ b/src/js/options.js @@ -1,4 +1,3 @@ - async function requestPermissions() { const checkbox = document.querySelector("#bookmarksPermissions"); if (checkbox.checked) { @@ -13,13 +12,30 @@ async function requestPermissions() { browser.runtime.sendMessage({ method: "resetBookmarksContext" }); } +async function enableDisableSync() { + const checkbox = document.querySelector("#syncCheck"); + if (checkbox.checked) { + await browser.storage.local.set({syncEnabled: true}); + } else { + await browser.storage.local.set({syncEnabled: false}); + } + browser.runtime.sendMessage({ method: "resetSync" }); +} + async function restoreOptions() { const hasPermission = await browser.permissions.contains({permissions: ["bookmarks"]}); + const { syncEnabled } = await browser.storage.local.get("syncEnabled"); if (hasPermission) { document.querySelector("#bookmarksPermissions").checked = true; } + if (syncEnabled) { + document.querySelector("#syncCheck").checked = true; + } else { + document.querySelector("#syncCheck").checked = false; + } } document.addEventListener("DOMContentLoaded", restoreOptions); -document.querySelector("#bookmarksPermissions").addEventListener( "change", requestPermissions); \ No newline at end of file +document.querySelector("#bookmarksPermissions").addEventListener( "change", requestPermissions); +document.querySelector("#syncCheck").addEventListener( "change", enableDisableSync); diff --git a/src/js/popup.js b/src/js/popup.js index 64dca45..b93981d 100644 --- a/src/js/popup.js +++ b/src/js/popup.js @@ -17,6 +17,8 @@ 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 P_CONTAINERS_EDIT = "containersEdit"; const P_CONTAINER_INFO = "containerInfo"; @@ -99,9 +101,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; @@ -352,6 +360,9 @@ const Logic = { }, getAssignmentObjectByContainer(userContextId) { + if (!userContextId) { + return {}; + } return browser.runtime.sendMessage({ method: "getAssignmentObjectByContainer", message: { userContextId } @@ -500,6 +511,39 @@ Logic.registerPanel(P_ONBOARDING_5, { // Let's move to the containers list panel. Logic.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. + Logic.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); + }); + Logic.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); }); }, @@ -510,6 +554,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. + Logic.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); + }); + Logic.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. // ---------------------------------------------------------------------------- @@ -721,8 +792,12 @@ Logic.registerPanel(P_CONTAINERS_LIST, { however it allows us to have a tabindex before the first selected item */ const focusHandler = () => { - list.querySelector("tr .clickable").focus(); - document.removeEventListener("focus", focusHandler); + const identityList = list.querySelector("tr .clickable"); + if (identityList) { + // otherwise this throws an error when there are no containers present. + identityList.focus(); + document.removeEventListener("focus", focusHandler); + } }; document.addEventListener("focus", focusHandler); /* If the user mousedown's first then remove the focus handler */ @@ -1022,6 +1097,7 @@ Logic.registerPanel(P_CONTAINER_EDIT, { while (tableElement.firstChild) { tableElement.firstChild.remove(); } + assignmentKeys.forEach((siteKey) => { const site = assignments[siteKey]; const trElement = document.createElement("div"); diff --git a/src/manifest.json b/src/manifest.json index 285388d..5dfc37b 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Firefox Multi-Account Containers", - "version": "6.1.1", + "version": "6.2.0", "incognito": "not_allowed", "description": "Multi-Account 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.", "icons": { diff --git a/src/options.html b/src/options.html index 1dcdd9b..31a567e 100644 --- a/src/options.html +++ b/src/options.html @@ -12,6 +12,11 @@ Enable Bookmark Menus

This setting allows you to open a bookmark or folder of bookmarks in a container.

+ +

This setting allows you to sync your containers and site assignments across devices.

diff --git a/src/popup.html b/src/popup.html index cf9878f..1497212 100644 --- a/src/popup.html +++ b/src/popup.html @@ -64,7 +64,27 @@ 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.

+
diff --git a/test/.eslintrc.js b/test/.eslintrc.js index d32e265..6d733f0 100644 --- a/test/.eslintrc.js +++ b/test/.eslintrc.js @@ -6,15 +6,7 @@ module.exports = { "parserOptions": { "ecmaVersion": 2018 }, - globals: { - "sinon": false, - "expect": false, - "nextTick": false, - "buildDom": false, - "buildBackgroundDom": false, - "background": false, - "buildPopupDom": false, - "popup": false, - "helper": false + "rules": { + "no-restricted-globals": ["error", "browser"] } } diff --git a/test/common.js b/test/common.js new file mode 100644 index 0000000..8640afa --- /dev/null +++ b/test/common.js @@ -0,0 +1,111 @@ +if (!process.listenerCount("unhandledRejection")) { + // eslint-disable-next-line no-console + process.on("unhandledRejection", r => console.log(r)); +} + +const path = require("path"); +const chai = require("chai"); +const sinonChai = require("sinon-chai"); +const crypto = require("crypto"); +const sinon = require("sinon"); +const expect = chai.expect; +chai.should(); +chai.use(sinonChai); +const nextTick = () => { + return new Promise(resolve => { + setTimeout(() => { + process.nextTick(resolve); + }); + }); +}; + +const webExtensionsJSDOM = require("webextensions-jsdom"); +const manifestPath = path.resolve(path.join(__dirname, "../src/manifest.json")); + +const buildDom = async ({background = {}, popup = {}}) => { + background = { + ...background, + jsdom: { + ...background.jsom, + beforeParse(window) { + window.browser.permissions.getAll.resolves({permissions: ["bookmarks"]}); + window.crypto = { + getRandomValues: arr => crypto.randomBytes(arr.length), + }; + } + } + }; + + popup = { + ...popup, + jsdom: { + ...popup.jsdom, + pretendToBeVisual: true + } + }; + + const webExtension = await webExtensionsJSDOM.fromManifest(manifestPath, { + apiFake: true, + wiring: true, + sinon: global.sinon, + background, + popup + }); + + webExtension.browser = webExtension.background.browser; + return webExtension; +}; + +const buildBackgroundDom = background => { + return buildDom({ + background, + popup: false + }); +}; + +const buildPopupDom = popup => { + return buildDom({ + popup, + background: false + }); +}; + +const initializeWithTab = async (details = { + cookieStoreId: "firefox-default" +}) => { + let tab; + const webExtension = await buildDom({ + background: { + async afterBuild(background) { + tab = await background.browser.tabs._create(details); + } + }, + popup: { + jsdom: { + beforeParse(window) { + window.browser.storage.local.set({ + "browserActionBadgesClicked": [], + "onboarding-stage": 7, + "achievements": [], + "syncEnabled": true + }); + window.browser.storage.local.set.resetHistory(); + window.browser.storage.sync.clear(); + } + } + } + }); + webExtension.tab = tab; + + return webExtension; +}; + +module.exports = { + buildDom, + buildBackgroundDom, + buildPopupDom, + initializeWithTab, + sinon, + expect, + nextTick, +}; \ No newline at end of file diff --git a/test/features/assignment.test.js b/test/features/assignment.test.js index 43bb981..ca50f49 100644 --- a/test/features/assignment.test.js +++ b/test/features/assignment.test.js @@ -1,25 +1,30 @@ -describe("Assignment Feature", () => { +const {initializeWithTab} = require("../common"); + +describe("Assignment Feature", function () { const url = "http://example.com"; - let activeTab; - beforeEach(async () => { - activeTab = await helper.browser.initializeWithTab({ + beforeEach(async function () { + this.webExt = await initializeWithTab({ cookieStoreId: "firefox-container-1", url }); }); - describe("click the 'Always open in' checkbox in the popup", () => { - beforeEach(async () => { + afterEach(function () { + this.webExt.destroy(); + }); + + describe("click the 'Always open in' checkbox in the popup", function () { + beforeEach(async function () { // popup click to set assignment for activeTab.url - await helper.popup.clickElementById("container-page-assigned"); + await this.webExt.popup.helper.clickElementById("container-page-assigned"); }); - describe("open new Tab with the assigned URL in the default container", () => { + describe("open new Tab with the assigned URL in the default container", function () { let newTab; - beforeEach(async () => { + beforeEach(async function () { // new Tab opening activeTab.url in default container - newTab = await helper.browser.openNewTab({ + newTab = await this.webExt.background.browser.tabs._create({ cookieStoreId: "firefox-default", url }, { @@ -29,12 +34,12 @@ describe("Assignment Feature", () => { }); }); - it("should open the confirm page", async () => { + it("should open the confirm page", async function () { // should have created a new tab with the confirm page - background.browser.tabs.create.should.have.been.calledWithMatch({ + this.webExt.background.browser.tabs.create.should.have.been.calledWithMatch({ url: "moz-extension://fake/confirm-page.html?" + `url=${encodeURIComponent(url)}` + - `&cookieStoreId=${activeTab.cookieStoreId}`, + `&cookieStoreId=${this.webExt.tab.cookieStoreId}`, cookieStoreId: undefined, openerTabId: null, index: 2, @@ -42,29 +47,29 @@ describe("Assignment Feature", () => { }); }); - it("should remove the new Tab that got opened in the default container", () => { - background.browser.tabs.remove.should.have.been.calledWith(newTab.id); + it("should remove the new Tab that got opened in the default container", function () { + this.webExt.background.browser.tabs.remove.should.have.been.calledWith(newTab.id); }); }); - describe("click the 'Always open in' checkbox in the popup again", () => { - beforeEach(async () => { + describe("click the 'Always open in' checkbox in the popup again", function () { + beforeEach(async function () { // popup click to remove assignment for activeTab.url - await helper.popup.clickElementById("container-page-assigned"); + await this.webExt.popup.helper.clickElementById("container-page-assigned"); }); - describe("open new Tab with the no longer assigned URL in the default container", () => { - beforeEach(async () => { + describe("open new Tab with the no longer assigned URL in the default container", function () { + beforeEach(async function () { // new Tab opening activeTab.url in default container - await helper.browser.openNewTab({ + await this.webExt.background.browser.tabs._create({ cookieStoreId: "firefox-default", url }); }); - it("should not open the confirm page", async () => { + it("should not open the confirm page", async function () { // should not have created a new tab - background.browser.tabs.create.should.not.have.been.called; + this.webExt.background.browser.tabs.create.should.not.have.been.called; }); }); }); diff --git a/test/features/containers.test.js b/test/features/containers.test.js index ac5bccd..d50a6b8 100644 --- a/test/features/containers.test.js +++ b/test/features/containers.test.js @@ -1,27 +1,33 @@ -describe("Containers Management", () => { - beforeEach(async () => { - await helper.browser.initializeWithTab(); +const {initializeWithTab} = require("../common"); + +describe("Containers Management", function () { + beforeEach(async function () { + this.webExt = await initializeWithTab(); }); - describe("creating a new container", () => { - beforeEach(async () => { - await helper.popup.clickElementById("container-add-link"); - await helper.popup.clickElementById("edit-container-ok-link"); + afterEach(function () { + this.webExt.destroy(); + }); + + describe("creating a new container", function () { + beforeEach(async function () { + await this.webExt.popup.helper.clickElementById("container-add-link"); + await this.webExt.popup.helper.clickElementById("edit-container-ok-link"); }); - it("should create it in the browser as well", () => { - background.browser.contextualIdentities.create.should.have.been.calledOnce; + it("should create it in the browser as well", function () { + this.webExt.background.browser.contextualIdentities.create.should.have.been.calledOnce; }); - describe("removing it afterwards", () => { - beforeEach(async () => { - await helper.popup.clickElementById("edit-containers-link"); - await helper.popup.clickLastMatchingElementByQuerySelector(".delete-container-icon"); - await helper.popup.clickElementById("delete-container-ok-link"); + describe("removing it afterwards", function () { + beforeEach(async function () { + await this.webExt.popup.helper.clickElementById("edit-containers-link"); + await this.webExt.popup.helper.clickElementByQuerySelectorAll(".delete-container-icon", "last"); + await this.webExt.popup.helper.clickElementById("delete-container-ok-link"); }); - it("should remove it in the browser as well", () => { - background.browser.contextualIdentities.remove.should.have.been.calledOnce; + it("should remove it in the browser as well", function () { + this.webExt.background.browser.contextualIdentities.remove.should.have.been.calledOnce; }); }); }); diff --git a/test/features/external-webextensions.test.js b/test/features/external-webextensions.test.js index 30e6b49..f3c6810 100644 --- a/test/features/external-webextensions.test.js +++ b/test/features/external-webextensions.test.js @@ -1,17 +1,24 @@ -describe("External Webextensions", () => { +const {expect, initializeWithTab} = require("../common"); + +describe("External Webextensions", function () { const url = "http://example.com"; - beforeEach(async () => { - await helper.browser.initializeWithTab({ + beforeEach(async function () { + this.webExt = await initializeWithTab({ cookieStoreId: "firefox-container-1", url }); - await helper.popup.clickElementById("container-page-assigned"); + + await this.webExt.popup.helper.clickElementById("container-page-assigned"); }); - describe("with contextualIdentities permissions", () => { - it("should be able to get assignments", async () => { - background.browser.management.get.resolves({ + afterEach(function () { + this.webExt.destroy(); + }); + + describe("with contextualIdentities permissions", function () { + it("should be able to get assignments", async function () { + this.webExt.background.browser.management.get.resolves({ permissions: ["contextualIdentities"] }); @@ -23,18 +30,19 @@ describe("External Webextensions", () => { id: "external-webextension" }; - const [promise] = background.browser.runtime.onMessageExternal.addListener.yield(message, sender); + const [promise] = this.webExt.background.browser.runtime.onMessageExternal.addListener.yield(message, sender); const answer = await promise; - expect(answer).to.deep.equal({ - userContextId: "1", - neverAsk: false - }); + expect(answer.userContextId === "1").to.be.true; + expect(answer.neverAsk === false).to.be.true; + expect( + Object.prototype.hasOwnProperty.call( + answer, "identityMacAddonUUID")).to.be.true; }); }); - describe("without contextualIdentities permissions", () => { - it("should throw an error", async () => { - background.browser.management.get.resolves({ + describe("without contextualIdentities permissions", function () { + it("should throw an error", async function () { + this.webExt.background.browser.management.get.resolves({ permissions: [] }); @@ -46,7 +54,7 @@ describe("External Webextensions", () => { id: "external-webextension" }; - const [promise] = background.browser.runtime.onMessageExternal.addListener.yield(message, sender); + const [promise] = this.webExt.background.browser.runtime.onMessageExternal.addListener.yield(message, sender); return promise.catch(error => { expect(error.message).to.equal("Missing contextualIdentities permission"); }); diff --git a/test/features/sync.test.js b/test/features/sync.test.js new file mode 100644 index 0000000..1e4070a --- /dev/null +++ b/test/features/sync.test.js @@ -0,0 +1,465 @@ +const {initializeWithTab} = require("../common"); + +describe("Sync", function() { + beforeEach(async function() { + this.webExt = await initializeWithTab(); + this.syncHelper = new SyncTestHelper(this.webExt); + }); + + afterEach(function() { + this.webExt.destroy(); + delete this.syncHelper; + }); + + it("testIdentityStateCleanup", async function() { + await this.syncHelper.stopSyncListeners(); + + await this.syncHelper.setState({}, LOCAL_DATA, TEST_CONTAINERS, []); + + await this.webExt.browser.storage.local.set({ + "identitiesState@@_firefox-container-5": { + "hiddenTabs": [] + } + }); + + await this.webExt.background.window.identityState.storageArea.upgradeData(); + + const macConfigs = await this.webExt.browser.storage.local.get(); + const identities = []; + for(const configKey of Object.keys(macConfigs)) { + if (configKey.includes("identitiesState@@_") && !configKey.includes("default")) { + identities.push(macConfigs[configKey]); + } + } + + identities.should.have.lengthOf(5, "There should be 5 identity entries"); + for (const identity of identities) { + (!!identity.macAddonUUID).should.be.true; + + } + }); + + it("testAssignManagerCleanup", async function() { + await this.syncHelper.stopSyncListeners(); + + await this.syncHelper.setState({}, LOCAL_DATA, TEST_CONTAINERS, TEST_ASSIGNMENTS); + + await this.webExt.browser.storage.local.set({ + "siteContainerMap@@_www.goop.com": { + "userContextId": "999", + "neverAsk": true + } + }); + + await this.webExt.background.window.identityState.storageArea.upgradeData(); + await this.webExt.background.window.assignManager.storageArea.upgradeData(); + const macConfigs = await this.webExt.browser.storage.local.get(); + const assignments = []; + for(const configKey of Object.keys(macConfigs)) { + if (configKey.includes("siteContainerMap@@_")) { + macConfigs[configKey].configKey = configKey; + assignments.push(macConfigs[configKey]); + } + } + assignments.should.have.lengthOf(5, "There should be 5 site assignments"); + for (const assignment of assignments) { + (!!assignment.identityMacAddonUUID).should.be.true; + } + }); + + it("testReconcileSiteAssignments", async function() { + await this.syncHelper.stopSyncListeners(); + + await this.syncHelper.setState( + DUPE_TEST_SYNC, + LOCAL_DATA, + TEST_CONTAINERS, + SITE_ASSIGNMENT_TEST + ); + + // add 200ok (bad data). + const testSites = { + "siteContainerMap@@_developer.mozilla.org": { + "userContextId": "588", + "neverAsk": true, + "identityMacAddonUUID": "d20d7af2-9866-468e-bb43-541efe8c2c2e", + "hostname": "developer.mozilla.org" + }, + "siteContainerMap@@_reddit.com": { + "userContextId": "592", + "neverAsk": true, + "identityMacAddonUUID": "3dc916fb-8c0a-4538-9758-73ef819a45f7", + "hostname": "reddit.com" + }, + "siteContainerMap@@_twitter.com": { + "userContextId": "589", + "neverAsk": true, + "identityMacAddonUUID": "cdd73c20-c26a-4c06-9b17-735c1f5e9187", + "hostname": "twitter.com" + }, + "siteContainerMap@@_www.facebook.com": { + "userContextId": "590", + "neverAsk": true, + "identityMacAddonUUID": "32cc4a9b-05ed-4e54-8e11-732468de62f4", + "hostname": "www.facebook.com" + }, + "siteContainerMap@@_www.linkedin.com": { + "userContextId": "591", + "neverAsk": true, + "identityMacAddonUUID": "9ff381e3-4c11-420d-8e12-e352a3318be1", + "hostname": "www.linkedin.com" + }, + "siteContainerMap@@_200ok.us": { + "userContextId": "1", + "neverAsk": true, + "identityMacAddonUUID": "b5f5f794-b37e-4cec-9f4e-6490df620336", + "hostname": "www.linkedin.com" + } + }; + + for (const site of Object.keys(testSites)) { + await this.webExt.browser.storage.sync.set({[site]:testSites[site]}); + } + + await this.webExt.browser.storage.sync.set({ + deletedSiteList: ["siteContainerMap@@_www.google.com"] + }); + await this.webExt.background.window.sync.runSync(); + + const assignedSites = await this.webExt.background.window.assignManager.storageArea.getAssignedSites(); + Object.keys(assignedSites).should.have.lengthOf(6); + }); + + it("testInitialSync", async function() { + await this.syncHelper.stopSyncListeners(); + await this.syncHelper.setState({}, LOCAL_DATA, TEST_CONTAINERS, []); + await this.webExt.background.window.sync.runSync(); + + const getAssignedSites = + await this.webExt.background.window.assignManager.storageArea.getAssignedSites(); + const identities = await this.webExt.browser.contextualIdentities.query({}); + + identities.should.have.lengthOf(5, "There should be 5 identity entries"); + Object.keys(getAssignedSites).should.have.lengthOf(0, "There should be no site assignments"); + }); + + it("test2", async function() { + await this.syncHelper.stopSyncListeners(); + + await this.syncHelper.setState(SYNC_DATA, LOCAL_DATA, TEST_CONTAINERS, TEST_ASSIGNMENTS); + + await this.webExt.background.window.sync.runSync(); + + const getAssignedSites = + await this.webExt.background.window.assignManager.storageArea.getAssignedSites(); + + const identities = await this.webExt.browser.contextualIdentities.query({}); + + identities.should.have.lengthOf(6, "There should be 6 identity entries"); + Object.keys(getAssignedSites).should.have.lengthOf(5, "There should be 5 site assignments"); + }); + + it("dupeTest", async function() { + await this.syncHelper.stopSyncListeners(); + await this.syncHelper.setState( + DUPE_TEST_SYNC, + DUPE_TEST_LOCAL, + DUPE_TEST_IDENTS, + DUPE_TEST_ASSIGNMENTS + ); + + await this.webExt.background.window.sync.runSync(); + + const getAssignedSites = + await this.webExt.background.window.assignManager.storageArea.getAssignedSites(); + + const identities = await this.webExt.browser.contextualIdentities.query({}); + + identities.should.have.lengthOf(7, "There should be 7 identity entries"); + + Object.keys(getAssignedSites).should.have.lengthOf(5, "There should be 5 identity entries"); + + const personalContainer = + this.syncHelper.lookupIdentityBy(identities, {name: "Personal"}); + (personalContainer.color === "red").should.be.true; + + const mozillaContainer = + this.syncHelper.lookupIdentityBy(identities, {name: "Mozilla"}); + (mozillaContainer.icon === "pet").should.be.true; + }); +}); + +class SyncTestHelper { + constructor(webExt) { + this.webExt = webExt; + } + + async stopSyncListeners() { + await this.webExt.browser.storage.onChanged.removeListener(this.webExt.background.window.sync.storageArea.onChangedListener); + await this.webExt.background.window.sync.removeContextualIdentityListeners(); + } + + async setState(syncData, localData, identityData, assignmentData){ + await this.removeAllContainers(); + await this.webExt.browser.storage.sync.clear(); + await this.webExt.browser.storage.sync.set(syncData); + await this.webExt.browser.storage.local.clear(); + await this.webExt.browser.storage.local.set(localData); + for (let i=0; i < identityData.length; i++) { + //build identities + const newIdentity = + await this.webExt.browser.contextualIdentities.create(identityData[i]); + // fill identies with site assignments + if (assignmentData && assignmentData[i]) { + const data = { + "userContextId": + String( + newIdentity.cookieStoreId.replace(/^firefox-container-/, "") + ), + "neverAsk": true + }; + + await this.webExt.browser.storage.local.set({[assignmentData[i]]: data}); + } + } + return; + } + + async removeAllContainers() { + const identities = await this.webExt.browser.contextualIdentities.query({}); + for (const identity of identities) { + await this.webExt.browser.contextualIdentities.remove(identity.cookieStoreId); + } + } + + lookupIdentityBy(identities, options) { + for (const identity of identities) { + if (options && options.name) { + if (identity.name === options.name) return identity; + } + if (options && options.color) { + if (identity.color === options.color) return identity; + } + if (options && options.color) { + if (identity.color === options.color) return identity; + } + } + return false; + } +} + +const TEST_CONTAINERS = [ + { + name: "Personal", + color: "blue", + icon: "fingerprint" + }, + { + name: "Banking", + color: "green", + icon: "dollar" + }, + { + name: "Mozilla", + color: "red", + icon: "briefcase" + }, + { + name: "Groceries, obviously", + color: "yellow", + icon: "cart" + }, + { + name: "Facebook", + color: "toolbar", + icon: "fence" + }, +]; + +const TEST_ASSIGNMENTS = [ + "siteContainerMap@@_developer.mozilla.org", + "siteContainerMap@@_twitter.com", + "siteContainerMap@@_www.facebook.com", + "siteContainerMap@@_www.linkedin.com", + "siteContainerMap@@_reddit.com" +]; + +const LOCAL_DATA = { + "browserActionBadgesClicked": [ "6.2.0" ], + "containerTabsOpened": 7, + "identitiesState@@_firefox-default": { "hiddenTabs": [] }, + "onboarding-stage": 5 +}; + +const SYNC_DATA = { + "identity@@_22ded543-5173-44a5-a47a-8813535945ca": { + "name": "Personal", + "icon": "fingerprint", + "color": "red", + "cookieStoreId": "firefox-container-146", + "macAddonUUID": "22ded543-5173-44a5-a47a-8813535945ca" + }, + "identity@@_63e5212f-0858-418e-b5a3-09c2dea61fcd": { + "name": "Oscar", + "icon": "dollar", + "color": "green", + "cookieStoreId": "firefox-container-147", + "macAddonUUID": "3e5212f-0858-418e-b5a3-09c2dea61fcd" + }, + "identity@@_71335417-158e-4d74-a55b-e9e9081601ec": { + "name": "Mozilla", + "icon": "pet", + "color": "red", + "cookieStoreId": "firefox-container-148", + "macAddonUUID": "71335417-158e-4d74-a55b-e9e9081601ec" + }, + "identity@@_59c4e5f7-fe3b-435a-ae60-1340db31a91b": { + "name": "Groceries, obviously", + "icon": "cart", + "color": "pink", + "cookieStoreId": "firefox-container-149", + "macAddonUUID": "59c4e5f7-fe3b-435a-ae60-1340db31a91b" + }, + "identity@@_3dc916fb-8c0a-4538-9758-73ef819a45f7": { + "name": "Facebook", + "icon": "fence", + "color": "toolbar", + "cookieStoreId": "firefox-container-150", + "macAddonUUID": "3dc916fb-8c0a-4538-9758-73ef819a45f7" + } +}; + +const DUPE_TEST_SYNC = { + "identity@@_d20d7af2-9866-468e-bb43-541efe8c2c2e": { + "name": "Personal", + "icon": "fingerprint", + "color": "red", + "cookieStoreId": "firefox-container-588", + "macAddonUUID": "d20d7af2-9866-468e-bb43-541efe8c2c2e" + }, + "identity@@_cdd73c20-c26a-4c06-9b17-735c1f5e9187": { + "name": "Big Bird", + "icon": "pet", + "color": "yellow", + "cookieStoreId": "firefox-container-589", + "macAddonUUID": "cdd73c20-c26a-4c06-9b17-735c1f5e9187" + }, + "identity@@_32cc4a9b-05ed-4e54-8e11-732468de62f4": { + "name": "Mozilla", + "icon": "pet", + "color": "red", + "cookieStoreId": "firefox-container-590", + "macAddonUUID": "32cc4a9b-05ed-4e54-8e11-732468de62f4" + }, + "identity@@_9ff381e3-4c11-420d-8e12-e352a3318be1": { + "name": "Groceries, obviously", + "icon": "cart", + "color": "pink", + "cookieStoreId": "firefox-container-591", + "macAddonUUID": "9ff381e3-4c11-420d-8e12-e352a3318be1" + }, + "identity@@_3dc916fb-8c0a-4538-9758-73ef819a45f7": { + "name": "Facebook", + "icon": "fence", + "color": "toolbar", + "cookieStoreId": "firefox-container-592", + "macAddonUUID": "3dc916fb-8c0a-4538-9758-73ef819a45f7" + }, + "identity@@_63e5212f-0858-418e-b5a3-09c2dea61fcd": { + "name": "Oscar", + "icon": "dollar", + "color": "green", + "cookieStoreId": "firefox-container-593", + "macAddonUUID": "63e5212f-0858-418e-b5a3-09c2dea61fcd" + }, + "siteContainerMap@@_developer.mozilla.org": { + "userContextId": "588", + "neverAsk": true, + "identityMacAddonUUID": "d20d7af2-9866-468e-bb43-541efe8c2c2e", + "hostname": "developer.mozilla.org" + }, + "siteContainerMap@@_reddit.com": { + "userContextId": "592", + "neverAsk": true, + "identityMacAddonUUID": "3dc916fb-8c0a-4538-9758-73ef819a45f7", + "hostname": "reddit.com" + }, + "siteContainerMap@@_twitter.com": { + "userContextId": "589", + "neverAsk": true, + "identityMacAddonUUID": "cdd73c20-c26a-4c06-9b17-735c1f5e9187", + "hostname": "twitter.com" + }, + "siteContainerMap@@_www.facebook.com": { + "userContextId": "590", + "neverAsk": true, + "identityMacAddonUUID": "32cc4a9b-05ed-4e54-8e11-732468de62f4", + "hostname": "www.facebook.com" + }, + "siteContainerMap@@_www.linkedin.com": { + "userContextId": "591", + "neverAsk": true, + "identityMacAddonUUID": "9ff381e3-4c11-420d-8e12-e352a3318be1", + "hostname": "www.linkedin.com" + } +}; + +const DUPE_TEST_LOCAL = { + "beenSynced": true, + "browserActionBadgesClicked": [ + "6.2.0" + ], + "containerTabsOpened": 7, + "identitiesState@@_firefox-default": { + "hiddenTabs": [] + }, + "onboarding-stage": 5, +}; + +const DUPE_TEST_ASSIGNMENTS = [ + "siteContainerMap@@_developer.mozilla.org", + "siteContainerMap@@_reddit.com", + "siteContainerMap@@_twitter.com", + "siteContainerMap@@_www.facebook.com", + "siteContainerMap@@_www.linkedin.com" +]; + +const SITE_ASSIGNMENT_TEST = [ + "siteContainerMap@@_developer.mozilla.org", + "siteContainerMap@@_www.facebook.com", + "siteContainerMap@@_www.google.com", + "siteContainerMap@@_bugzilla.mozilla.org" +]; + +const DUPE_TEST_IDENTS = [ + { + "name": "Personal", + "icon": "fingerprint", + "color": "blue", + }, + { + "name": "Banking", + "icon": "pet", + "color": "green", + }, + { + "name": "Mozilla", + "icon": "briefcase", + "color": "red", + }, + { + "name": "Groceries, obviously", + "icon": "cart", + "color": "orange", + }, + { + "name": "Facebook", + "icon": "fence", + "color": "toolbar", + }, + { + "name": "Big Bird", + "icon": "dollar", + "color": "yellow", + } +]; \ No newline at end of file diff --git a/test/helper.js b/test/helper.js deleted file mode 100644 index 2704bac..0000000 --- a/test/helper.js +++ /dev/null @@ -1,44 +0,0 @@ -module.exports = { - browser: { - async initializeWithTab(details = { - cookieStoreId: "firefox-default" - }) { - let tab; - await buildDom({ - background: { - async afterBuild(background) { - tab = await background.browser.tabs._create(details); - } - }, - popup: { - jsdom: { - beforeParse(window) { - window.browser.storage.local.set({ - "browserActionBadgesClicked": [], - "onboarding-stage": 5, - "achievements": [] - }); - window.browser.storage.local.set.resetHistory(); - } - } - } - }); - - return tab; - }, - - async openNewTab(tab, options = {}) { - return background.browser.tabs._create(tab, options); - } - }, - - popup: { - async clickElementById(id) { - await popup.helper.clickElementById(id); - }, - - async clickLastMatchingElementByQuerySelector(querySelector) { - await popup.helper.clickElementByQuerySelectorAll(querySelector, "last"); - } - } -}; diff --git a/test/issues/1168.test.js b/test/issues/1168.test.js index 50f463a..45d87e6 100644 --- a/test/issues/1168.test.js +++ b/test/issues/1168.test.js @@ -1,16 +1,19 @@ -describe("#1168", () => { - describe("when navigation happens too slow after opening new tab to a page which then redirects", () => { - let clock, tab; +const {expect, sinon, initializeWithTab} = require("../common"); - beforeEach(async () => { - await helper.browser.initializeWithTab({ +describe("#1168", function () { + describe("when navigation happens too slow after opening new tab to a page which then redirects", function () { + let clock, tab, background; + + beforeEach(async function () { + this.webExt = await initializeWithTab({ cookieStoreId: "firefox-container-1", url: "https://bugzilla.mozilla.org" }); - await helper.popup.clickElementById("container-page-assigned"); + + await this.webExt.popup.helper.clickElementById("container-page-assigned"); clock = sinon.useFakeTimers(); - tab = await helper.browser.openNewTab({}); + tab = await this.webExt.browser.tabs._create({}); clock.tick(2000); @@ -20,15 +23,16 @@ describe("#1168", () => { ]); }); + afterEach(function () { + this.webExt.destroy(); + clock.restore(); + }); + // Not solved yet // See: https://github.com/mozilla/multi-account-containers/issues/1168#issuecomment-378394091 - it.skip("should remove the old tab", async () => { + it.skip("should remove the old tab", async function () { expect(background.browser.tabs.create).to.have.been.calledOnce; expect(background.browser.tabs.remove).to.have.been.calledWith(tab.id); }); - - afterEach(() => { - clock.restore(); - }); }); }); \ No newline at end of file diff --git a/test/issues/940.test.js b/test/issues/940.test.js index 4a7eb0e..bbe4a64 100644 --- a/test/issues/940.test.js +++ b/test/issues/940.test.js @@ -1,15 +1,18 @@ -describe("#940", () => { - describe("when other onBeforeRequestHandlers are faster and redirect with the same requestId", () => { - it("should not open two confirm pages", async () => { - await helper.browser.initializeWithTab({ +const {expect, sinon, initializeWithTab} = require("../common"); + +describe("#940", function () { + describe("when other onBeforeRequestHandlers are faster and redirect with the same requestId", function () { + it("should not open two confirm pages", async function () { + const webExtension = await initializeWithTab({ cookieStoreId: "firefox-container-1", url: "http://example.com" }); - await helper.popup.clickElementById("container-page-assigned"); + + await webExtension.popup.helper.clickElementById("container-page-assigned"); const responses = {}; - await helper.browser.openNewTab({ - url: "http://example.com" + await webExtension.background.browser.tabs._create({ + url: "https://example.com" }, { options: { webRequestRedirects: ["https://example.com"], @@ -23,46 +26,55 @@ describe("#940", () => { expect(result).to.deep.equal({ cancel: true }); - background.browser.tabs.create.should.have.been.calledOnce; + webExtension.browser.tabs.create.should.have.been.calledOnce; + + webExtension.destroy(); }); }); - describe("when redirects change requestId midflight", () => { - let newTab; - const newTabResponses = {}; - const redirectedRequest = async (options = {}) => { - global.clock = sinon.useFakeTimers(); - newTab = await helper.browser.openNewTab({ - url: "http://youtube.com" - }, { - options: Object.assign({ - webRequestRedirects: [ - "https://youtube.com", - "https://www.youtube.com", - { - url: "https://www.youtube.com", - webRequest: { - requestId: 2 - } - } - ], - webRequestError: true, - instantRedirects: true - }, options), - responses: newTabResponses - }); - }; - - beforeEach(async () => { - await helper.browser.initializeWithTab({ + describe("when redirects change requestId midflight", function () { + beforeEach(async function () { + + this.webExt = await initializeWithTab({ cookieStoreId: "firefox-container-1", url: "https://www.youtube.com" }); - await helper.popup.clickElementById("container-page-assigned"); + await this.webExt.popup.helper.clickElementById("container-page-assigned"); + + global.clock = sinon.useFakeTimers(); + this.redirectedRequest = async (options = {}) => { + const newTabResponses = {}; + const newTab = await this.webExt.browser.tabs._create({ + url: "http://youtube.com" + }, { + options: Object.assign({ + webRequestRedirects: [ + "https://youtube.com", + "https://www.youtube.com", + { + url: "https://www.youtube.com", + webRequest: { + requestId: 2 + } + } + ], + webRequestError: true, + instantRedirects: true + }, options), + responses: newTabResponses + }); + + return [newTabResponses, newTab]; + }; }); - it("should not open two confirm pages", async () => { - await redirectedRequest(); + afterEach(function () { + this.webExt.destroy(); + global.clock.restore(); + }); + + it("should not open two confirm pages", async function () { + const [newTabResponses] = await this.redirectedRequest(); // http://youtube.com is not assigned, no cancel, no reopening expect(await newTabResponses.webRequest.onBeforeRequest[0]).to.deep.equal({}); @@ -80,17 +92,17 @@ describe("#940", () => { cancel: true }); - background.browser.tabs.create.should.have.been.calledOnce; + this.webExt.background.browser.tabs.create.should.have.been.calledOnce; }); - it("should uncancel after webRequest.onCompleted", async () => { - await redirectedRequest(); + it("should uncancel after webRequest.onCompleted", async function () { + const [newTabResponses, newTab] = await this.redirectedRequest(); // remove onCompleted listeners because in the real world this request would never complete // and thus might trigger unexpected behavior because the tab gets removed when reopening - background.browser.webRequest.onCompleted.addListener = sinon.stub(); - background.browser.tabs.create.resetHistory(); + this.webExt.background.browser.webRequest.onCompleted.addListener = sinon.stub(); + this.webExt.background.browser.tabs.create.resetHistory(); // we create a tab with the same id and use the same request id to see if uncanceled - await helper.browser.openNewTab({ + await this.webExt.browser.tabs._create({ id: newTab.id, url: "https://www.youtube.com" }, { @@ -101,14 +113,14 @@ describe("#940", () => { } }); - background.browser.tabs.create.should.have.been.calledOnce; + this.webExt.background.browser.tabs.create.should.have.been.calledOnce; }); - it("should uncancel after webRequest.onErrorOccurred", async () => { - await redirectedRequest(); - background.browser.tabs.create.resetHistory(); + it("should uncancel after webRequest.onErrorOccurred", async function () { + const [newTabResponses, newTab] = await this.redirectedRequest(); + this.webExt.background.browser.tabs.create.resetHistory(); // we create a tab with the same id and use the same request id to see if uncanceled - await helper.browser.openNewTab({ + await this.webExt.browser.tabs._create({ id: newTab.id, url: "https://www.youtube.com" }, { @@ -120,18 +132,18 @@ describe("#940", () => { } }); - background.browser.tabs.create.should.have.been.calledOnce; + this.webExt.background.browser.tabs.create.should.have.been.calledOnce; }); - it("should uncancel after 2 seconds", async () => { - await redirectedRequest({ + it("should uncancel after 2 seconds", async function () { + const [newTabResponses, newTab] = await this.redirectedRequest({ webRequestDontYield: ["onCompleted", "onErrorOccurred"] }); global.clock.tick(2000); - background.browser.tabs.create.resetHistory(); + this.webExt.background.browser.tabs.create.resetHistory(); // we create a tab with the same id and use the same request id to see if uncanceled - await helper.browser.openNewTab({ + await this.webExt.browser.tabs._create({ id: newTab.id, url: "https://www.youtube.com" }, { @@ -143,13 +155,13 @@ describe("#940", () => { } }); - background.browser.tabs.create.should.have.been.calledOnce; + this.webExt.background.browser.tabs.create.should.have.been.calledOnce; }); - it("should not influence the canceled url in other tabs", async () => { - await redirectedRequest(); - background.browser.tabs.create.resetHistory(); - await helper.browser.openNewTab({ + it("should not influence the canceled url in other tabs", async function () { + await this.redirectedRequest(); + this.webExt.background.browser.tabs.create.resetHistory(); + await this.webExt.browser.tabs._create({ cookieStoreId: "firefox-default", url: "https://www.youtube.com" }, { @@ -158,11 +170,7 @@ describe("#940", () => { } }); - background.browser.tabs.create.should.have.been.calledOnce; - }); - - afterEach(() => { - global.clock.restore(); + this.webExt.background.browser.tabs.create.should.have.been.calledOnce; }); }); }); diff --git a/test/setup.js b/test/setup.js deleted file mode 100644 index 0cc45ee..0000000 --- a/test/setup.js +++ /dev/null @@ -1,81 +0,0 @@ -if (!process.listenerCount("unhandledRejection")) { - // eslint-disable-next-line no-console - process.on("unhandledRejection", r => console.log(r)); -} -const path = require("path"); -const chai = require("chai"); -const sinonChai = require("sinon-chai"); -global.sinon = require("sinon"); -global.expect = chai.expect; -chai.should(); -chai.use(sinonChai); -global.nextTick = () => { - return new Promise(resolve => { - setTimeout(() => { - process.nextTick(resolve); - }); - }); -}; - -global.helper = require("./helper"); - -const webExtensionsJSDOM = require("webextensions-jsdom"); -const manifestPath = path.resolve(path.join(__dirname, "../src/manifest.json")); - -global.buildDom = async ({background = {}, popup = {}}) => { - background = { - ...background, - jsdom: { - ...background.jsom, - beforeParse(window) { - window.browser.permissions.getAll.resolves({permissions: ["bookmarks"]}); - } - } - }; - - popup = { - ...popup, - jsdom: { - ...popup.jsdom, - pretendToBeVisual: true - } - }; - - const webExtension = await webExtensionsJSDOM.fromManifest(manifestPath, { - apiFake: true, - wiring: true, - sinon: global.sinon, - background, - popup - }); - - // eslint-disable-next-line require-atomic-updates - global.background = webExtension.background; - // eslint-disable-next-line require-atomic-updates - global.popup = webExtension.popup; -}; - -global.buildBackgroundDom = async background => { - await global.buildDom({ - background, - popup: false - }); -}; - -global.buildPopupDom = async popup => { - await global.buildDom({ - popup, - background: false - }); -}; - - -global.afterEach(() => { - if (global.background) { - global.background.destroy(); - } - - if (global.popup) { - global.popup.destroy(); - } -});