diff --git a/src/confirm-page.html b/src/confirm-page.html
new file mode 100644
index 0000000..259af1a
--- /dev/null
+++ b/src/confirm-page.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/js/background/assignManager.js b/src/js/background/assignManager.js
new file mode 100644
index 0000000..9b4891f
--- /dev/null
+++ b/src/js/background/assignManager.js
@@ -0,0 +1,866 @@
+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(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}`;
+ } else {
+ return `${storagePrefix}${url.hostname}${url.port}`;
+ }
+ },
+
+ getWildcardStoreKey(wildcardHostname) {
+ return `wildcardMap@@_${wildcardHostname}`;
+ },
+
+ setExempted(pageUrlorUrlKey, tabId) {
+ const siteStoreKey = this.getSiteStoreKey(pageUrlorUrlKey);
+ if (!(siteStoreKey in this.exemptedTabs)) {
+ this.exemptedTabs[siteStoreKey] = [];
+ }
+ this.exemptedTabs[siteStoreKey].push(tabId);
+ },
+
+ removeExempted(pageUrlorUrlKey) {
+ const siteStoreKey = this.getSiteStoreKey(pageUrlorUrlKey);
+ this.exemptedTabs[siteStoreKey] = [];
+ },
+
+ isExempted(pageUrlorUrlKey, tabId) {
+ const siteStoreKey = this.getSiteStoreKey(pageUrlorUrlKey);
+ if (!(siteStoreKey in this.exemptedTabs)) {
+ return false;
+ }
+ return this.exemptedTabs[siteStoreKey].includes(tabId);
+ },
+
+ get(pageUrlorUrlKey) {
+ const siteStoreKey = this.getSiteStoreKey(pageUrlorUrlKey);
+ return this.getByUrlKey(siteStoreKey);
+ },
+
+ async getOrWildcardMatch(pageUrlorUrlKey) {
+ const siteStoreKey = this.getSiteStoreKey(pageUrlorUrlKey);
+ const siteSettings = await this.getByUrlKey(siteStoreKey);
+ if (siteSettings) {
+ return {
+ siteStoreKey,
+ siteSettings
+ };
+ }
+ return this.getByWildcardMatch(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) {
+ resolve(storageResponse[siteStoreKey]);
+ }
+ resolve(null);
+ }).catch((e) => {
+ reject(e);
+ });
+ });
+ },
+
+ async getByWildcardMatch(siteStoreKey) {
+ // Keep stripping subdomains off site hostname until match a wildcard hostname
+ let remainingHostname = siteStoreKey.replace(/^siteContainerMap@@_/, "");
+ while (remainingHostname) {
+ const wildcardStoreKey = this.getWildcardStoreKey(remainingHostname);
+ siteStoreKey = await this.getByUrlKey(wildcardStoreKey);
+ if (siteStoreKey) {
+ const siteSettings = await this.getByUrlKey(siteStoreKey);
+ if (siteSettings) {
+ return {
+ siteStoreKey,
+ siteSettings
+ };
+ }
+ }
+ const indexOfDot = remainingHostname.indexOf(".");
+ remainingHostname = indexOfDot < 0 ? null : remainingHostname.substring(indexOfDot + 1);
+ }
+ },
+
+ async set(pageUrlorUrlKey, data, exemptedTabIds, backup = true) {
+ const siteStoreKey = this.getSiteStoreKey(pageUrlorUrlKey);
+ if (exemptedTabIds) {
+ exemptedTabIds.forEach((tabId) => {
+ this.setExempted(pageUrlorUrlKey, tabId);
+ });
+ }
+ await this.removeWildcardLookup(siteStoreKey);
+ // eslint-disable-next-line require-atomic-updates
+ data.identityMacAddonUUID =
+ await identityState.lookupMACaddonUUID(data.userContextId);
+ await this.area.set({
+ [siteStoreKey]: data
+ });
+ if (data.wildcardHostname) {
+ await this.setWildcardLookup(siteStoreKey, data.wildcardHostname);
+ }
+ const syncEnabled = await this.getSyncEnabled();
+ if (backup && syncEnabled) {
+ await sync.storageArea.backup({undeleteSiteStoreKey: siteStoreKey});
+ }
+ return;
+ },
+
+ async setWildcardLookup(siteStoreKey, wildcardHostname) {
+ const wildcardStoreKey = this.getWildcardStoreKey(wildcardHostname);
+ return this.area.set({
+ [wildcardStoreKey]: siteStoreKey
+ });
+ },
+
+ async remove(pageUrlorUrlKey, shouldSync = true) {
+ const siteStoreKey = this.getSiteStoreKey(pageUrlorUrlKey);
+ // When we remove an assignment we should clear all the exemptions
+ this.removeExempted(pageUrlorUrlKey);
+ // When we remove an assignment we should clear the wildcard lookup
+ await this.removeWildcardLookup(siteStoreKey);
+ await this.area.remove([siteStoreKey]);
+ const syncEnabled = await this.getSyncEnabled();
+ if (shouldSync && syncEnabled) await sync.storageArea.backup({siteStoreKey});
+ return;
+ },
+
+ async removeWildcardLookup(siteStoreKey) {
+ const siteSettings = await this.getByUrlKey(siteStoreKey);
+ const wildcardHostname = siteSettings && siteSettings.wildcardHostname;
+ if (wildcardHostname) {
+ const wildcardStoreKey = this.getWildcardStoreKey(wildcardHostname);
+ await this.area.remove([wildcardStoreKey]);
+ }
+ },
+
+ async deleteContainer(userContextId) {
+ const sitesByContainer = await this.getAssignedSites(userContextId);
+ this.area.remove(Object.keys(sitesByContainer));
+ // Delete wildcard lookups
+ const wildcardStoreKeys = Object.values(sitesByContainer)
+ .map((site) => {
+ if (site && site.wildcardHostname) {
+ return this.getWildcardStoreKey(site.wildcardHostname);
+ }
+ })
+ .filter((wildcardStoreKey) => { return !!wildcardStoreKey; });
+ this.area.remove(wildcardStoreKeys);
+ },
+
+ async getAssignedSites(userContextId = null) {
+ const sites = {};
+ const siteConfigs = await this.area.get();
+ 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 = 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
+ this.storageArea.getOrWildcardMatch(pageUrl).then((siteMatchResult) => {
+ if (siteMatchResult) {
+ siteMatchResult.siteSettings.neverAsk = true;
+ this.storageArea.set(siteMatchResult.siteStoreKey, siteMatchResult.siteSettings);
+ }
+ }).catch((e) => {
+ throw e;
+ });
+ }
+ },
+
+ // We return here so the confirm page can load the tab when exempted
+ async _exemptTab(m) {
+ const pageUrl = m.pageUrl;
+ 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 {};
+ }
+
+ const tab = await browser.tabs.get(requestInfo.tabId);
+ const result = await proxifiedContainers.retrieve(tab.cookieStoreId);
+ if (!result || !result.proxy) {
+ return {};
+ }
+
+ // proxyDNS only works for SOCKS proxies
+ if (["socks", "socks4"].includes(result.proxy.type)) {
+ result.proxy.proxyDNS = true;
+ }
+
+ if (!result.proxy.mozProxyEnabled) {
+ return result.proxy;
+ }
+
+ // Let's add the isolation key.
+ return [{ ...result.proxy, connectionIsolationKey: "" + MozillaVPN_Background.isolationKey }];
+ },
+
+ // 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 {};
+ }
+ this.removeContextMenu();
+ const [tab, siteMatchResult] = await Promise.all([
+ browser.tabs.get(options.tabId),
+ this.storageArea.getOrWildcardMatch(options.url)
+ ]);
+ const siteSettings = siteMatchResult && siteMatchResult.siteSettings;
+ let container;
+ try {
+ 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
+ if (siteSettings && !container) {
+ this.deleteContainer(siteSettings.userContextId);
+ return {};
+ }
+ const userContextId = this.getUserContextIdFromCookieStore(tab);
+
+ // 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)
+ || 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
+ this.canceledRequests[tab.id] = {
+ requestIds: {
+ [options.requestId]: true
+ },
+ urls: {
+ [options.url]: true
+ }
+ };
+
+ // 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]) {
+ delete this.canceledRequests[tab.id];
+ }
+ }, 2000);
+ } else {
+ let cancelEarly = false;
+ 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
+ cancelEarly = true;
+ }
+ // 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) {
+ return {
+ cancel: true
+ };
+ }
+ }
+
+ 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.
+ */
+ if (removeTab) {
+ browser.tabs.remove(tab.id);
+ }
+ return {
+ cancel: true,
+ };
+ },
+
+ 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;
+ },
+
+ maybeAddProxyListeners() {
+ if (browser.proxy) {
+ browser.proxy.onRequest.addListener(this.handleProxifiedRequest, {urls: [""]});
+ }
+ },
+
+ init() {
+ browser.contextMenus.onClicked.addListener((info, tab) => {
+ info.bookmarkId ?
+ this._onClickedBookmark(info) :
+ this._onClickedHandler(info, tab);
+ });
+
+ // Before anything happens we decide if the request should be proxified
+ this.maybeAddProxyListeners();
+
+ // 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);
+ },{urls: [""], types: ["main_frame"]}, ["blocking"]);
+
+ // Clean up canceled requests
+ browser.webRequest.onCompleted.addListener((options) => {
+ if (this.canceledRequests[options.tabId]) {
+ delete this.canceledRequests[options.tabId];
+ }
+ },{urls: [""], types: ["main_frame"]});
+ browser.webRequest.onErrorOccurred.addListener((options) => {
+ if (this.canceledRequests[options.tabId]) {
+ delete this.canceledRequests[options.tabId];
+ }
+ },{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) {
+ const userContextId = this.getUserContextIdFromCookieStore(tab);
+ // Mapping ${URL(info.pageUrl).hostname} to ${userContextId}
+ let remove;
+ if (userContextId) {
+ switch (info.menuItemId) {
+ case this.MENU_ASSIGN_ID:
+ case this.MENU_REMOVE_ID:
+ if (info.menuItemId === this.MENU_ASSIGN_ID) {
+ remove = false;
+ } else {
+ remove = true;
+ }
+ await this._setOrRemoveAssignment(
+ tab.id, info.pageUrl, userContextId, remove
+ );
+ break;
+ case this.MENU_MOVE_ID:
+ backgroundLogic.moveTabsToWindow({
+ cookieStoreId: tab.cookieStoreId,
+ windowId: tab.windowId,
+ });
+ break;
+ case this.MENU_HIDE_ID:
+ backgroundLogic.hideTabs({
+ cookieStoreId: tab.cookieStoreId,
+ windowId: tab.windowId,
+ });
+ break;
+ }
+ }
+ },
+
+ 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);
+ },
+
+ getUserContextIdFromCookieStore(tab) {
+ if (!("cookieStoreId" in tab)) {
+ return false;
+ }
+ return backgroundLogic.getUserContextIdFromCookieStoreId(
+ tab.cookieStoreId
+ );
+ },
+
+ isTabPermittedAssign(tab) {
+ // Ensure we are not an important about url
+ const url = new URL(tab.url);
+ if (url.protocol === "about:"
+ || url.protocol === "moz-extension:") {
+ return false;
+ }
+ return true;
+ },
+
+ async _setOrRemoveAssignment(tabId, pageUrl, userContextId, remove) {
+ let actionName;
+ // https://github.com/mozilla/testpilot-containers/issues/626
+ // Context menu has stored context IDs as strings, so we need to coerce
+ // the value to a string for accurate checking
+ userContextId = String(userContextId);
+
+ if (!remove) {
+ const tabs = await browser.tabs.query({});
+ const assignmentStoreKey = this.storageArea.getSiteStoreKey(pageUrl);
+ const exemptedTabIds = tabs.filter((tab) => {
+ const tabStoreKey = this.storageArea.getSiteStoreKey(tab.url);
+ /* Auto exempt all tabs that exist for this hostname that are not in the same container */
+ if (tabStoreKey === assignmentStoreKey &&
+ this.getUserContextIdFromCookieStore(tab) !== userContextId) {
+ return true;
+ }
+ return false;
+ }).map((tab) => {
+ return tab.id;
+ });
+
+ await this.storageArea.set(pageUrl, {
+ userContextId,
+ neverAsk: false
+ }, exemptedTabIds);
+ actionName = "assigned site to always open in this container";
+ } else {
+ // Remove assignment
+ await this.storageArea.remove(pageUrl);
+
+ actionName = "removed from assigned sites list";
+
+ // remove site isolation if now empty
+ await this._maybeRemoveSiteIsolation(userContextId);
+ }
+
+ if (tabId) {
+ const tab = await browser.tabs.get(tabId);
+ setTimeout(function(){
+ browser.tabs.sendMessage(tabId, {
+ text: `Successfully ${actionName}`
+ });
+ }, 1000);
+
+
+ this.calculateContextMenu(tab);
+ }
+ },
+
+ async _setWildcardHostnameForAssignment(pageUrl, wildcardHostname) {
+ const siteSettings = await this.storageArea.get(pageUrl);
+ if (siteSettings) {
+ siteSettings.wildcardHostname = wildcardHostname;
+ await this.storageArea.set(pageUrl, siteSettings);
+ }
+ },
+
+ 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) {
+ const cookieStore = this.getUserContextIdFromCookieStore(tab);
+ // Ensure we have a cookieStore to assign to
+ if (cookieStore
+ && this.isTabPermittedAssign(tab)) {
+ return this.storageArea.get(tab.url);
+ }
+ return false;
+ },
+
+ _getByContainer(userContextId) {
+ return this.storageArea.getAssignedSites(userContextId);
+ },
+
+ removeContextMenu() {
+ // There is a focus issue in this menu where if you change window with a context menu click
+ // you get the wrong menu display because of async
+ // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1215376#c16
+ // We also can't change for always private mode
+ // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1352102
+ browser.contextMenus.remove(this.MENU_ASSIGN_ID);
+ browser.contextMenus.remove(this.MENU_REMOVE_ID);
+ browser.contextMenus.remove(this.MENU_SEPARATOR_ID);
+ browser.contextMenus.remove(this.MENU_HIDE_ID);
+ browser.contextMenus.remove(this.MENU_MOVE_ID);
+ },
+
+ async calculateContextMenu(tab) {
+ this.removeContextMenu();
+ const siteSettings = await this._getAssignment(tab);
+ // Return early and not add an item if we have false
+ // False represents assignment is not permitted
+ if (siteSettings === false) {
+ return false;
+ }
+ let checked = false;
+ let menuId = this.MENU_ASSIGN_ID;
+ const tabUserContextId = this.getUserContextIdFromCookieStore(tab);
+ if (siteSettings &&
+ Number(siteSettings.userContextId) === Number(tabUserContextId)) {
+ checked = true;
+ menuId = this.MENU_REMOVE_ID;
+ }
+ browser.contextMenus.create({
+ id: menuId,
+ title: "Always Open in This Container",
+ checked,
+ type: "checkbox",
+ contexts: ["all"],
+ });
+
+ browser.contextMenus.create({
+ id: this.MENU_SEPARATOR_ID,
+ type: "separator",
+ contexts: ["all"],
+ });
+
+ browser.contextMenus.create({
+ id: this.MENU_HIDE_ID,
+ title: "Hide This Container",
+ contexts: ["all"],
+ });
+
+ browser.contextMenus.create({
+ id: this.MENU_MOVE_ID,
+ title: "Move Tabs to a New Window",
+ contexts: ["all"],
+ });
+ },
+
+ encodeURLProperty(url) {
+ return encodeURIComponent(url).replace(/[!'()*]/g, (c) => {
+ const charCode = c.charCodeAt(0).toString(16);
+ return `%${charCode}`;
+ });
+ },
+
+ 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.runtime.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) {
+ return browser.tabs.create({url, cookieStoreId, index, active, openerTabId});
+ } else {
+ let confirmUrl = `${loadPage}?url=${this.encodeURLProperty(url)}&cookieStoreId=${cookieStoreId}`;
+ let currentCookieStoreId;
+ if (currentUserContextId) {
+ currentCookieStoreId = backgroundLogic.cookieStoreId(currentUserContextId);
+ confirmUrl += `¤tCookieStoreId=${currentCookieStoreId}`;
+ }
+ return browser.tabs.create({
+ url: confirmUrl,
+ cookieStoreId: currentCookieStoreId,
+ openerTabId,
+ index,
+ active
+ }).then(() => {
+ // We don't want to sync this URL ever nor clutter the users history
+ browser.history.deleteUrl({url: confirmUrl});
+ }).catch((e) => {
+ 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
new file mode 100644
index 0000000..1050d40
--- /dev/null
+++ b/src/js/background/backgroundLogic.js
@@ -0,0 +1,406 @@
+/* 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 DEFAULT_TAB = "about:newtab";
+
+const backgroundLogic = {
+ NEW_TAB_PAGES: new Set([
+ "about:startpage",
+ "about:newtab",
+ "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});
+ }
+ }
+ });
+
+ browser.permissions.onAdded.addListener(permissions => this.resetPermissions(permissions));
+ browser.permissions.onRemoved.addListener(permissions => this.resetPermissions(permissions));
+ },
+
+ resetPermissions(permissions) {
+ permissions.permissions.forEach(async permission => {
+ switch (permission) {
+ case "bookmarks":
+ assignManager.resetBookmarksMenuItem();
+ break;
+
+ case "nativeMessaging":
+ await MozillaVPN_Background.removeMozillaVpnProxies();
+ await browser.runtime.reload();
+ break;
+
+ case "proxy":
+ assignManager.maybeAddProxyListeners();
+ break;
+ }
+ });
+ },
+
+ async getExtensionInfo() {
+ const manifestPath = browser.runtime.getURL("manifest.json");
+ const response = await fetch(manifestPath);
+ const extensionInfo = await response.json();
+ return extensionInfo;
+ },
+
+ getUserContextIdFromCookieStoreId(cookieStoreId) {
+ if (!cookieStoreId) {
+ return false;
+ }
+ const container = cookieStoreId.replace("firefox-container-", "");
+ if (container !== cookieStoreId) {
+ return container;
+ }
+ return false;
+ },
+
+ async deleteContainer(userContextId, removed = false) {
+ await this._closeTabs(userContextId);
+
+ if (!removed) {
+ await browser.contextualIdentities.remove(this.cookieStoreId(userContextId));
+ }
+
+ assignManager.deleteContainer(userContextId);
+
+ // Now remove the identity->proxy association in proxifiedContainers also
+ proxifiedContainers.delete(this.cookieStoreId(userContextId));
+
+ return {done: true, userContextId};
+ },
+
+ async createOrUpdateContainer(options) {
+ if (options.userContextId !== "new") {
+ return await browser.contextualIdentities.update(
+ this.cookieStoreId(options.userContextId),
+ options.params
+ );
+ }
+ return await browser.contextualIdentities.create(options.params);
+ },
+
+ 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
+
+ // We can't open new tab pages, so open a blank tab. Used in tab un-hide
+ if (this.NEW_TAB_PAGES.has(url)) {
+ url = undefined;
+ }
+
+ if (!this.isPermissibleURL(url)) {
+ return;
+ }
+
+ return browser.tabs.create({
+ url,
+ active,
+ discarded,
+ pinned: options.pinned || false,
+ cookieStoreId
+ });
+ },
+
+ isPermissibleURL(url) {
+ const protocol = new URL(url).protocol;
+ // We can't open these we just have to throw them away
+ if (protocol === "about:"
+ || protocol === "chrome:"
+ || protocol === "moz-extension:") {
+ return false;
+ }
+ return true;
+ },
+
+ checkArgs(requiredArguments, options, methodName) {
+ requiredArguments.forEach((argument) => {
+ if (!(argument in options)) {
+ return new Error(`${methodName} must be called with ${argument} argument.`);
+ }
+ });
+ },
+
+ async getTabs(options) {
+ const requiredArguments = ["cookieStoreId", "windowId"];
+ this.checkArgs(requiredArguments, options, "getTabs");
+ const { cookieStoreId, windowId } = options;
+
+ const list = [];
+ const tabs = await browser.tabs.query({
+ cookieStoreId,
+ windowId
+ });
+ tabs.forEach((tab) => {
+ list.push(identityState._createTabObject(tab));
+ });
+
+ const containerState = await identityState.storageArea.get(cookieStoreId);
+ return list.concat(containerState.hiddenTabs);
+ },
+
+ async unhideContainer(cookieStoreId, alreadyShowingUrl) {
+ if (!this.unhideQueue.includes(cookieStoreId)) {
+ this.unhideQueue.push(cookieStoreId);
+ await this.showTabs({
+ 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"];
+ this.checkArgs(requiredArguments, options, "moveTabsToWindow");
+ const { cookieStoreId, windowId } = options;
+
+ const list = await browser.tabs.query({
+ cookieStoreId,
+ windowId
+ });
+
+ const containerState = await identityState.storageArea.get(cookieStoreId);
+
+ // Nothing to do
+ if (list.length === 0 &&
+ containerState.hiddenTabs.length === 0) {
+ return;
+ }
+ let newWindowObj;
+ let hiddenDefaultTabToClose;
+ if (list.length) {
+ newWindowObj = await browser.windows.create();
+
+ // Pin the default tab in the new window so existing pinned tabs can be moved after it.
+ // From the docs (https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/tabs/move):
+ // Note that you can't move pinned tabs to a position after any unpinned tabs in a window, or move any unpinned tabs to a position before any pinned tabs.
+ await browser.tabs.update(newWindowObj.tabs[0].id, { pinned: true });
+
+ browser.tabs.move(list.map((tab) => tab.id), {
+ windowId: newWindowObj.id,
+ index: -1
+ });
+ } else {
+ // As we get a blank tab here we will need to await the tabs creation
+ newWindowObj = await browser.windows.create({
+ });
+ hiddenDefaultTabToClose = true;
+ }
+
+ const showHiddenPromises = [];
+
+ // Let's show the hidden tabs.
+ if (!this.unhideQueue.includes(cookieStoreId)) {
+ this.unhideQueue.push(cookieStoreId);
+ for (let object of containerState.hiddenTabs) { // eslint-disable-line prefer-const
+ showHiddenPromises.push(browser.tabs.create({
+ url: object.url || DEFAULT_TAB,
+ windowId: newWindowObj.id,
+ cookieStoreId
+ }));
+ }
+ }
+
+ if (hiddenDefaultTabToClose) {
+ // Lets wait for hidden tabs to show before closing the others
+ await showHiddenPromises;
+ }
+
+ containerState.hiddenTabs = [];
+
+ // Let's close all the normal tab in the new window. In theory it
+ // should be only the first tab, but maybe there are addons doing
+ // crazy stuff.
+ const tabs = await browser.tabs.query({windowId: newWindowObj.id});
+ for (let tab of tabs) { // eslint-disable-line prefer-const
+ if (tab.cookieStoreId !== cookieStoreId) {
+ browser.tabs.remove(tab.id);
+ }
+ }
+ const rv = await identityState.storageArea.set(cookieStoreId, containerState);
+ this.unhideQueue.splice(this.unhideQueue.indexOf(cookieStoreId), 1);
+ return rv;
+ },
+
+ async _closeTabs(userContextId, windowId = false) {
+ const cookieStoreId = this.cookieStoreId(userContextId);
+ let tabs;
+ /* if we have no windowId we are going to close all this container (used for deleting) */
+ if (windowId !== false) {
+ tabs = await browser.tabs.query({
+ cookieStoreId,
+ windowId
+ });
+ } else {
+ tabs = await browser.tabs.query({
+ cookieStoreId
+ });
+ }
+ const tabIds = tabs.map((tab) => tab.id);
+ return browser.tabs.remove(tabIds);
+ },
+
+ async queryIdentitiesState(windowId) {
+ const identities = await browser.contextualIdentities.query({});
+ const identitiesOutput = {};
+ const identitiesPromise = identities.map(async (identity) => {
+ const { cookieStoreId } = identity;
+ const containerState = await identityState.storageArea.get(cookieStoreId);
+ const openTabs = await browser.tabs.query({
+ cookieStoreId,
+ windowId
+ });
+ identitiesOutput[cookieStoreId] = {
+ hasHiddenTabs: !!containerState.hiddenTabs.length,
+ hasOpenTabs: !!openTabs.length,
+ numberOfHiddenTabs: containerState.hiddenTabs.length,
+ numberOfOpenTabs: openTabs.length,
+ isIsolated: !!containerState.isIsolated
+ };
+ return;
+ });
+ await Promise.all(identitiesPromise);
+ return identitiesOutput;
+ },
+
+ async sortTabs() {
+ const windows = await browser.windows.getAll();
+ for (let windowObj of windows) { // eslint-disable-line prefer-const
+ // First the pinned tabs, then the normal ones.
+ await this._sortTabsInternal(windowObj, true);
+ await this._sortTabsInternal(windowObj, false);
+ }
+ },
+
+ async _sortTabsInternal(windowObj, pinnedTabs) {
+ const tabs = await browser.tabs.query({windowId: windowObj.id});
+ let pos = 0;
+
+ // Let's collect UCIs/tabs for this window.
+ const map = new Map;
+ for (const tab of tabs) {
+ if (pinnedTabs && !tab.pinned) {
+ // We don't have, or we already handled all the pinned tabs.
+ break;
+ }
+
+ if (!pinnedTabs && tab.pinned) {
+ // pinned tabs must be consider as taken positions.
+ ++pos;
+ continue;
+ }
+
+ if (!map.has(tab.cookieStoreId)) {
+ const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(tab.cookieStoreId);
+ map.set(tab.cookieStoreId, { order: userContextId, tabs: [] });
+ }
+ map.get(tab.cookieStoreId).tabs.push(tab);
+ }
+
+ const containerOrderStorage = await browser.storage.local.get([CONTAINER_ORDER_STORAGE_KEY]);
+ const containerOrder =
+ containerOrderStorage && containerOrderStorage[CONTAINER_ORDER_STORAGE_KEY];
+
+ if (containerOrder) {
+ map.forEach((obj, key) => {
+ obj.order = (key in containerOrder) ? containerOrder[key] : -1;
+ });
+ }
+
+ // Let's sort the map.
+ const sortMap = new Map([...map.entries()].sort((a, b) => a[1].order > b[1].order));
+
+ // Let's move tabs.
+ sortMap.forEach(obj => {
+ for (const tab of obj.tabs) {
+ ++pos;
+ browser.tabs.move(tab.id, {
+ windowId: windowObj.id,
+ index: pos
+ });
+ }
+ });
+ },
+
+ async hideTabs(options) {
+ const requiredArguments = ["cookieStoreId", "windowId"];
+ this.checkArgs(requiredArguments, options, "hideTabs");
+ const { cookieStoreId, windowId } = options;
+
+ const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(cookieStoreId);
+
+ const containerState = await identityState.storeHidden(cookieStoreId, windowId);
+ await this._closeTabs(userContextId, windowId);
+ return containerState;
+ },
+
+ async showTabs(options) {
+ if (!("cookieStoreId" in options)) {
+ return Promise.reject("showTabs must be called with cookieStoreId argument.");
+ }
+
+ const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(options.cookieStoreId);
+ const promises = [];
+
+ const containerState = await identityState.storageArea.get(options.cookieStoreId);
+
+ for (let object of containerState.hiddenTabs) { // eslint-disable-line prefer-const
+ // 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 identityState.storageArea.set(options.cookieStoreId, containerState);
+ },
+
+ cookieStoreId(userContextId) {
+ if(userContextId === 0) return "firefox-default";
+ return `firefox-container-${userContextId}`;
+ }
+};
+
+
+backgroundLogic.init();
diff --git a/src/js/background/badge.js b/src/js/background/badge.js
new file mode 100644
index 0000000..f266ad9
--- /dev/null
+++ b/src/js/background/badge.js
@@ -0,0 +1,21 @@
+const MAJOR_VERSIONS = ["2.3.0", "2.4.0", "6.2.0", "8.0.2"];
+const badge = {
+ async init() {
+ const currentWindow = await browser.windows.getCurrent();
+ this.displayBrowserActionBadge(currentWindow);
+ },
+
+ async displayBrowserActionBadge() {
+ const extensionInfo = await backgroundLogic.getExtensionInfo();
+ const storage = await browser.storage.local.get({ browserActionBadgesClicked: [] });
+
+ if (MAJOR_VERSIONS.indexOf(extensionInfo.version) > -1 &&
+ storage.browserActionBadgesClicked.indexOf(extensionInfo.version) < 0) {
+ browser.browserAction.setBadgeBackgroundColor({ color: "rgb(255, 79, 94)" });
+ browser.browserAction.setBadgeText({ text: "!" });
+ browser.browserAction.setBadgeTextColor({ color: "rgb(255, 255, 255)" });
+ }
+ }
+};
+
+badge.init();
diff --git a/src/js/background/identityState.js b/src/js/background/identityState.js
new file mode 100644
index 0000000..9114240
--- /dev/null
+++ b/src/js/background/identityState.js
@@ -0,0 +1,194 @@
+window.identityState = {
+ keyboardShortcut: {},
+ storageArea: {
+ area: browser.storage.local,
+
+ getContainerStoreKey(cookieStoreId) {
+ const storagePrefix = "identitiesState@@_";
+ return `${storagePrefix}${cookieStoreId}`;
+ },
+
+ async get(cookieStoreId) {
+ const storeKey = this.getContainerStoreKey(cookieStoreId);
+ const storageResponse = await this.area.get([storeKey]);
+ if (storageResponse && storeKey in storageResponse) {
+ if (!storageResponse[storeKey].macAddonUUID){
+ storageResponse[storeKey].macAddonUUID = uuidv4();
+ await this.set(cookieStoreId, storageResponse[storeKey]);
+ }
+ return storageResponse[storeKey];
+ }
+ // 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) {
+ const storeKey = this.getContainerStoreKey(cookieStoreId);
+ return this.area.set({
+ [storeKey]: data
+ });
+ },
+
+ 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});
+ tabsByContainer.forEach((tab) => {
+ const tabObject = this._createTabObject(tab);
+ if (!backgroundLogic.isPermissibleURL(tab.url)) {
+ return;
+ }
+ // This tab is going to be closed. Let's mark this tabObject as
+ // non-active.
+ tabObject.active = false;
+ tabObject.hiddenState = true;
+ containerState.hiddenTabs.push(tabObject);
+ });
+
+ return this.storageArea.set(cookieStoreId, containerState);
+ },
+
+ async 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: [],
+ 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
new file mode 100644
index 0000000..818dbb4
--- /dev/null
+++ b/src/js/background/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/js/background/messageHandler.js b/src/js/background/messageHandler.js
new file mode 100644
index 0000000..b748916
--- /dev/null
+++ b/src/js/background/messageHandler.js
@@ -0,0 +1,272 @@
+const messageHandler = {
+ // After the timer completes we assume it's a tab the user meant to keep open
+ // We use this to catch redirected tabs that have just opened
+ // If this were in platform we would change how the tab opens based on "new tab" link navigations such as ctrl+click
+ LAST_CREATED_TAB_TIMER: 2000,
+
+ init() {
+ // Handles messages from webextension code
+ browser.runtime.onMessage.addListener(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 "deleteContainer":
+ response = backgroundLogic.deleteContainer(m.message.userContextId);
+ break;
+ case "createOrUpdateContainer":
+ response = backgroundLogic.createOrUpdateContainer(m.message);
+ break;
+ 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);
+ });
+ break;
+ case "getAssignmentObjectByContainer":
+ response = assignManager._getByContainer(m.message.userContextId);
+ break;
+ case "setOrRemoveAssignment":
+ // m.tabId is used for where to place the in content message
+ // m.url is the assignment to be removed/added
+ response = assignManager._setOrRemoveAssignment(m.tabId, m.url, m.userContextId, m.value);
+ break;
+ case "setWildcardHostnameForAssignment":
+ response = assignManager._setWildcardHostnameForAssignment(m.url, m.wildcardHostname);
+ break;
+ case "sortTabs":
+ backgroundLogic.sortTabs();
+ break;
+ case "showTabs":
+ backgroundLogic.unhideContainer(m.cookieStoreId);
+ break;
+ case "hideTabs":
+ backgroundLogic.hideTabs({
+ cookieStoreId: m.cookieStoreId,
+ windowId: m.windowId
+ });
+ break;
+ case "checkIncompatibleAddons":
+ // TODO
+ break;
+ case "moveTabsToWindow":
+ response = backgroundLogic.moveTabsToWindow({
+ cookieStoreId: m.cookieStoreId,
+ windowId: m.windowId
+ });
+ break;
+ case "getTabs":
+ response = backgroundLogic.getTabs({
+ cookieStoreId: m.cookieStoreId,
+ windowId: m.windowId
+ });
+ break;
+ case "queryIdentitiesState":
+ response = backgroundLogic.queryIdentitiesState(m.message.windowId);
+ break;
+ 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;
+
+ case "MozillaVPN_attemptPort":
+ MozillaVPN_Background.maybeInitPort();
+ break;
+ case "MozillaVPN_queryServers":
+ MozillaVPN_Background.postToApp("servers");
+ break;
+ case "MozillaVPN_queryStatus":
+ response = MozillaVPN_Background.postToApp("status");
+ break;
+ case "MozillaVPN_getConnectionStatus":
+ response = MozillaVPN_Background.getConnectionStatus();
+ break;
+ case "MozillaVPN_getInstallationStatus":
+ response = MozillaVPN_Background.getInstallationStatus();
+ break;
+ }
+ return response;
+ });
+
+ // Handles external messages from webextensions
+ const externalExtensionAllowed = {};
+ browser.runtime.onMessageExternal.addListener(async (message, sender) => {
+ if (!externalExtensionAllowed[sender.id]) {
+ const extensionInfo = await browser.management.get(sender.id);
+ if (!extensionInfo.permissions.includes("contextualIdentities")) {
+ throw new Error("Missing contextualIdentities permission");
+ }
+ // eslint-disable-next-line require-atomic-updates
+ externalExtensionAllowed[sender.id] = true;
+ }
+ let response;
+ switch (message.method) {
+ case "getAssignment":
+ if (typeof message.url === "undefined") {
+ throw new Error("Missing message.url");
+ }
+ response = assignManager.storageArea.get(message.url);
+ break;
+ default:
+ throw new Error("Unknown message.method");
+ }
+ return response;
+ });
+ // Delete externalExtensionAllowed if add-on installs/updates; permissions might change
+ browser.management.onInstalled.addListener(extensionInfo => {
+ if (externalExtensionAllowed[extensionInfo.id]) {
+ delete externalExtensionAllowed[extensionInfo.id];
+ }
+ });
+ // Delete externalExtensionAllowed if add-on uninstalls; not needed anymore
+ browser.management.onUninstalled.addListener(extensionInfo => {
+ if (externalExtensionAllowed[extensionInfo.id]) {
+ delete externalExtensionAllowed[extensionInfo.id];
+ }
+ });
+
+ if (browser.contextualIdentities.onRemoved) {
+ browser.contextualIdentities.onRemoved.addListener(({contextualIdentity}) => {
+ const userContextId = backgroundLogic.getUserContextIdFromCookieStoreId(contextualIdentity.cookieStoreId);
+ backgroundLogic.deleteContainer(userContextId, true);
+ });
+ }
+
+ browser.tabs.onActivated.addListener((info) => {
+ assignManager.removeContextMenu();
+ browser.tabs.get(info.tabId).then((tab) => {
+ assignManager.calculateContextMenu(tab);
+ }).catch((e) => {
+ throw e;
+ });
+ });
+
+ browser.windows.onFocusChanged.addListener((windowId) => {
+ this.onFocusChangedCallback(windowId);
+ });
+
+ browser.webRequest.onCompleted.addListener((details) => {
+ if (details.frameId !== 0 || details.tabId === -1) {
+ return {};
+ }
+ assignManager.removeContextMenu();
+
+ browser.tabs.get(details.tabId).then((tab) => {
+ assignManager.calculateContextMenu(tab);
+ }).catch((e) => {
+ throw e;
+ });
+ }, {urls: [""], types: ["main_frame"]});
+
+ browser.tabs.onCreated.addListener((tab) => {
+ // lets remember the last tab created so we can close it if it looks like a redirect
+ this.lastCreatedTab = tab;
+ if (tab.cookieStoreId) {
+ // Don't count firefox-default, firefox-private, nor our own confirm page loads
+ if (tab.cookieStoreId !== "firefox-default" &&
+ tab.cookieStoreId !== "firefox-private" &&
+ !tab.url.startsWith("moz-extension")) {
+ // increment the counter of container tabs opened
+ this.incrementCountOfContainerTabsOpened();
+
+ 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;
+ }, this.LAST_CREATED_TAB_TIMER);
+ });
+ },
+
+ async incrementCountOfContainerTabsOpened() {
+ const key = "containerTabsOpened";
+ const count = await browser.storage.local.get({[key]: 0});
+ const countOfContainerTabsOpened = ++count[key];
+ browser.storage.local.set({[key]: countOfContainerTabsOpened});
+
+ // When the user opens their _ tab, give them the achievement
+ if (countOfContainerTabsOpened === 100) {
+ const storage = await browser.storage.local.get({achievements: []});
+ storage.achievements.push({"name": "manyContainersOpened", "done": false});
+ // use set and spread to create a unique array
+ const achievements = [...new Set(storage.achievements)];
+ browser.storage.local.set({achievements});
+ browser.browserAction.setBadgeBackgroundColor({color: "rgba(0,217,0,255)"});
+ browser.browserAction.setBadgeText({text: "NEW"});
+ }
+ },
+
+ async onFocusChangedCallback(windowId) {
+ assignManager.removeContextMenu();
+ // browserAction loses background color in new windows ...
+ // https://bugzil.la/1314674
+ // https://github.com/mozilla/testpilot-containers/issues/608
+ // ... so re-call displayBrowserActionBadge on window changes
+ badge.displayBrowserActionBadge();
+ browser.tabs.query({active: true, windowId}).then((tabs) => {
+ if (tabs && tabs[0]) {
+ assignManager.calculateContextMenu(tabs[0]);
+ }
+ }).catch((e) => {
+ throw e;
+ });
+ }
+};
+
+// Lets do this last as theme manager did a check before connecting before
+messageHandler.init();
diff --git a/src/js/background/mozillaVpnBackground.js b/src/js/background/mozillaVpnBackground.js
new file mode 100644
index 0000000..9337b2a
--- /dev/null
+++ b/src/js/background/mozillaVpnBackground.js
@@ -0,0 +1,118 @@
+const MozillaVPN_Background = {
+ MOZILLA_VPN_SERVERS_KEY: "mozillaVpnServers",
+ MOZILLA_VPN_HIDDEN_TOUTS_LIST_KEY: "mozillaVpnHiddenToutsList",
+
+ _isolationKey: 0,
+
+ async maybeInitPort() {
+ if (this.port && this.port.error === null) {
+ return;
+ }
+ try {
+ /*
+ Find a way to not spam the console when MozillaVPN client is not installed
+ File at path ".../../MozillaVPN/..." is not executable.` thrown by resource://gre/modules/Subprocess.jsm:152`
+ Which does is not caught by this try/catch
+ */
+ this.port = await browser.runtime.connectNative("mozillavpn");
+ this.port.onMessage.addListener(response => this.handleResponse(response));
+
+ this.port.onMessage.addListener(this.handleResponse);
+ this.postToApp("status");
+ this.postToApp("servers");
+
+ // When the mozillavpn dies or the VPN disconnects, we need to increase
+ // the isolation key in order to create new proxy connections. Otherwise
+ // we could see random timeout when the browser tries to connect to an
+ // invalid proxy connection.
+ this.port.onDisconnect.addListener(() => this.increaseIsolationKey());
+
+ } catch(e) {
+ this._installed = false;
+ this._connected = false;
+ }
+ },
+
+ async init() {
+ const { mozillaVpnServers } = await browser.storage.local.get(this.MOZILLA_VPN_SERVERS_KEY);
+ if (typeof(mozillaVpnServers) === "undefined") {
+ await browser.storage.local.set({ [this.MOZILLA_VPN_SERVERS_KEY]:[] });
+ await browser.storage.local.set({ [this.MOZILLA_VPN_HIDDEN_TOUTS_LIST_KEY]:[] });
+ this._installed = false;
+ this._connected = false;
+ }
+ this.maybeInitPort();
+ },
+
+ getConnectionStatus() {
+ return this._connected;
+ },
+
+ getInstallationStatus() {
+ return this._installed;
+ },
+
+ // Post messages to MozillaVPN client
+ postToApp(message) {
+ try {
+ this.port.postMessage({t: message});
+ } catch(e) {
+ if (e.message === "Attempt to postMessage on disconnected port") {
+ this._installed = false;
+ this._connected = false;
+ }
+ }
+ },
+
+ // Handle responses from MozillaVPN client
+ async handleResponse(response) {
+ MozillaVPN_Background._installed = true;
+ if (response.error && response.error === "vpn-client-down") {
+ MozillaVPN_Background._connected = false;
+ return;
+ }
+ if (response.servers) {
+ const servers = response.servers.countries;
+ browser.storage.local.set({ [MozillaVPN_Background.MOZILLA_VPN_SERVERS_KEY]: servers});
+ return;
+ }
+
+ if ((response.status && response.status.vpn) || response.t === "status") {
+ const status = response.status ? response.status.vpn : response.vpn;
+
+ if (status === "StateOn") {
+ MozillaVPN_Background._connected = true;
+ }
+
+ if (status === "StateOff" || status === "StateDisconnecting") {
+ MozillaVPN_Background._connected = false;
+ }
+
+ // Let's increase the network key isolation at any vpn status change.
+ MozillaVPN_Background.increaseIsolationKey();
+ }
+ },
+
+ increaseIsolationKey() {
+ ++this._isolationKey;
+ },
+
+ get isolationKey() {
+ return this._isolationKey;
+ },
+
+ async removeMozillaVpnProxies() {
+ const proxies = await proxifiedContainers.retrieveAll();
+ if (!proxies) {
+ return;
+ }
+ for (const proxyObj of proxies) {
+ const { proxy } = proxyObj;
+ if (proxy.countryCode !== undefined) {
+ await proxifiedContainers.delete(proxyObj.cookieStoreId);
+ }
+ }
+ },
+};
+
+MozillaVPN_Background.init();
diff --git a/src/js/background/sync.js b/src/js/background/sync.js
new file mode 100644
index 0000000..6dfb629
--- /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)) {
+ await assignManager
+ .storageArea
+ .remove(siteStoreKey, false);
+ }
+ }
+
+ 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
new file mode 100644
index 0000000..21a445c
--- /dev/null
+++ b/src/js/confirm-page.js
@@ -0,0 +1,80 @@
+async function load() {
+ const searchParams = new URL(window.location).searchParams;
+ const redirectUrl = searchParams.get("url");
+ const cookieStoreId = searchParams.get("cookieStoreId");
+ const currentCookieStoreId = searchParams.get("currentCookieStoreId");
+ const redirectUrlElement = document.getElementById("redirect-url");
+ redirectUrlElement.textContent = redirectUrl;
+ appendFavicon(redirectUrl, redirectUrlElement);
+
+ document.getElementById("deny").addEventListener("click", (e) => {
+ e.preventDefault();
+ denySubmit(redirectUrl);
+ });
+
+ const container = await browser.contextualIdentities.get(cookieStoreId);
+ const currentContainer = currentCookieStoreId ? await browser.contextualIdentities.get(currentCookieStoreId) : null;
+ const currentContainerName = currentContainer ? currentContainer.name : "";
+
+ document.querySelectorAll("[data-message-id]").forEach(el => {
+ const elementData = el.dataset;
+ const containerName = elementData.messageArg === "container-name" ? container.name : currentContainerName;
+ el.textContent = browser.i18n.getMessage(elementData.messageId, containerName);
+ });
+
+ document.getElementById("confirm").addEventListener("click", (e) => {
+ e.preventDefault();
+ confirmSubmit(redirectUrl, cookieStoreId);
+ });
+}
+
+function appendFavicon(pageUrl, redirectUrlElement) {
+ const origin = new URL(pageUrl).origin;
+ const favIconElement = Utils.createFavIconElement(`${origin}/favicon.ico`);
+
+ redirectUrlElement.prepend(favIconElement);
+}
+
+function confirmSubmit(redirectUrl, cookieStoreId) {
+ const neverAsk = document.getElementById("never-ask").checked;
+ // Sending neverAsk message to background to store for next time we see this process
+ if (neverAsk) {
+ browser.runtime.sendMessage({
+ method: "neverAsk",
+ neverAsk: true,
+ pageUrl: redirectUrl
+ });
+ }
+ openInContainer(redirectUrl, cookieStoreId);
+}
+
+function getCurrentTab() {
+ return browser.tabs.query({
+ active: true,
+ windowId: browser.windows.WINDOW_ID_CURRENT
+ });
+}
+
+async function denySubmit(redirectUrl) {
+ const tab = await getCurrentTab();
+ await browser.runtime.sendMessage({
+ method: "exemptContainerAssignment",
+ tabId: tab[0].id,
+ pageUrl: redirectUrl
+ });
+ document.location.replace(redirectUrl);
+}
+
+load();
+
+async function openInContainer(redirectUrl, cookieStoreId) {
+ const tab = await getCurrentTab();
+ await browser.tabs.create({
+ index: tab[0].index + 1,
+ cookieStoreId,
+ url: redirectUrl
+ });
+ if (tab.length > 0) {
+ browser.tabs.remove(tab[0].id);
+ }
+}
diff --git a/src/js/content-script.js b/src/js/content-script.js
new file mode 100644
index 0000000..539e43a
--- /dev/null
+++ b/src/js/content-script.js
@@ -0,0 +1,46 @@
+async function delayAnimation(delay = 350) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, delay);
+ });
+}
+
+async function doAnimation(element, property, value) {
+ return new Promise((resolve) => {
+ const handler = () => {
+ resolve();
+ element.removeEventListener("transitionend", handler);
+ };
+ element.addEventListener("transitionend", handler);
+ window.requestAnimationFrame(() => {
+ element.style[property] = value;
+ });
+ });
+}
+
+async function addMessage(message) {
+ const divElement = document.createElement("div");
+ divElement.classList.add("container-notification");
+ // Ideally we would use https://bugzilla.mozilla.org/show_bug.cgi?id=1340930 when this is available
+ divElement.innerText = message.text;
+
+ const imageElement = document.createElement("img");
+ const imagePath = browser.runtime.getURL("/img/container-site-d-24.png");
+ const response = await fetch(imagePath);
+ const blob = await response.blob();
+ const objectUrl = URL.createObjectURL(blob);
+ imageElement.src = objectUrl;
+ divElement.prepend(imageElement);
+
+ document.body.appendChild(divElement);
+
+ await delayAnimation(100);
+ await doAnimation(divElement, "transform", "translateY(0)");
+ await delayAnimation(3000);
+ await doAnimation(divElement, "transform", "translateY(-100%)");
+
+ divElement.remove();
+}
+
+browser.runtime.onMessage.addListener((message) => {
+ addMessage(message);
+});
diff --git a/src/js/i18n.js b/src/js/i18n.js
new file mode 100644
index 0000000..3c07064
--- /dev/null
+++ b/src/js/i18n.js
@@ -0,0 +1,9 @@
+document.addEventListener("DOMContentLoaded", async () => {
+ document.querySelectorAll("[data-i18n-message-id]").forEach(el => {
+ const messageArgs = el.dataset.i18nPlaceholder ? el.dataset.i18nPlaceholder : null;
+ el.textContent = browser.i18n.getMessage(el.dataset.i18nMessageId, [messageArgs]);
+ });
+ document.querySelectorAll("[data-i18n-attribute]").forEach(el => {
+ el.setAttribute(el.dataset.i18nAttribute, browser.i18n.getMessage(el.dataset.i18nAttributeMessageId));
+ });
+});
diff --git a/src/js/mozillaVpn.js b/src/js/mozillaVpn.js
new file mode 100644
index 0000000..941e148
--- /dev/null
+++ b/src/js/mozillaVpn.js
@@ -0,0 +1,260 @@
+const MozillaVPN = {
+
+ async handleContainerList(identities) {
+ const mozillaVpnConnected = await browser.runtime.sendMessage({ method: "MozillaVPN_getConnectionStatus" });
+ const mozillaVpnInstalled = await browser.runtime.sendMessage({ method: "MozillaVPN_getInstallationStatus" });
+ this.handleStatusIndicatorsInContainerLists(mozillaVpnInstalled);
+
+ const permissionsEnabled = await this.bothPermissionsEnabled();
+ if (!permissionsEnabled) {
+ return;
+ }
+
+ const proxies = await this.getProxies(identities);
+ if (Object.keys(proxies).length === 0) {
+ return;
+ }
+
+ const tooltipProxyWarning = browser.i18n.getMessage("tooltipWarning");
+ for (const el of document.querySelectorAll("[data-cookie-store-id]")) {
+ const cookieStoreId = el.dataset.cookieStoreId;
+
+ if (!proxies[cookieStoreId]) {
+ continue;
+ }
+ const { proxy } = proxies[cookieStoreId];
+
+ if (typeof(proxy) !== "undefined") {
+ const flag = el.querySelector(".flag-img");
+ if (proxy.countryCode) {
+ flag.src = `/img/flags/${proxy.countryCode.toUpperCase()}.png`;
+ }
+ if (typeof(proxy.mozProxyEnabled) === "undefined" && typeof(proxy.countryCode) !== "undefined") {
+ flag.classList.add("proxy-disabled");
+ }
+ if (!mozillaVpnConnected && proxy.mozProxyEnabled) {
+ flag.classList.add("proxy-unavailable");
+ const tooltip = el.querySelector(".tooltip.proxy-unavailable");
+ if (tooltip) {
+ tooltip.textContent = tooltipProxyWarning;
+ }
+ const menuItemName = el.querySelector(".menu-item-name");
+ if (menuItemName) {
+ el.querySelector(".menu-item-name").dataset.mozProxyWarning = "proxy-unavailable";
+ }
+ }
+ }
+ }
+ },
+
+ async setStatusIndicatorIcons(mozillaVpnInstalled) {
+
+ const statusIconEls = document.querySelectorAll(".moz-vpn-connection-status-indicator");
+
+ if (!mozillaVpnInstalled) {
+ statusIconEls.forEach(el => {
+ el.style.backgroundImage = "none";
+ if (el.querySelector(".tooltip")) {
+ el.querySelector(".tooltip").textContent = "";
+ }
+ el.textContent = "";
+ });
+ return;
+ }
+
+ const connectedIndicatorSrc = "url(./img/moz-vpn-connected.svg)";
+ const disconnectedIndicatorSrc = "url(./img/moz-vpn-disconnected.svg)";
+
+ const mozillaVpnConnected = await browser.runtime.sendMessage({ method: "MozillaVPN_getConnectionStatus" });
+ const connectionStatusStringId = mozillaVpnConnected ? "moz-vpn-connected" : "moz-vpn-disconnected";
+ const connectionStatusLocalizedString = browser.i18n.getMessage(connectionStatusStringId);
+
+ statusIconEls.forEach(el => {
+ el.style.backgroundImage = mozillaVpnConnected ? connectedIndicatorSrc : disconnectedIndicatorSrc;
+ if (el.querySelector(".tooltip")) {
+ el.querySelector(".tooltip").textContent = connectionStatusLocalizedString;
+ } else {
+ el.textContent = connectionStatusLocalizedString;
+ }
+ });
+ },
+
+ async handleStatusIndicatorsInContainerLists(mozillaVpnInstalled) {
+ const mozVpnLogotypes = document.querySelectorAll(".moz-vpn-logotype.vpn-status-container-list");
+
+ try {
+ if (!mozillaVpnInstalled) {
+ mozVpnLogotypes.forEach(el => {
+ el.style.display = "none";
+ });
+ return;
+ }
+ mozVpnLogotypes.forEach(el => {
+ el.style.display = "flex";
+ el.classList.remove("display-none");
+ });
+ this.setStatusIndicatorIcons(mozillaVpnInstalled);
+ } catch (e) {
+ mozVpnLogotypes.forEach(el => {
+ el.style.display = "none";
+ });
+ return;
+ }
+ },
+
+ handleMozillaCtaClick(buttonIdentifier) {
+ browser.tabs.create({
+ url: MozillaVPN.attachUtmParameters("https://www.mozilla.org/products/vpn", buttonIdentifier),
+ });
+ },
+
+ getRandomInteger(min, max) {
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+ },
+
+ proxyIsDisabled(proxy) {
+ return (
+ // Mozilla VPN proxy is disabled, last location data is stored
+ (proxy.mozProxyEnabled === undefined && proxy.countryCode !== undefined && proxy.cityName !== undefined) ||
+ // Mozilla VPN proxy is enabled but Mozilla VPN is not connected
+ proxy.mozProxyEnabled !== undefined
+ );
+ },
+
+ attachUtmParameters(baseUrl, utmContent) {
+ const url = new URL(baseUrl);
+ const utmParameters = {
+ utm_source: "multi.account.containers",
+ utm_medium: "mac-browser-addon",
+ utm_content: utmContent,
+ utm_campaign: "vpn-better-together",
+ };
+
+ for (const param in utmParameters) {
+ url.searchParams.append(param, utmParameters[param]);
+ }
+ return url.href;
+ },
+
+ async getProxies(identities) {
+ const proxies = {};
+ const mozillaVpnInstalled = await browser.runtime.sendMessage({ method: "MozillaVPN_getInstallationStatus" });
+
+ if (mozillaVpnInstalled) {
+ for (const identity of identities) {
+ try {
+ const proxy = await proxifiedContainers.retrieve(identity.cookieStoreId);
+ proxies[identity.cookieStoreId] = proxy;
+ } catch (e) {
+ proxies[identity.cookieStoreId] = {};
+ }
+ }
+ }
+ return proxies;
+ },
+
+ getMozillaProxyInfoObj() {
+ return {
+ countryCode: undefined,
+ cityName: undefined,
+ mozProxyEnabled: undefined
+ };
+ },
+
+ async bothPermissionsEnabled() {
+ return await browser.permissions.contains({ permissions: ["proxy", "nativeMessaging"] });
+ },
+
+
+ async getProxyWarnings(proxyObj) {
+ if (!proxyObj) {
+ return "";
+ }
+
+ const { proxy } = proxyObj;
+
+ if (typeof(proxy) === "undefined") {
+ return "";
+ }
+
+ const mozillaVpnConnected = await browser.runtime.sendMessage({ method: "MozillaVPN_getConnectionStatus" });
+ if (typeof(proxy.mozProxyEnabled) !== "undefined" && !mozillaVpnConnected) {
+ return "proxy-unavailable";
+ }
+ },
+
+ async getFlag(proxyObj) {
+ const flag = {
+ imgCode: "default",
+ elemClasses: "display-none",
+ imgAlt: "",
+ };
+
+ if (!proxyObj) {
+ return flag;
+ }
+
+ const { proxy } = proxyObj;
+ const mozillaVpnInstalled = await browser.runtime.sendMessage({ method: "MozillaVPN_getInstallationStatus" });
+ if (typeof(proxy) === "undefined" || !mozillaVpnInstalled) {
+ return flag;
+ }
+
+ const mozillaVpnConnected = await browser.runtime.sendMessage({ method: "MozillaVPN_getConnectionStatus" });
+ if (mozillaVpnInstalled && typeof(proxy.cityName) !== "undefined") {
+ flag.imgCode = proxy.countryCode.toUpperCase();
+ flag.imgAlt = proxy.cityName;
+ flag.elemClasses = typeof(proxy.mozProxyEnabled) === "undefined" || !mozillaVpnConnected ? "proxy-disabled" : "";
+ }
+
+ return flag;
+ },
+
+ getProxy(countryCode, cityName, mozProxyEnabled, mozillaVpnServers) {
+ const selectedServerCountry = mozillaVpnServers.find(({code}) => code === countryCode);
+ const selectedServerCity = selectedServerCountry.cities.find(({name}) => name === cityName);
+ const proxyServer = this.pickServerBasedOnWeight(selectedServerCity.servers);
+ return proxifiedContainers.parseProxy(
+ this.makeProxyString(proxyServer.socksName),
+ {
+ countryCode: countryCode,
+ cityName: cityName,
+ mozProxyEnabled,
+ }
+ );
+ },
+
+ makeProxyString(socksName) {
+ return `socks://${socksName}.mullvad.net:1080`;
+ },
+
+ async pickRandomLocation() {
+ const { mozillaVpnServers } = await browser.storage.local.get("mozillaVpnServers");
+ const randomInteger = this.getRandomInteger(0, mozillaVpnServers.length - 1);
+ const randomServerCountry = mozillaVpnServers[randomInteger];
+
+ return {
+ randomServerCountryCode: randomServerCountry.code,
+ randomServerCityName: randomServerCountry.cities[0].name,
+ };
+
+ },
+
+ pickServerBasedOnWeight(serverList) {
+ const filteredServerList = serverList.filter(server => typeof(server.socksName) !== "undefined" && server.socksName !== "");
+
+ const sumWeight = filteredServerList.reduce((sum, { weight }) => sum + weight, 0);
+ let randomInteger = this.getRandomInteger(0, sumWeight);
+
+ let nextServer = {};
+ for (const server of filteredServerList) {
+ if (server.weight >= randomInteger) {
+ return nextServer = server;
+ }
+ randomInteger = (randomInteger - server.weight);
+ }
+ return nextServer;
+ },
+};
+
+window.MozillaVPN = MozillaVPN;
diff --git a/src/js/options.js b/src/js/options.js
new file mode 100644
index 0000000..726827b
--- /dev/null
+++ b/src/js/options.js
@@ -0,0 +1,137 @@
+const NUMBER_OF_KEYBOARD_SHORTCUTS = 10;
+
+async function setUpCheckBoxes() {
+ document.querySelectorAll("[data-permission-id]").forEach(async(el) => {
+ const permissionId = el.dataset.permissionId;
+ const permissionEnabled = await browser.permissions.contains({ permissions: [permissionId] });
+ el.checked = !!permissionEnabled;
+ });
+}
+
+function disablePermissionsInputs() {
+ document.querySelectorAll("[data-permission-id").forEach(el => {
+ el.disabled = true;
+ });
+}
+
+function enablePermissionsInputs() {
+ document.querySelectorAll("[data-permission-id").forEach(el => {
+ el.disabled = false;
+ });
+}
+
+document.querySelectorAll("[data-permission-id").forEach(async(el) => {
+ const permissionId = el.dataset.permissionId;
+ el.addEventListener("change", async() => {
+ if (el.checked) {
+ disablePermissionsInputs();
+ const granted = await browser.permissions.request({ permissions: [permissionId] });
+ if (!granted) {
+ el.checked = false;
+ enablePermissionsInputs();
+ }
+ return;
+ }
+ await browser.permissions.remove({ permissions: [permissionId] });
+ });
+});
+
+async function maybeShowPermissionsWarningIcon() {
+ const bothMozillaVpnPermissionsEnabled = await MozillaVPN.bothPermissionsEnabled();
+ const permissionsWarningEl = document.querySelector(".warning-icon");
+ permissionsWarningEl.classList.toggle("show-warning", !bothMozillaVpnPermissionsEnabled);
+}
+
+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 { syncEnabled } = await browser.storage.local.get("syncEnabled");
+ const { replaceTabEnabled } = await browser.storage.local.get("replaceTabEnabled");
+ 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});
+}
+
+async function resetPermissionsUi() {
+ await maybeShowPermissionsWarningIcon();
+ await setUpCheckBoxes();
+ enablePermissionsInputs();
+}
+
+browser.permissions.onAdded.addListener(resetPermissionsUi);
+browser.permissions.onRemoved.addListener(resetPermissionsUi);
+
+document.addEventListener("DOMContentLoaded", setupOptions);
+document.querySelector("#syncCheck").addEventListener( "change", enableDisableSync);
+document.querySelector("#replaceTabCheck").addEventListener( "change", enableDisableReplaceTab);
+maybeShowPermissionsWarningIcon();
+for (let i=0; i < NUMBER_OF_KEYBOARD_SHORTCUTS; i++) {
+ document.querySelector("#open_container_"+i)
+ .addEventListener("change", storeShortcutChoice);
+}
+
+document.querySelectorAll("[data-btn-id]").forEach(btn => {
+ btn.addEventListener("click", () => {
+ switch (btn.dataset.btnId) {
+ case "reset-onboarding":
+ resetOnboarding();
+ break;
+ case "moz-vpn-learn-more":
+ browser.tabs.create({
+ url: MozillaVPN.attachUtmParameters("https://support.mozilla.org/kb/protect-your-container-tabs-mozilla-vpn", "options-learn-more")
+ });
+ break;
+ }
+ });
+});
+resetPermissionsUi();
diff --git a/src/js/pageAction.js b/src/js/pageAction.js
new file mode 100644
index 0000000..bc0ba3c
--- /dev/null
+++ b/src/js/pageAction.js
@@ -0,0 +1,37 @@
+async function init() {
+ const fragment = document.createDocumentFragment();
+ const identities = await browser.contextualIdentities.query({});
+
+ for (const identity of identities) {
+ const tr = document.createElement("tr");
+ tr.classList.add("menu-item", "hover-highlight");
+ tr.setAttribute("data-cookie-store-id", identity.cookieStoreId);
+ const td = document.createElement("td");
+ td.innerHTML = Utils.escaped`
+
+
+
+ `;
+
+ 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);
+
+ MozillaVPN.handleContainerList(identities);
+}
+
+init();
diff --git a/src/js/popup.js b/src/js/popup.js
new file mode 100644
index 0000000..db40ffc
--- /dev/null
+++ b/src/js/popup.js
@@ -0,0 +1,2363 @@
+/* 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/password-hide.svg";
+const CONTAINER_UNHIDE_SRC = "/img/password-hide.svg";
+
+const DEFAULT_COLOR = "blue";
+const DEFAULT_ICON = "circle";
+const NEW_CONTAINER_ID = "new";
+
+const ONBOARDING_STORAGE_KEY = "onboarding-stage";
+const CONTAINER_DRAG_DATA_TYPE = "firefox-container";
+
+// 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_ONBOARDING_6 = "onboarding6";
+const P_ONBOARDING_7 = "onboarding7";
+const P_ONBOARDING_8 = "onboarding8";
+
+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";
+
+const P_MOZILLA_VPN_SERVER_LIST = "moz-vpn-server-list";
+const P_ADVANCED_PROXY_SETTINGS = "advanced-proxy-settings-panel";
+
+function addRemoveSiteIsolation() {
+ const identity = Logic.currentIdentity();
+ browser.runtime.sendMessage({
+ method: "addRemoveSiteIsolation",
+ cookieStoreId: identity.cookieStoreId
+ });
+}
+
+async function getExtensionInfo() {
+ const manifestPath = browser.runtime.getURL("manifest.json");
+ const response = await fetch(manifestPath);
+ const extensionInfo = await response.json();
+ return extensionInfo;
+}
+
+// This object controls all the panels, identities and many other things.
+const Logic = {
+ _identities: [],
+ _currentIdentity: null,
+ _currentPanel: null,
+ _previousPanelPath: [],
+ _panels: {},
+ _onboardingVariation: null,
+
+ async init() {
+ browser.runtime.sendMessage({
+ method: "MozillaVPN_attemptPort"
+ }),
+
+ // Remove browserAction "upgraded" badge when opening panel
+ this.clearBrowserActionBadge();
+
+ // Retrieve the list of identities.
+ const identitiesPromise = this.refreshIdentities();
+
+ try {
+ await identitiesPromise;
+ } catch (e) {
+ throw new Error("Failed to retrieve the identities or variation. We cannot continue. ", e.message);
+ }
+
+ // Routing to the correct panel.
+ // If localStorage is disabled, we don't show the onboarding.
+ const onboardingData = await browser.storage.local.get([ONBOARDING_STORAGE_KEY]);
+ let onboarded = onboardingData[ONBOARDING_STORAGE_KEY];
+ if (!onboarded) {
+ onboarded = 9;
+ this.setOnboardingStage(onboarded);
+ }
+
+ switch (onboarded) {
+ case 8:
+ this.showAchievementOrContainersListPanel();
+ break;
+ case 7:
+ this.showPanel(P_ONBOARDING_8);
+ break;
+ case 6:
+ this.showPanel(P_ONBOARDING_8);
+ break;
+ case 5:
+ this.showPanel(P_ONBOARDING_6);
+ break;
+ case 4:
+ this.showPanel(P_ONBOARDING_5);
+ break;
+ case 3:
+ this.showPanel(P_ONBOARDING_4);
+ break;
+ case 2:
+ this.showPanel(P_ONBOARDING_3);
+ break;
+ case 1:
+ this.showPanel(P_ONBOARDING_2);
+ break;
+ case 0:
+ default:
+ this.showPanel(P_ONBOARDING_1);
+ break;
+ }
+
+ },
+
+ async showAchievementOrContainersListPanel() {
+ // Do we need to show an achievement panel?
+ let showAchievements = false;
+ const achievementsStorage = await browser.storage.local.get({ achievements: [] });
+ for (const achievement of achievementsStorage.achievements) {
+ if (!achievement.done) {
+ showAchievements = true;
+ }
+ }
+ if (showAchievements) {
+ this.showPanel(P_CONTAINERS_ACHIEVEMENT);
+ } else {
+ this.showPanel(P_CONTAINERS_LIST);
+ }
+ },
+
+ // In case the user wants to click multiple actions,
+ // 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 achievements = achievementsStorage.achievements;
+ achievements.forEach((achievement, index, achievementsArray) => {
+ if (achievement.name === achievementName) {
+ achievement.done = true;
+ achievementsArray[index] = achievement;
+ }
+ });
+ browser.storage.local.set({ achievements });
+ },
+
+ setOnboardingStage(stage) {
+ return browser.storage.local.set({
+ [ONBOARDING_STORAGE_KEY]: stage
+ });
+ },
+
+ async clearBrowserActionBadge() {
+ const extensionInfo = await getExtensionInfo();
+ const storage = await browser.storage.local.get({ browserActionBadgesClicked: [] });
+ browser.browserAction.setBadgeBackgroundColor({ color: "#ffffff" });
+ browser.browserAction.setBadgeText({ text: "" });
+ storage.browserActionBadgesClicked.push(extensionInfo.version);
+ // use set and spread to create a unique array
+ const browserActionBadgesClicked = [...new Set(storage.browserActionBadgesClicked)];
+ browser.storage.local.set({
+ browserActionBadgesClicked
+ });
+ },
+
+ async identity(cookieStoreId) {
+ const defaultContainer = {
+ name: "Default",
+ cookieStoreId,
+ icon: "default-tab",
+ color: "default-tab",
+ numberOfHiddenTabs: 0,
+ numberOfOpenTabs: 0
+ };
+ // Handle old style rejection with null and also Promise.reject new style
+ try {
+ return await browser.contextualIdentities.get(cookieStoreId) || defaultContainer;
+ } catch (e) {
+ return defaultContainer;
+ }
+ },
+
+ async numTabs() {
+ const activeTabs = await browser.tabs.query({ windowId: browser.windows.WINDOW_ID_CURRENT });
+ return activeTabs.length;
+ },
+
+ _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");
+ },
+
+ _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 saveContainerOrder(rows) {
+ const containerOrder = {};
+ rows.forEach((node, index) => {
+ return containerOrder[node.dataset.containerId] = index;
+ });
+ await browser.storage.local.set({
+ [CONTAINER_ORDER_STORAGE_KEY]: containerOrder
+ });
+ },
+
+ async refreshIdentities() {
+ const [identities, state, containerOrderStorage] = await Promise.all([
+ browser.contextualIdentities.query({}),
+ browser.runtime.sendMessage({
+ method: "queryIdentitiesState",
+ message: {
+ windowId: browser.windows.WINDOW_ID_CURRENT
+ }
+ }),
+ browser.storage.local.get([CONTAINER_ORDER_STORAGE_KEY])
+ ]);
+ const containerOrder =
+ containerOrderStorage && containerOrderStorage[CONTAINER_ORDER_STORAGE_KEY];
+ this._identities = identities.map((identity) => {
+ const stateObject = state[identity.cookieStoreId];
+ if (stateObject) {
+ identity.hasOpenTabs = stateObject.hasOpenTabs;
+ identity.hasHiddenTabs = stateObject.hasHiddenTabs;
+ identity.numberOfHiddenTabs = stateObject.numberOfHiddenTabs;
+ identity.numberOfOpenTabs = stateObject.numberOfOpenTabs;
+ identity.isIsolated = stateObject.isIsolated;
+ }
+ if (containerOrder) {
+ identity.order = containerOrder[identity.cookieStoreId];
+ }
+ return identity;
+ }).sort((i1, i2) => i1.order - i2.order);
+ },
+
+ getPanelSelector(panel) {
+ if (this._onboardingVariation === "securityOnboarding" &&
+ // eslint-disable-next-line no-prototype-builtins
+ panel.hasOwnProperty("securityPanelSelector")) {
+ return panel.securityPanelSelector;
+ } else {
+ return panel.panelSelector;
+ }
+ },
+
+ async showPanel(panel, currentIdentity = null, backwards = false, addToPreviousPanelPath = true) {
+ if ((!backwards && addToPreviousPanelPath) || !this._currentPanel) {
+ this._previousPanelPath.push(this._currentPanel);
+ }
+
+ // If invalid panel, reset panels.
+ if (!(panel in this._panels)) {
+ panel = P_CONTAINERS_LIST;
+ this._previousPanelPath = [];
+ }
+
+ this._currentPanel = panel;
+
+ this._currentIdentity = currentIdentity;
+
+ // Initialize the panel before showing it.
+ await this._panels[panel].prepare();
+ Object.keys(this._panels).forEach((panelKey) => {
+ const panelItem = this._panels[panelKey];
+ const panelElement = document.querySelector(this.getPanelSelector(panelItem));
+ if (!panelElement.classList.contains("hide")) {
+ panelElement.classList.add("hide");
+ if ("unregister" in panelItem) {
+ panelItem.unregister();
+ }
+ }
+ });
+ 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._previousPanelPath) {
+ throw new Error("Current panel not set!");
+ }
+ this.showPanel(this._previousPanelPath.pop(), this._currentIdentity, true);
+ },
+
+ registerPanel(panelName, panelObject) {
+ this._panels[panelName] = panelObject;
+ panelObject.initialize();
+ },
+
+ identities() {
+ return this._identities;
+ },
+
+ currentIdentity() {
+ if (!this._currentIdentity) {
+ throw new Error("CurrentIdentity must be set before calling Logic.currentIdentity.");
+ }
+ return this._currentIdentity;
+ },
+
+ currentUserContextId() {
+ const identity = Logic.currentIdentity();
+ return Utils.userContextId(identity.cookieStoreId);
+ },
+
+ cookieStoreId(userContextId) {
+ return `firefox-container-${userContextId}`;
+ },
+
+ currentCookieStoreId() {
+ const identity = Logic.currentIdentity();
+ return identity.cookieStoreId;
+ },
+
+ removeIdentity(userContextId) {
+ if (!userContextId) {
+ return Promise.reject("removeIdentity must be called with userContextId argument.");
+ }
+
+ return browser.runtime.sendMessage({
+ method: "deleteContainer",
+ message: { userContextId }
+ });
+ },
+
+ getAssignment(tab) {
+ return browser.runtime.sendMessage({
+ method: "getAssignment",
+ tabId: tab.id
+ });
+ },
+
+ getAssignmentObjectByContainer(userContextId) {
+ if (!userContextId) {
+ return {};
+ }
+ return browser.runtime.sendMessage({
+ method: "getAssignmentObjectByContainer",
+ message: { userContextId }
+ });
+ },
+
+ generateIdentityName() {
+ const defaultName = "Container #";
+ const ids = [];
+
+ // This loop populates the 'ids' array with all the already-used ids.
+ this._identities.forEach(identity => {
+ if (identity.name.startsWith(defaultName)) {
+ const id = parseInt(identity.name.substr(defaultName.length), 10);
+ if (id) {
+ ids.push(id);
+ }
+ }
+ });
+
+ // Here we find the first valid 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.showPanel(P_CONTAINERS_LIST);
+ });
+ 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.
+// ----------------------------------------------------------------------------
+
+Logic.registerPanel(P_ONBOARDING_1, {
+ panelSelector: ".onboarding-panel-1",
+ securityPanelSelector: ".security-onboarding-panel-1",
+
+ // This method is called when the object is registered.
+ initialize() {
+ // Let's move to the next panel.
+ [...document.querySelectorAll(".onboarding-start-button")].forEach(startElement => {
+ Utils.addEnterHandler(startElement, async () => {
+ await Logic.setOnboardingStage(1);
+ Logic.showPanel(P_ONBOARDING_2);
+ });
+ });
+ },
+
+ // This method is called when the panel is shown.
+ prepare() {
+ return Promise.resolve(null);
+ },
+});
+
+// P_ONBOARDING_2: Second page for Onboarding.
+// ----------------------------------------------------------------------------
+
+Logic.registerPanel(P_ONBOARDING_2, {
+ panelSelector: ".onboarding-panel-2",
+ securityPanelSelector: ".security-onboarding-panel-2",
+
+ // This method is called when the object is registered.
+ initialize() {
+ // Let's move to the containers list panel.
+ [...document.querySelectorAll(".onboarding-next-button")].forEach(nextElement => {
+ Utils.addEnterHandler(nextElement, async () => {
+ await Logic.setOnboardingStage(2);
+ Logic.showPanel(P_ONBOARDING_3);
+ });
+ });
+ },
+
+ // This method is called when the panel is shown.
+ prepare() {
+ return Promise.resolve(null);
+ },
+});
+
+// P_ONBOARDING_3: Third page for Onboarding.
+// ----------------------------------------------------------------------------
+
+Logic.registerPanel(P_ONBOARDING_3, {
+ panelSelector: ".onboarding-panel-3",
+ securityPanelSelector: ".security-onboarding-panel-3",
+
+ // This method is called when the object is registered.
+ initialize() {
+ // Let's move to the containers list panel.
+ [...document.querySelectorAll(".onboarding-almost-done-button")].forEach(almostElement => {
+ Utils.addEnterHandler(almostElement, async () => {
+ await Logic.setOnboardingStage(3);
+ Logic.showPanel(P_ONBOARDING_4);
+ });
+ });
+ },
+
+ // This method is called when the panel is shown.
+ prepare() {
+ return Promise.resolve(null);
+ },
+});
+
+// P_ONBOARDING_4: Fourth page for Onboarding.
+// ----------------------------------------------------------------------------
+
+Logic.registerPanel(P_ONBOARDING_4, {
+ panelSelector: ".onboarding-panel-4",
+
+ // This method is called when the object is registered.
+ initialize() {
+ // Let's move to the containers list panel.
+ Utils.addEnterHandler(document.querySelector("#onboarding-done-button"), async () => {
+ await Logic.setOnboardingStage(4);
+ Logic.showPanel(P_ONBOARDING_5);
+ });
+ },
+
+ // This method is called when the panel is shown.
+ prepare() {
+ return Promise.resolve(null);
+ },
+});
+
+// P_ONBOARDING_5: Fifth page for Onboarding: new tab long-press behavior
+// ----------------------------------------------------------------------------
+
+Logic.registerPanel(P_ONBOARDING_5, {
+ panelSelector: ".onboarding-panel-5",
+
+ // This method is called when the object is registered.
+ initialize() {
+ // Let's move to the containers list panel.
+ 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(6);
+ await browser.storage.local.set({syncEnabled: false});
+ await browser.runtime.sendMessage({
+ method: "resetSync"
+ });
+ Logic.showPanel(P_ONBOARDING_8);
+ });
+ },
+
+ // This method is called when the panel is shown.
+ prepare() {
+ return Promise.resolve(null);
+ },
+});
+// -----------------------------------------------------------------------
+
+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_ONBOARDING_8);
+ });
+ Utils.addEnterHandler(document.querySelector("#no-sign-in"), async () => {
+ await Logic.setOnboardingStage(7);
+ Logic.showPanel(P_ONBOARDING_8);
+ });
+ },
+
+ // This method is called when the panel is shown.
+ prepare() {
+ return Promise.resolve(null);
+ },
+});
+
+Logic.registerPanel(P_ONBOARDING_8, {
+ panelSelector: ".onboarding-panel-8",
+
+ // This method is called when the object is registered.
+ initialize() {
+ document.querySelectorAll(".onboarding-done").forEach(el => {
+ Utils.addEnterHandler(el, async () => {
+ await Logic.setOnboardingStage(8);
+ Logic.showPanel(P_CONTAINERS_LIST);
+ });
+ });
+
+ },
+
+ // This method is called when the panel is shown.
+ async prepare() {
+ const mozillaVpnPermissionsEnabled = await MozillaVPN.bothPermissionsEnabled();
+ if (!mozillaVpnPermissionsEnabled) {
+ const panel = document.querySelector(".onboarding-panel-8");
+ panel.classList.add("optional-permissions-disabled");
+
+ Utils.addEnterHandler(panel.querySelector("#onboarding-enable-permissions"), async () => {
+ const granted = await browser.permissions.request({ permissions: ["proxy", "nativeMessaging"] });
+ if (granted) {
+ await Logic.setOnboardingStage(8);
+ }
+ });
+ }
+ return Promise.resolve(null);
+ },
+});
+// P_CONTAINERS_LIST: The list of containers. The main page.
+// ----------------------------------------------------------------------------
+
+Logic.registerPanel(P_CONTAINERS_LIST, {
+ panelSelector: "#container-panel",
+
+ // This method is called when the object is registered.
+ async initialize() {
+ await browser.runtime.sendMessage({ method: "MozillaVPN_queryStatus" });
+ Utils.addEnterHandler(document.querySelector("#manage-containers-link"), (e) => {
+ if (!e.target.classList.contains("disable-edit-containers")) {
+ Logic.showPanel(MANAGE_CONTAINERS_PICKER);
+ }
+ });
+ 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("#sort-containers-link"), async () => {
+ try {
+ await browser.runtime.sendMessage({
+ method: "sortTabs"
+ });
+ window.close();
+ } catch (e) {
+ window.close();
+ }
+ });
+
+ const mozillaVpnToutName = "moz-tout-main-panel";
+ const mozillaVpnPermissionsWarningDotName = "moz-permissions-warning-dot";
+
+ let { mozillaVpnHiddenToutsList } = await browser.storage.local.get("mozillaVpnHiddenToutsList");
+ if (typeof(mozillaVpnHiddenToutsList) === "undefined") {
+ await browser.storage.local.set({ "mozillaVpnHiddenToutsList": [] });
+ mozillaVpnHiddenToutsList = [];
+ }
+
+ // Decide whether to show Mozilla VPN tout
+ const mozVpnTout = document.getElementById("moz-vpn-tout");
+ const mozillaVpnInstalled = await browser.runtime.sendMessage({ method: "MozillaVPN_getInstallationStatus" });
+ const mozillaVpnToutShouldBeHidden = mozillaVpnHiddenToutsList.find(tout => tout.name === mozillaVpnToutName);
+ if (mozillaVpnInstalled || mozillaVpnToutShouldBeHidden) {
+ mozVpnTout.remove();
+ }
+
+ // Add handlers if tout is visible
+ const mozVpnDismissTout = document.querySelector(".dismiss-moz-vpn-tout");
+ if (mozVpnDismissTout) {
+ Utils.addEnterHandler((mozVpnDismissTout), async() => {
+ mozVpnTout.remove();
+ mozillaVpnHiddenToutsList.push({
+ name: mozillaVpnToutName
+ });
+ await browser.storage.local.set({ mozillaVpnHiddenToutsList });
+ });
+
+ Utils.addEnterHandler(document.querySelector("#moz-vpn-learn-more"), () => {
+ MozillaVPN.handleMozillaCtaClick("mac-main-panel-btn");
+ window.close();
+ });
+ }
+
+ // Badge Options icon if both nativeMessaging and/or proxy permissions are disabled
+ const bothMozillaVpnPermissionsEnabled = await MozillaVPN.bothPermissionsEnabled();
+ const warningDotShouldBeHidden = mozillaVpnHiddenToutsList.find(tout => tout.name === mozillaVpnPermissionsWarningDotName);
+ const optionsIcon = document.getElementById("info-icon");
+ if (optionsIcon && !bothMozillaVpnPermissionsEnabled && !warningDotShouldBeHidden) {
+ optionsIcon.classList.add("info-icon-alert");
+ }
+
+ Utils.addEnterHandler((document.querySelector("#info-icon")), async() => {
+ browser.runtime.openOptionsPage();
+ if (!mozillaVpnHiddenToutsList.find(tout => tout.name === mozillaVpnPermissionsWarningDotName)) {
+ optionsIcon.classList.remove("info-icon-alert");
+ mozillaVpnHiddenToutsList.push({
+ name: mozillaVpnPermissionsWarningDotName
+ });
+ }
+ await browser.storage.local.set({ mozillaVpnHiddenToutsList });
+ });
+ },
+
+ unregister() {
+ },
+
+ // This method is called when the panel is shown.
+ async prepare() {
+ const fragment = document.createDocumentFragment();
+ const identities = Logic.identities();
+
+ for (const identity of identities) {
+ const tr = document.createElement("tr");
+ tr.classList.add("menu-item", "hover-highlight", "keyboard-nav", "keyboard-right-arrow-override");
+ tr.setAttribute("tabindex", "0");
+ tr.setAttribute("data-cookie-store-id", identity.cookieStoreId);
+ const td = document.createElement("td");
+ const openTabs = identity.numberOfOpenTabs || "" ;
+
+ // TODO get UX and content decision on how to message and block clicks to containers with Mozilla VPN proxy configs
+ // when Mozilla VPN app is disconnected.
+
+ td.innerHTML = Utils.escaped`
+
+ `;
+
+
+
+ fragment.appendChild(tr);
+
+ tr.appendChild(td);
+
+ const openInThisContainer = tr.querySelector(".menu-item-name");
+ Utils.addEnterHandler(openInThisContainer, (e) => {
+ e.preventDefault();
+ if (openInThisContainer.dataset.mozProxyWarning === "proxy-unavailable") {
+ return;
+ }
+ 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");
+
+ list.innerHTML = "";
+ list.appendChild(fragment);
+
+ document.addEventListener("keydown", Logic.keyboardNavListener);
+ document.addEventListener("keydown", Logic.shortcutListener);
+
+ MozillaVPN.handleContainerList(identities);
+
+ // reset path
+ this._previousPanelPath = [];
+ return Promise.resolve();
+ },
+});
+
+// P_CONTAINER_INFO: More info about a container.
+// ----------------------------------------------------------------------------
+
+Logic.registerPanel(P_CONTAINER_INFO, {
+ panelSelector: "#container-info-panel",
+
+ // This method is called when the object is registered.
+ async initialize() {
+ const closeContEl = document.querySelector("#close-container-info-panel");
+ Utils.addEnterHandler(closeContEl, () => {
+ Logic.showPanel(P_CONTAINERS_LIST);
+ });
+
+ // Check if the user has incompatible add-ons installed
+ // Note: this is not implemented in messageHandler.js
+ let incompatible = false;
+ try {
+ incompatible = await browser.runtime.sendMessage({
+ method: "checkIncompatibleAddons"
+ });
+ } catch (e) {
+ throw new Error("Could not check for incompatible add-ons.");
+ }
+
+ const moveTabsEl = document.querySelector("#move-to-new-window");
+ const numTabs = await Logic.numTabs();
+ if (incompatible) {
+ Logic._disableMenuItem("Moving container tabs is incompatible with Pulse, PageShot, and SnoozeTabs.");
+ return;
+ } else if (numTabs === 1) {
+ Logic._disableMenuItem("Cannot move a tab from a single-tab window.");
+ return;
+ }
+
+ Utils.addEnterHandler(moveTabsEl, async () => {
+ await browser.runtime.sendMessage({
+ method: "moveTabsToWindow",
+ windowId: browser.windows.WINDOW_ID_CURRENT,
+ cookieStoreId: Logic.currentIdentity().cookieStoreId,
+ });
+ 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 = browser.i18n.getMessage(identity.hasHiddenTabs ? "showThisContainer" : "hideThisContainer");
+ 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`
+
+
+
+
+ | `;
+ 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 = browser.i18n.getMessage("openANewTabIn");
+ 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`
+
+ `;
+
+ 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.
+ async prepare() {
+ Logic.listenToPickerBackButton();
+ const closeContEl = document.querySelector("#close-container-picker-panel");
+ if (!this._listenerSet) {
+ Utils.addEnterHandler(closeContEl, () => {
+ Logic.showPanel(P_CONTAINERS_LIST);
+ });
+ this._listenerSet = true;
+ }
+ document.getElementById("picker-title").textContent = browser.i18n.getMessage("manageContainers");
+ 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() });
+ });
+
+ const identities = Logic.identities();
+
+ for (const identity of identities) {
+ const tr = document.createElement("tr");
+ tr.classList.add("menu-item", "hover-highlight", "keyboard-nav");
+ tr.setAttribute("tabindex", "0");
+ tr.setAttribute("data-cookie-store-id", identity.cookieStoreId);
+
+ const td = document.createElement("td");
+
+ td.innerHTML = Utils.escaped`
+
+
+
+
+
+ `;
+
+ fragment.appendChild(tr);
+
+ tr.appendChild(td);
+
+ tr.draggable = true;
+ tr.dataset.containerId = identity.cookieStoreId;
+ tr.addEventListener("dragstart", (e) => {
+ e.dataTransfer.setData(CONTAINER_DRAG_DATA_TYPE, identity.cookieStoreId);
+ });
+ tr.addEventListener("dragover", (e) => {
+ if (e.dataTransfer.types.includes(CONTAINER_DRAG_DATA_TYPE)) {
+ tr.classList.add("drag-over");
+ e.preventDefault();
+ }
+ });
+ tr.addEventListener("dragenter", (e) => {
+ if (e.dataTransfer.types.includes(CONTAINER_DRAG_DATA_TYPE)) {
+ e.preventDefault();
+ tr.classList.add("drag-over");
+ }
+ });
+ tr.addEventListener("dragleave", (e) => {
+ if (e.dataTransfer.types.includes(CONTAINER_DRAG_DATA_TYPE)) {
+ e.preventDefault();
+ tr.classList.remove("drag-over");
+ }
+ });
+ tr.addEventListener("drop", async (e) => {
+ e.preventDefault();
+ const parent = tr.parentNode;
+ const containerId = e.dataTransfer.getData(CONTAINER_DRAG_DATA_TYPE);
+ let droppedElement;
+ parent.childNodes.forEach((node) => {
+ if (node.dataset.containerId === containerId) {
+ droppedElement = node;
+ }
+ });
+ if (droppedElement && droppedElement !== tr) {
+ tr.classList.remove("drag-over");
+ parent.insertBefore(droppedElement, tr);
+ await Logic.saveContainerOrder(parent.childNodes);
+ await Logic.refreshIdentities();
+ }
+ });
+
+ Utils.addEnterHandler(tr, () => {
+ pickedFunction(identity);
+ });
+ }
+
+ const list = document.querySelector("#picker-identities-list");
+
+ list.innerHTML = "";
+ list.appendChild(fragment);
+
+ MozillaVPN.handleContainerList(identities);
+
+ return Promise.resolve();
+ }
+});
+
+// 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 = browser.i18n.getMessage("reopenThisSiteIn");
+ 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");
+ tr.setAttribute("tabindex", "0");
+ const td = document.createElement("td");
+
+ td.innerHTML = Utils.escaped`
+
+ `;
+
+ 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`
+
+ `;
+
+ 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.
+ async prepare() {
+ const identities = Logic.identities();
+ Logic.listenToPickerBackButton();
+ document.getElementById("picker-title").textContent = browser.i18n.getMessage("alwaysOpenIn");
+ const fragment = document.createDocumentFragment();
+
+ document.getElementById("new-container-div").innerHTML = "";
+
+ for (const identity of identities) {
+ 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`
+
+
+ `;
+
+ 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() { },
+
+ // This method is called when the panel is shown.
+ async prepare() {
+ const identity = Logic.currentIdentity();
+
+ // Populating the panel: name and icon
+ document.getElementById("edit-assignments-title").textContent = identity.name;
+
+ const userContextId = Logic.currentUserContextId();
+ const assignments = await Logic.getAssignmentObjectByContainer(userContextId);
+ this.showAssignedContainers(assignments);
+
+ return Promise.resolve(null);
+ },
+
+ showAssignedContainers(assignments) {
+ const closeContEl = document.querySelector("#close-container-assignment-panel");
+ Utils.addEnterHandler(closeContEl, () => {
+ const identity = Logic.currentIdentity();
+ Logic.showPanel(P_CONTAINER_EDIT, identity, false, false);
+ });
+
+ 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`
+
+
+
+
+ | `;
+ trElement.getElementsByClassName("favicon")[0].appendChild(Utils.createFavIconElement(assumedUrl));
+ trElement.querySelector(".hostname").appendChild(this.assignmentHostnameElement(site));
+ 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);
+ });
+ // Wildcard click-to-toggle subdomains
+ trElement.querySelectorAll(".subdomain").forEach((subdomainLink) => {
+ subdomainLink.addEventListener("click", async (e) => {
+ const wildcardHostname = e.target.getAttribute("data-wildcardHostname");
+ Utils.setWildcardHostnameForAssignment(assumedUrl, wildcardHostname);
+ if (wildcardHostname) {
+ // Remove wildcard from other site that has same wildcard
+ Object.values(assignments).forEach((site) => {
+ if (site.wildcardHostname === wildcardHostname) { delete site.wildcardHostname; }
+ });
+ site.wildcardHostname = wildcardHostname;
+ } else {
+ delete site.wildcardHostname;
+ }
+ this.showAssignedContainers(assignments);
+ });
+ });
+ trElement.classList.add("menu-item", "hover-highlight", "keyboard-nav");
+ tableElement.appendChild(trElement);
+ });
+ }
+ },
+
+ getSubdomains(site) {
+ const hostname = site.hostname;
+ const wildcardHostname = site.wildcardHostname;
+ if (wildcardHostname && wildcardHostname !== hostname) {
+ if (hostname.endsWith(wildcardHostname)) {
+ return {
+ wildcard: hostname.substring(0, hostname.length - wildcardHostname.length),
+ remaining: wildcardHostname
+ };
+ } else {
+ // In case something got corrupted, allow user to fix error
+ // by clicking "____" link to clear corrupted wildcard hostname
+ return {
+ wildcard: "___",
+ remaining: hostname
+ };
+ }
+ } else {
+ return {
+ wildcard: null,
+ remaining: hostname
+ };
+ }
+ },
+
+ assignmentHostnameElement(site) {
+ const result = document.createElement("span");
+ const subdomains = this.getSubdomains(site);
+
+ // Add wildcard subdomain(s)
+ if (subdomains.wildcard) {
+ result.appendChild(this.assignmentSubdomainLink(null, subdomains.wildcard));
+ }
+
+ // Add non-wildcard subdomains
+ let remainingHostname = subdomains.remaining;
+ let indexOfDot;
+ while ((indexOfDot = remainingHostname.indexOf(".")) >= 0) {
+ const subdomain = remainingHostname.substring(0, indexOfDot);
+ remainingHostname = remainingHostname.substring(indexOfDot + 1);
+ result.appendChild(this.assignmentSubdomainLink(remainingHostname, subdomain));
+ result.appendChild(document.createTextNode("."));
+ }
+
+ // Root domain
+ if (remainingHostname) { result.appendChild(document.createTextNode(remainingHostname)); }
+
+ return result;
+ },
+
+ assignmentSubdomainLink(wildcardHostnameOnClick, text) {
+ const result = document.createElement("a");
+ result.className = "subdomain";
+ if (wildcardHostnameOnClick) {
+ result.setAttribute("data-wildcardHostname", wildcardHostnameOnClick);
+ } else {
+ result.classList.add("wildcardSubdomain");
+ }
+ result.appendChild(document.createTextNode(text));
+ return result;
+ },
+});
+
+// P_CONTAINER_EDIT: Editor for a container.
+// ----------------------------------------------------------------------------
+
+Logic.registerPanel(P_CONTAINER_EDIT, {
+ panelSelector: "#edit-container-panel",
+
+ // This method is called when the object is registered.
+ async initialize() {
+ this.initializeRadioButtons();
+
+ await browser.runtime.sendMessage({ method: "MozillaVPN_queryServers" });
+ await browser.runtime.sendMessage({ method: "MozillaVPN_queryStatus" });
+
+ class MozVpnContainerUi extends HTMLElement {
+ constructor() {
+ super();
+
+ this.subtitle = this.querySelector(".moz-vpn-subtitle");
+ this.collapsibleContent = this.querySelector(".collapsible-content");
+
+ this.visibilityTogglers = this.querySelectorAll(".hide-show-label");
+ this.hideShowButton = this.querySelector(".expand-collapse");
+ this.primaryCta = this.querySelector("#get-mozilla-vpn");
+ this.advancedProxySettingsButton = document.querySelector(".advanced-proxy-settings-btn");
+ this.toutName = "moz-tout-edit-container-panel";
+
+ // Switch
+ this.switch = this.querySelector("#moz-vpn-switch");
+ this.switchLabel = this.querySelector(".switch");
+
+ // Current server button
+ this.currentServerButton = this.querySelector("#moz-vpn-current-server");
+ this.currentCityName = this.querySelector(".current-city-name");
+ this.currentCountryFlag = this.querySelector(".current-country-flag");
+ this.currentCountryCode;
+
+ // Proxy inputs + viewer
+ this.advancedProxyAddress = document.getElementById("advanced-proxy-address");
+ this.proxyAddressInput = document.querySelector("#edit-container-panel-proxy");
+ this.cityNameInput = document.getElementById("city-name-input");
+ this.countryCodeInput = document.getElementById("country-code-input");
+ this.mozProxyEnabledInput = document.getElementById("moz-proxy-enabled");
+ }
+
+ async connectedCallback() {
+ const { mozillaVpnHiddenToutsList } = await browser.storage.local.get("mozillaVpnHiddenToutsList");
+ const mozillaVpnCollapseEditContainerTout = mozillaVpnHiddenToutsList && mozillaVpnHiddenToutsList.find(tout => tout.name === this.toutName);
+ const mozillaVpnInstalled = await browser.runtime.sendMessage({ method: "MozillaVPN_getInstallationStatus" });
+
+ this.hideShowButton.addEventListener("click", this);
+
+ if (mozillaVpnCollapseEditContainerTout && !mozillaVpnInstalled) {
+ this.collapseUi();
+ }
+
+ // Add listeners
+ if (!this.classList.contains("has-attached-listeners")) {
+
+ const bothMozillaVpnPermissionsEnabled = await MozillaVPN.bothPermissionsEnabled();
+ this.primaryCta.addEventListener("click", async() => {
+ if (!bothMozillaVpnPermissionsEnabled && mozillaVpnInstalled) {
+ await browser.permissions.request({ permissions: ["proxy", "nativeMessaging"] });
+ } else {
+ MozillaVPN.handleMozillaCtaClick("mac-edit-container-panel-btn");
+ }
+
+ });
+
+ this.switch.addEventListener("click", async() => {
+ const { mozillaVpnServers } = await browser.storage.local.get("mozillaVpnServers");
+ const id = Logic.currentIdentity();
+ this.enableDisableProxyButtons();
+
+ if (!this.switch.checked) {
+ const deactivatedMozProxy = MozillaVPN.getProxy(
+ this.countryCodeInput.value,
+ this.cityNameInput.value,
+ undefined,
+ mozillaVpnServers
+ );
+
+ if (!deactivatedMozProxy) {
+ return;
+ }
+
+ await proxifiedContainers.set(id.cookieStoreId, deactivatedMozProxy);
+ this.switch.checked = false;
+ return;
+ }
+ let proxy;
+
+ if (this.countryCodeInput.value.length === 2) {
+ // User is re-enabling a Mozilla proxy for this container.
+ // Use the stored location information to select a server
+ // in the same location.
+ proxy = MozillaVPN.getProxy(
+ this.countryCodeInput.value,
+ this.cityNameInput.value,
+ true,
+ mozillaVpnServers
+ );
+
+ } else {
+ // No saved Mozilla VPN proxy information. Get something new.
+ const { randomServerCountryCode, randomServerCityName } = await MozillaVPN.pickRandomLocation();
+
+ proxy = MozillaVPN.getProxy(
+ randomServerCountryCode,
+ randomServerCityName,
+ true,
+ mozillaVpnServers
+ );
+ }
+
+ if (proxy) {
+ await proxifiedContainers.set(id.cookieStoreId, proxy);
+ this.switch.checked = true;
+ this.updateProxyDependentUi(proxy);
+ } else {
+ this.switch.checked = false;
+ this.updateProxyDependentUi({});
+ return;
+ }
+ });
+ }
+
+ this.classList.add("has-attached-listeners");
+ this.currentServerButton.classList.add("hidden");
+ }
+
+ async updateMozVpnStatusDependentUi() {
+ const mozillaVpnInstalled = await browser.runtime.sendMessage({ method: "MozillaVPN_getInstallationStatus" });
+ const mozillaVpnConnected = await browser.runtime.sendMessage({ method: "MozillaVPN_getConnectionStatus" });
+
+ this.subtitle.textContent = browser.i18n.getMessage("integrateContainers");
+
+ const bothMozillaVpnPermissionsEnabled = await MozillaVPN.bothPermissionsEnabled();
+
+ if (mozillaVpnInstalled && !bothMozillaVpnPermissionsEnabled) {
+ this.subtitle.style.flex = "1 1 100%";
+ this.classList.remove("show-server-button");
+ this.subtitle.textContent = browser.i18n.getMessage("additionalPermissionNeeded");
+ this.hideEls(this.hideShowButton, this.switch, this.switchLabel, this.currentServerButton);
+ this.primaryCta.style.display = "block";
+ this.primaryCta.textContent = browser.i18n.getMessage("enable");
+ return;
+ }
+
+ if (mozillaVpnInstalled) {
+ // Hide cta and hide/show button
+ this.hideEls(this.primaryCta, this.hideShowButton);
+
+ // Update subtitle
+ this.subtitle.textContent = mozillaVpnConnected ? browser.i18n.getMessage("useCustomLocation") : browser.i18n.getMessage("mozillaVpnMustBeOn");
+ this.subtitle.style.flex = "1 1 80%";
+ this.currentServerButton.style.display = "flex";
+ }
+
+ if (mozillaVpnConnected) {
+ [this.switchLabel, this.switch].forEach(el => {
+ el.style.display = "inline-block";
+ });
+ } else {
+ this.hideEls(this.switch, this.switchLabel, this.currentServerButton);
+ this.switch.checked = false;
+ }
+
+ if ((mozillaVpnInstalled && !mozillaVpnConnected) || mozillaVpnConnected) {
+ this.expandUi();
+ }
+ }
+
+
+ async enableDisableProxyButtons() {
+ const mozillaVpnConnected = await browser.runtime.sendMessage({ method: "MozillaVPN_getConnectionStatus" });
+
+ if (!this.switch.checked || this.switch.disabled || !mozillaVpnConnected) {
+ this.currentServerButton.disabled = true;
+ this.advancedProxySettingsButton.disabled = false;
+ document.getElementById("moz-proxy-enabled").value = undefined;
+ return;
+ }
+
+ this.currentServerButton.disabled = false;
+ this.advancedProxySettingsButton.disabled = true;
+ this.advancedProxyAddress.textContent = "";
+ }
+
+ updateProxyInputs(proxyInfo) {
+ const resetProxyStorageEls = () => {
+ [this.proxyAddressInput, this.cityNameInput, this.countryCodeInput, this.mozProxyEnabledInput].forEach(el => {
+ el.value = "";
+
+ });
+ this.advancedProxyAddress.textContent = "";
+ };
+
+ resetProxyStorageEls();
+
+ if (typeof(proxyInfo) === "undefined" || typeof(proxyInfo.type) === "undefined") {
+ // no custom proxy is set
+ return;
+ }
+
+ this.cityNameInput.value = proxyInfo.cityName;
+ this.countryCodeInput.value = proxyInfo.countryCode;
+ this.mozProxyEnabledInput.value = proxyInfo.mozProxyEnabled;
+ this.proxyAddressInput.value = `${proxyInfo.type}://${proxyInfo.host}:${proxyInfo.port}`;
+
+ if (typeof(proxyInfo.countryCode) === "undefined" && proxyInfo.type) {
+ // Set custom proxy URL below 'Advanced proxy settings' button label
+ this.advancedProxyAddress.textContent = `${proxyInfo.type}://${proxyInfo.host}:${proxyInfo.port}`;
+ }
+ }
+
+ async updateProxyDependentUi(proxyInfo) {
+ const mozillaVpnProxyLocationAvailable = (proxy) => {
+ return typeof(proxy) !== "undefined" && typeof(proxy.countryCode) !== "undefined" && typeof(proxy.cityName) !== "undefined";
+ };
+
+ const mozillaVpnProxyIsEnabled = (proxy) => {
+ return typeof(proxy) !== "undefined" && typeof(proxy.mozProxyEnabled) !== "undefined" && proxy.mozProxyEnabled === true;
+ };
+
+ this.switch.checked = mozillaVpnProxyIsEnabled(proxyInfo);
+ this.updateProxyInputs(proxyInfo);
+ this.enableDisableProxyButtons();
+
+ const mozillaVpnConnected = await browser.runtime.sendMessage({ method: "MozillaVPN_getConnectionStatus" });
+ if (
+ !proxyInfo ||
+ !mozillaVpnProxyLocationAvailable(proxyInfo) ||
+ !mozillaVpnConnected
+ ) {
+ // Hide server location button
+ this.currentServerButton.classList.add("hidden");
+ this.classList.remove("show-server-button");
+ } else {
+ // Unhide server location button
+ this.currentServerButton.style.display = "flex";
+ this.currentServerButton.classList.remove("hidden");
+ this.classList.add("show-server-button");
+ }
+
+ // Populate inputs and server button with current or previously stored mozilla vpn proxy
+ if(proxyInfo && mozillaVpnProxyLocationAvailable(proxyInfo)) {
+ this.currentCountryFlag.style.backgroundImage = `url("./img/flags/${proxyInfo.countryCode.toUpperCase()}.png")`;
+ this.currentCountryFlag.style.backgroundImage = proxyInfo.countryCode + ".png";
+ this.currentCityName.textContent = proxyInfo.cityName;
+ this.countryCode = proxyInfo.countryCode;
+ }
+ }
+
+ expandUi() {
+ this.classList.add("expanded");
+ }
+
+ collapseUi() {
+ this.classList.remove("expanded");
+ }
+
+ hideEls(...els) {
+ els.forEach(el => {
+ el.style.display = "none";
+ });
+ }
+
+ async handleEvent(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.type === "keyup" && e.key !== " ") {
+ return;
+ }
+ this.classList.toggle("expanded");
+
+ const { mozillaVpnHiddenToutsList } = await browser.storage.local.get("mozillaVpnHiddenToutsList");
+ if (typeof(mozillaVpnHiddenToutsList) === "undefined") {
+ await browser.storage.local.set({ "mozillaVpnHiddenToutsList":[] });
+ }
+
+ const toutIndex = mozillaVpnHiddenToutsList.findIndex(tout => tout.name === mozillaVpnUi.toutName);
+ if (toutIndex === -1) {
+ mozillaVpnHiddenToutsList.push({ name: mozillaVpnUi.toutName });
+ } else {
+ this.expandUi();
+ mozillaVpnHiddenToutsList.splice(toutIndex, 1);
+ }
+ return await browser.storage.local.set({ mozillaVpnHiddenToutsList });
+ }
+
+ }
+
+ customElements.define("moz-vpn-container-ui", MozVpnContainerUi);
+ const mozillaVpnUi = document.querySelector("moz-vpn-container-ui");
+ mozillaVpnUi.updateMozVpnStatusDependentUi();
+
+ browser.permissions.onAdded.addListener(() => { mozillaVpnUi.updateMozVpnStatusDependentUi(); });
+ browser.permissions.onRemoved.addListener(() => { mozillaVpnUi.updateMozVpnStatusDependentUi(); });
+
+ const advancedProxySettingsButton = document.querySelector(".advanced-proxy-settings-btn");
+ Utils.addEnterHandler(advancedProxySettingsButton, () => {
+ Logic.showPanel(P_ADVANCED_PROXY_SETTINGS, this.getEditInProgressIdentity(), false, false);
+ });
+
+ const serverListButton = document.getElementById("moz-vpn-current-server");
+ Utils.addEnterHandler(serverListButton, () => {
+ const mozVpnEnabled = document.querySelector("#moz-vpn-switch").checked;
+ if (!mozVpnEnabled) {
+ return;
+ }
+ Logic.showPanel(P_MOZILLA_VPN_SERVER_LIST, this.getEditInProgressIdentity(), false);
+ });
+
+ 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();
+ } else {
+ Logic.showPreviousPanel();
+ }
+ });
+
+ this._editForm = document.getElementById("edit-container-panel-form");
+ this._editForm.addEventListener("submit", () => {
+ this._submitForm();
+ });
+ Utils.addEnterHandler(document.querySelector("#create-container-cancel-link"), () => {
+ Logic.showPanel(MANAGE_CONTAINERS_PICKER);
+ });
+
+ Utils.addEnterHandler(document.querySelector("#create-container-ok-link"), () => {
+ this._submitForm();
+ });
+ },
+
+ async _submitForm() {
+ const formValues = new FormData(this._editForm);
+
+ try {
+ await browser.runtime.sendMessage({
+ method: "createOrUpdateContainer",
+ message: {
+ userContextId: formValues.get("container-id") || NEW_CONTAINER_ID,
+ params: {
+ name: document.getElementById("edit-container-panel-name-input").value || Logic.generateIdentityName(),
+ icon: formValues.get("container-icon") || DEFAULT_ICON,
+ color: formValues.get("container-color") || DEFAULT_COLOR
+ },
+ }
+ });
+ await Logic.refreshIdentities();
+ Logic.showPreviousPanel();
+ } catch (e) {
+ Logic.showPreviousPanel();
+ }
+ },
+
+ openServerList() {
+ const updatedIdentity = this.getEditInProgressIdentity();
+ Logic.showPanel(P_MOZILLA_VPN_SERVER_LIST, updatedIdentity, false);
+ },
+
+ // This prevents identity edits (change of icon, color, etc)
+ // from getting lost when navigating to and from one
+ // of the edit sub-pages (advanced proxy settings, for instance).
+ getEditInProgressIdentity() {
+ const formValues = new FormData(this._editForm);
+ const editedIdentity = Logic.currentIdentity();
+
+ editedIdentity.color = formValues.get("container-color") || DEFAULT_COLOR;
+ editedIdentity.icon = formValues.get("container-icon") || DEFAULT_ICON;
+ editedIdentity.name = document.getElementById("edit-container-panel-name-input").value || Logic.generateIdentityName();
+ return editedIdentity;
+ },
+
+ initializeRadioButtons() {
+ const colorRadioTemplate = (containerColor) => {
+ return Utils.escaped`
+