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` + + ${identity.name} + + `; + + tr.appendChild(td); + fragment.appendChild(tr); + + Utils.addEnterHandler(tr, async () => { + Utils.alwaysOpenInContainer(identity); + window.close(); + }); + } + + const list = document.querySelector("#picker-identities-list"); + list.innerHTML = ""; + list.appendChild(fragment); + + 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` + + + + ${openTabs} + + Container Info + + + `; + + + + 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` + +
+ ${tab.title} + + `; + tr.querySelector(".favicon").appendChild(Utils.createFavIconElement(tab.favIconUrl)); + tr.setAttribute("tabindex", "0"); + table.appendChild(fragment); + + // On click, we activate this tab. But only if this tab is active. + if (!tab.hiddenState) { + Utils.addEnterHandler(tr, async () => { + await browser.tabs.update(tab.id, { active: true }); + window.close(); + }); + + const closeTab = tr.querySelector(".trash-button"); + if (closeTab) { + Utils.addEnterHandler(closeTab, async (e) => { + await browser.tabs.remove(Number(e.target.id)); + window.close(); + }); + } + } + } + }, +}); + +// OPEN_NEW_CONTAINER_PICKER: Opens a new container tab. +// ---------------------------------------------------------------------------- + +Logic.registerPanel(OPEN_NEW_CONTAINER_PICKER, { + panelSelector: "#container-picker-panel", + + // This method is called when the object is registered. + initialize() { + }, + + // This method is called when the panel is shown. + prepare() { + Logic.listenToPickerBackButton(); + document.getElementById("picker-title").textContent = 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` + + ${identity.name}`; + + fragment.appendChild(tr); + + tr.appendChild(td); + + Utils.addEnterHandler(tr, () => { + pickedFunction(identity); + }); + + }); + + const list = document.querySelector("#picker-identities-list"); + + list.innerHTML = ""; + list.appendChild(fragment); + + return Promise.resolve(null); + } +}); + +// MANAGE_CONTAINERS_PICKER: Makes the list editable. +// ---------------------------------------------------------------------------- + +Logic.registerPanel(MANAGE_CONTAINERS_PICKER, { + panelSelector: "#container-picker-panel", + + // This method is called when the object is registered. + initialize() { + }, + + // This method is called when the panel is shown. + 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` + + ${identity.name} + + + + `; + + 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` + + Default Container`; + + fragment.appendChild(tr); + + tr.appendChild(td); + + Utils.addEnterHandler(tr, () => { + Utils.reloadInContainer( + currentTab.url, + false, + 0, + currentTab.index + 1, + currentTab.active + ); + window.close(); + }); + } + + Logic.identities().forEach(identity => { + if (currentTab.cookieStoreId !== identity.cookieStoreId) { + const tr = document.createElement("tr"); + tr.classList.add("menu-item", "hover-highlight", "keyboard-nav"); + tr.setAttribute("tabindex", "0"); + const td = document.createElement("td"); + + td.innerHTML = Utils.escaped` + + ${identity.name}`; + + fragment.appendChild(tr); + + tr.appendChild(td); + + Utils.addEnterHandler(tr, () => { + pickedFunction(identity); + }); + } + }); + + const list = document.querySelector("#picker-identities-list"); + + list.innerHTML = ""; + list.appendChild(fragment); + + return Promise.resolve(null); + } +}); + +// ALWAYS_OPEN_IN_PICKER: Makes the list editable. +// ---------------------------------------------------------------------------- + +Logic.registerPanel(ALWAYS_OPEN_IN_PICKER, { + panelSelector: "#container-picker-panel", + + // This method is called when the object is registered. + initialize() { + }, + + // This method is called when the panel is shown. + 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` + + ${identity.name} + `; + + fragment.appendChild(tr); + + tr.appendChild(td); + + Utils.addEnterHandler(tr, () => { + Utils.alwaysOpenInContainer(identity); + window.close(); + }); + } + + const list = document.querySelector("#picker-identities-list"); + + list.innerHTML = ""; + list.appendChild(fragment); + + return Promise.resolve(null); + } +}); + +// P_CONTAINER_ASSIGNMENTS: Shows Site Assignments and allows editing. +// ---------------------------------------------------------------------------- + +Logic.registerPanel(P_CONTAINER_ASSIGNMENTS, { + panelSelector: "#edit-container-assignments", + + // This method is called when the object is registered. + initialize() { }, + + // 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` +