diff --git a/src/css/popup.css b/src/css/popup.css
index 5c36ee6..e0b7ecf 100644
--- a/src/css/popup.css
+++ b/src/css/popup.css
@@ -818,6 +818,11 @@ span ~ .panel-header-text {
flex: 1;
}
+/* Wildcard subdomains: https://github.com/mozilla/multi-account-containers/issues/473 */
+.assigned-sites-list .hostname .subdomain:hover {
+ text-decoration: underline;
+}
+
.radio-choice > .radio-container {
align-items: center;
block-size: 29px;
diff --git a/src/js/background/assignManager.js b/src/js/background/assignManager.js
index dc9e991..860bd81 100644
--- a/src/js/background/assignManager.js
+++ b/src/js/background/assignManager.js
@@ -1,3 +1,183 @@
+/**
+ Utils for dealing with hosts.
+
+ E.g. www.google.com:443
+ */
+const HostUtils = {
+ getHost(pageUrl) {
+ const url = new window.URL(pageUrl);
+ if (url.port === "80" || url.port === "443") {
+ return `${url.hostname}`;
+ } else {
+ return `${url.hostname}${url.port}`;
+ }
+ },
+
+ // Wildcard subdomains: https://github.com/mozilla/multi-account-containers/issues/473
+ hasSubdomain(host) {
+ return host.indexOf(".") >= 0;
+ },
+
+ removeSubdomain(host) {
+ const indexOfDot = host.indexOf(".");
+ if (indexOfDot < 0) {
+ return null;
+ } else {
+ return host.substring(indexOfDot + 1);
+ }
+ }
+};
+
+/**
+ Store data in 'named stores'.
+
+ (In actual fact, all data for all stores is stored in the same storage area,
+ but this class provides accessor methods to get/set only the data that applies
+ to one specific named store, as identified in the constructor.)
+ */
+class AssignStore {
+ constructor(name) {
+ this.prefix = `${name}@@_`;
+ }
+
+ _storeKeyForKey(key) {
+ if (Array.isArray(key)) {
+ return key.map(oneKey => oneKey.startsWith(this.prefix) ? oneKey : `${this.prefix}${oneKey}`);
+ } else if (key) {
+ return key.startsWith(this.prefix) ? key : `${this.prefix}${key}`;
+ } else {
+ return null;
+ }
+ }
+
+ _keyForStoreKey(storeKey) {
+ if (Array.isArray(storeKey)) {
+ return storeKey.map(oneStoreKey => oneStoreKey.startsWith(this.prefix) ? oneStoreKey.substring(this.prefix.length) : null);
+ } else if (storeKey) {
+ return storeKey.startsWith(this.prefix) ? storeKey.substring(this.prefix.length) : null;
+ } else {
+ return null;
+ }
+ }
+
+ get(key) {
+ if (typeof key !== "string") { return Promise.reject(new Error(`[AssignStore.get] Invalid key: ${key}`)); }
+ const storeKey = this._storeKeyForKey(key);
+ return new Promise((resolve, reject) => {
+ browser.storage.local.get([storeKey]).then((storageResponse) => {
+ if (storeKey in storageResponse) {
+ resolve(storageResponse[storeKey]);
+ } else {
+ resolve(null);
+ }
+ }).catch((e) => {
+ reject(e);
+ });
+ });
+ }
+
+ getAll(keys) {
+ if (keys && !Array.isArray(keys)) { return Promise.reject(new Error(`[AssignStore.getAll] Invalid keys: ${keys}`)); }
+ const storeKeys = this._storeKeyForKey(keys);
+ return new Promise((resolve, reject) => {
+ browser.storage.local.get(storeKeys).then((storageResponse) => {
+ if (storageResponse) {
+ resolve(Object.assign({}, ...Object.entries(storageResponse).map(([oneStoreKey, data]) => {
+ const key = this._keyForStoreKey(oneStoreKey);
+ return key ? { [key]: data } : null;
+ })));
+ } else {
+ resolve(null);
+ }
+ }).catch((e) => {
+ reject(e);
+ });
+ });
+ }
+
+ set(key, data) {
+ if (typeof key !== "string") { return Promise.reject(new Error(`[AssignStore.set] Expected String, but received ${key}`)); }
+ const storeKey = this._storeKeyForKey(key);
+ return browser.storage.local.set({
+ [storeKey]: data
+ });
+ }
+
+ remove(key) {
+ if (typeof key !== "string") { return Promise.reject(new Error(`[AssignStore.remove] Expected String, but received ${key}`)); }
+ const storeKey = this._storeKeyForKey(key);
+ return browser.storage.local.remove(storeKey);
+ }
+
+ removeAll(keys) {
+ if (keys && !Array.isArray(keys)) { return Promise.reject(new Error(`[AssignStore.removeAll] Invalid keys: ${keys}`)); }
+ const storeKeys = this._storeKeyForKey(keys);
+ return browser.storage.local.remove(storeKeys);
+ }
+}
+
+/**
+ Manages mappings of Site Host <-> Wildcard Host.
+
+ E.g. drive.google.com <-> google.com
+
+ Wildcard subdomains: https://github.com/mozilla/multi-account-containers/issues/473
+ */
+const WildcardManager = {
+ bySite: new AssignStore("siteToWildcardMap"),
+ byWildcard: new AssignStore("wildcardToSiteMap"),
+
+ // Site -> Wildcard
+ get(site) {
+ return this.bySite.get(site);
+ },
+
+ async getAll(sites) {
+ return this.bySite.getAll(sites);
+ },
+
+ async set(site, wildcard) {
+ // Remove existing site -> wildcard
+ const oldSite = await this.byWildcard.get(wildcard);
+ if (oldSite) { await this.bySite.remove(oldSite); }
+
+ // Set new mappings site <-> wildcard
+ await this.bySite.set(site, wildcard);
+ await this.byWildcard.set(wildcard, site);
+ },
+
+ async remove(site) {
+ const wildcard = await this.bySite.get(site);
+ if (!wildcard) { return; }
+
+ await this.bySite.remove(site);
+ await this.byWildcard.remove(wildcard);
+ },
+
+ async removeAll(sites) {
+ const data = await this.bySite.getAll(sites);
+ const existingSites = Object.keys(data);
+ const existingWildcards = Object.values(data);
+
+ await this.bySite.removeAll(existingSites);
+ await this.byWildcard.removeAll(existingWildcards);
+ },
+
+ // Site -> Site that owns Wildcard
+ async match(site) {
+ // Keep stripping subdomains off site domain until match a wildcard domain
+ do {
+ // Use the ever-shortening site hostname as if it is a wildcard
+ const siteHavingWildcard = await this.byWildcard.get(site);
+ if (siteHavingWildcard) { return siteHavingWildcard; }
+ } while ((site = HostUtils.removeSubdomain(site)));
+ return null;
+ }
+};
+
+/**
+ Main interface for managing assignments.
+ */
const assignManager = {
MENU_ASSIGN_ID: "open-in-this-container",
MENU_REMOVE_ID: "remove-open-in-this-container",
@@ -6,91 +186,123 @@ const assignManager = {
MENU_MOVE_ID: "move-to-new-window-container",
storageArea: {
- area: browser.storage.local,
+ store: new AssignStore("siteContainerMap"),
exemptedTabs: {},
- getSiteStoreKey(pageUrl) {
- const url = new window.URL(pageUrl);
- const storagePrefix = "siteContainerMap@@_";
- if (url.port === "80" || url.port === "443") {
- return `${storagePrefix}${url.hostname}`;
- } else {
- return `${storagePrefix}${url.hostname}${url.port}`;
+ setExempted(host, tabId) {
+ if (!(host in this.exemptedTabs)) {
+ this.exemptedTabs[host] = [];
}
+ this.exemptedTabs[host].push(tabId);
},
- setExempted(pageUrl, tabId) {
- const siteStoreKey = this.getSiteStoreKey(pageUrl);
- if (!(siteStoreKey in this.exemptedTabs)) {
- this.exemptedTabs[siteStoreKey] = [];
- }
- this.exemptedTabs[siteStoreKey].push(tabId);
+ removeExempted(host) {
+ this.exemptedTabs[host] = [];
},
- removeExempted(pageUrl) {
- const siteStoreKey = this.getSiteStoreKey(pageUrl);
- this.exemptedTabs[siteStoreKey] = [];
- },
-
- isExempted(pageUrl, tabId) {
- const siteStoreKey = this.getSiteStoreKey(pageUrl);
- if (!(siteStoreKey in this.exemptedTabs)) {
+ isExemptedUrl(pageUrl, tabId) {
+ const host = HostUtils.getHost(pageUrl);
+ if (!(host in this.exemptedTabs)) {
return false;
}
- return this.exemptedTabs[siteStoreKey].includes(tabId);
+ return this.exemptedTabs[host].includes(tabId);
},
- get(pageUrl) {
- const siteStoreKey = this.getSiteStoreKey(pageUrl);
- 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 matchUrl(pageUrl) {
+ const host = HostUtils.getHost(pageUrl);
+
+ // Try exact match
+ const result = await this.get(host);
+ if (result) { return result; }
+
+ // Try wildcard match
+ const wildcard = await WildcardManager.match(host);
+ if (wildcard) { return await this.get(wildcard); }
+
+ return null;
},
- set(pageUrl, data, exemptedTabIds) {
- const siteStoreKey = this.getSiteStoreKey(pageUrl);
+ async get(host) {
+ const result = await this.store.get(host);
+ if (result) {
+ if (result.host !== host) { result.host = host; }
+ result.wildcard = await WildcardManager.get(host);
+ }
+ return result;
+ },
+
+ async set(host, data, exemptedTabIds, wildcard) {
+ // Store exempted tabs
if (exemptedTabIds) {
exemptedTabIds.forEach((tabId) => {
- this.setExempted(pageUrl, tabId);
+ this.setExempted(host, tabId);
});
}
- return this.area.set({
- [siteStoreKey]: data
- });
+ // Store wildcard mapping
+ if (wildcard) {
+ if (wildcard === host) {
+ await WildcardManager.remove(host);
+ } else {
+ await WildcardManager.set(host, wildcard);
+ }
+ }
+ // Do not store wildcard property
+ if (data.wildcard) {
+ data = Object.assign(data);
+ delete data.wildcard;
+ }
+ // Store assignment
+ return this.store.set(host, data);
},
- remove(pageUrl) {
- const siteStoreKey = this.getSiteStoreKey(pageUrl);
+ async remove(host) {
// When we remove an assignment we should clear all the exemptions
- this.removeExempted(pageUrl);
- return this.area.remove([siteStoreKey]);
+ this.removeExempted(host);
+ // ...and also clear the wildcard mapping
+ await WildcardManager.remove(host);
+
+ return this.store.remove(host);
},
async deleteContainer(userContextId) {
const sitesByContainer = await this.getByContainer(userContextId);
- this.area.remove(Object.keys(sitesByContainer));
+ const sites = Object.keys(sitesByContainer);
+
+ sites.forEach((site) => {
+ // When we remove an assignment we should clear all the exemptions
+ this.removeExempted(site);
+ });
+
+ // ...and also clear the wildcard mappings
+ await WildcardManager.removeAll(sites);
+
+ return this.store.removeAll(sites);
},
async getByContainer(userContextId) {
- const sites = {};
- const siteConfigs = await this.area.get();
- Object.keys(siteConfigs).forEach((key) => {
+ // Get sites
+ const sitesConfig = await this.store.getAll();
+ const sites = Object.assign({}, ...Object.entries(sitesConfig).map(([host, data]) => {
// For some reason this is stored as string... lets check them both as that
- if (String(siteConfigs[key].userContextId) === String(userContextId)) {
- const site = siteConfigs[key];
+ if (String(data.userContextId) === String(userContextId)) {
// In hindsight we should have stored this
// TODO file a follow up to clean the storage onLoad
- site.hostname = key.replace(/^siteContainerMap@@_/, "");
- sites[key] = site;
- }
- });
+ data.host = host;
+ return { [host]: data };
+ } else {
+ return null;
+ }
+ }));
+
+ // Add wildcards
+ const hosts = Object.keys(sites);
+ if (hosts.length > 0) {
+ const sitesToWildcards = await WildcardManager.getAll(hosts);
+ Object.entries(sitesToWildcards).forEach(([site, wildcard]) => {
+ sites[site].wildcard = wildcard;
+ });
+ }
+
return sites;
}
},
@@ -99,10 +311,10 @@ const assignManager = {
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.get(pageUrl).then((siteSettings) => {
+ this.storageArea.matchUrl(pageUrl).then((siteSettings) => {
if (siteSettings) {
siteSettings.neverAsk = true;
- this.storageArea.set(pageUrl, siteSettings);
+ return this.storageArea.set(siteSettings.host, siteSettings);
}
}).catch((e) => {
throw e;
@@ -113,7 +325,8 @@ const assignManager = {
// We return here so the confirm page can load the tab when exempted
async _exemptTab(m) {
const pageUrl = m.pageUrl;
- this.storageArea.setExempted(pageUrl, m.tabId);
+ const host = HostUtils.getHost(pageUrl);
+ this.storageArea.setExempted(host, m.tabId);
return true;
},
@@ -125,7 +338,7 @@ const assignManager = {
this.removeContextMenu();
const [tab, siteSettings] = await Promise.all([
browser.tabs.get(options.tabId),
- this.storageArea.get(options.url)
+ this.storageArea.matchUrl(options.url)
]);
let container;
try {
@@ -144,7 +357,7 @@ const assignManager = {
if (!siteSettings
|| userContextId === siteSettings.userContextId
|| tab.incognito
- || this.storageArea.isExempted(options.url, tab.id)) {
+ || this.storageArea.isExemptedUrl(options.url, tab.id)) {
return {};
}
const removeTab = backgroundLogic.NEW_TAB_PAGES.has(tab.url)
@@ -299,7 +512,7 @@ const assignManager = {
return true;
},
- async _setOrRemoveAssignment(tabId, pageUrl, userContextId, remove) {
+ async _setOrRemoveAssignment(tabId, pageUrl, userContextId, remove, options) {
let actionName;
// https://github.com/mozilla/testpilot-containers/issues/626
@@ -307,13 +520,15 @@ const assignManager = {
// the value to a string for accurate checking
userContextId = String(userContextId);
+ const assignmentHost = HostUtils.getHost(pageUrl);
if (!remove) {
const tabs = await browser.tabs.query({});
- const assignmentStoreKey = this.storageArea.getSiteStoreKey(pageUrl);
+ const wildcardHost = options && options.wildcard ? options.wildcard : null;
const exemptedTabIds = tabs.filter((tab) => {
- const tabStoreKey = this.storageArea.getSiteStoreKey(tab.url);
+ const tabHost = HostUtils.getHost(tab.url);
/* Auto exempt all tabs that exist for this hostname that are not in the same container */
- if (tabStoreKey === assignmentStoreKey &&
+ if ( (tabHost === assignmentHost ||
+ (wildcardHost && tabHost.endsWith(wildcardHost))) &&
this.getUserContextIdFromCookieStore(tab) !== userContextId) {
return true;
}
@@ -321,29 +536,38 @@ const assignManager = {
}).map((tab) => {
return tab.id;
});
-
- await this.storageArea.set(pageUrl, {
+
+ await this.storageArea.set(assignmentHost, {
userContextId,
neverAsk: false
- }, exemptedTabIds);
+ }, exemptedTabIds, (wildcardHost || assignmentHost));
actionName = "added";
} else {
- await this.storageArea.remove(pageUrl);
+ await this.storageArea.remove(assignmentHost);
actionName = "removed";
}
- browser.tabs.sendMessage(tabId, {
- text: `Successfully ${actionName} site to always open in this container`
- });
+ if (!options || !options.silent) {
+ browser.tabs.sendMessage(tabId, {
+ text: `Successfully ${actionName} site to always open in this container`
+ });
+ }
const tab = await browser.tabs.get(tabId);
this.calculateContextMenu(tab);
},
+
+ async _setOrRemoveWildcard(tabId, pageUrl, userContextId, wildcard) {
+ // Remove assignment
+ await this._setOrRemoveAssignment(tabId, pageUrl, userContextId, true, {silent:true});
+ // Add assignment
+ await this._setOrRemoveAssignment(tabId, pageUrl, userContextId, false, {wildcard:wildcard, silent:true});
+ },
async _getAssignment(tab) {
const cookieStore = this.getUserContextIdFromCookieStore(tab);
// Ensure we have a cookieStore to assign to
if (cookieStore
&& this.isTabPermittedAssign(tab)) {
- return await this.storageArea.get(tab.url);
+ return await this.storageArea.matchUrl(tab.url);
}
return false;
},
diff --git a/src/js/background/messageHandler.js b/src/js/background/messageHandler.js
index 9fbe88e..5017807 100644
--- a/src/js/background/messageHandler.js
+++ b/src/js/background/messageHandler.js
@@ -34,6 +34,12 @@ const messageHandler = {
return assignManager._setOrRemoveAssignment(tab.id, m.url, m.userContextId, m.value);
});
break;
+ case "setOrRemoveWildcard":
+ // Wildcard subdomains: https://github.com/mozilla/multi-account-containers/issues/473
+ response = browser.tabs.get(m.tabId).then((tab) => {
+ return assignManager._setOrRemoveWildcard(tab.id, m.url, m.userContextId, m.wildcard);
+ });
+ break;
case "sortTabs":
backgroundLogic.sortTabs();
break;
diff --git a/src/js/popup.js b/src/js/popup.js
index a672017..fb70f50 100644
--- a/src/js/popup.js
+++ b/src/js/popup.js
@@ -361,6 +361,17 @@ const Logic = {
});
},
+ // Wildcard subdomains: https://github.com/mozilla/multi-account-containers/issues/473
+ setOrRemoveWildcard(tabId, url, userContextId, wildcard) {
+ return browser.runtime.sendMessage({
+ method: "setOrRemoveWildcard",
+ tabId,
+ url,
+ userContextId,
+ wildcard
+ });
+ },
+
generateIdentityName() {
const defaultName = "Container #";
const ids = [];
@@ -381,7 +392,7 @@ const Logic = {
return defaultName + (id < 10 ? "0" : "") + id;
}
}
- },
+ }
};
// P_ONBOARDING_1: First page for Onboarding.
@@ -985,18 +996,40 @@ Logic.registerPanel(P_CONTAINER_EDIT, {
const trElement = document.createElement("div");
/* As we don't have the full or correct path the best we can assume is the path is HTTPS and then replace with a broken icon later if it doesn't load.
This is pending a better solution for favicons from web extensions */
- const assumedUrl = `https://${site.hostname}`;
+ const assumedUrl = `https://${site.host}`;
trElement.innerHTML = escaped`
-