Recording - automatically add sites to containers as you browse

This commit is contained in:
Francis McKenzie 2020-01-06 20:49:01 +01:00
parent dc9e8f6399
commit 30a2601d35
17 changed files with 1370 additions and 97 deletions

View file

@ -1,4 +1,72 @@
.container-notification {
#container-notifications,
#container-notifications * {
all: unset;
}
#container-notifications {
display: block;
inline-size: 100vw;
inset-block-start: 0; /* stylelint-disable-line property-no-unknown */
inset-inline-start: 0; /* stylelint-disable-line property-no-unknown */
margin-block-end: 0;
margin-block-start: 0;
margin-inline-end: 0;
margin-inline-start: 0;
offset-block-start: 0;
offset-inline-start: 0;
padding-block-end: 0;
padding-block-start: 0;
padding-inline-end: 0;
padding-inline-start: 0;
position: fixed;
z-index: 999999999999;
}
#container-notifications > iframe {
border: 1px solid;
inset-block-start: 4px; /* stylelint-disable-line property-no-unknown */
inset-inline-end: 4px; /* stylelint-disable-line property-no-unknown */
offset-block-start: 4px;
offset-inline-end: 4px;
position: absolute;
z-index: 2;
}
#container-notifications > div.recording {
z-index: 1;
}
#container-notifications > div {
display: block;
max-block-size: 0;
overflow: hidden;
position: relative;
transition: all 1s cubic-bezier(0.07, 0.95, 0, 1);
}
#container-notifications > div.show {
max-block-size: 500px;
transition: all 1s ease-in;
}
#container-notifications > div:hover,
#container-notifications > div:focus,
#container-notifications > div:visited {
color: #003f07;
text-decoration: none;
}
#container-notifications > div > div.real {
inset-block-end: 0; /* stylelint-disable-line property-no-unknown */
offset-block-end: 0;
position: absolute;
}
#container-notifications > div > div.dummy {
visibility: hidden;
}
#container-notifications > div > div > div {
align-items: center;
background: #efefef;
color: #003f07;
@ -6,22 +74,34 @@
font: 12px sans-serif;
inline-size: 100vw;
justify-content: start;
offset-block-start: 0;
offset-inline-start: 0;
margin-block-end: 0;
margin-block-start: 0;
margin-inline-end: 0;
margin-inline-start: 0;
padding-block-end: 8px;
padding-block-start: 8px;
padding-inline-end: 8px;
padding-inline-start: 8px;
position: fixed;
text-align: start;
transform: translateY(-100%);
transition: transform 0.3s cubic-bezier(0.07, 0.95, 0, 1) 0.3s;
z-index: 999999999999;
}
.container-notification img {
#container-notifications > div > div > div > .title {
font-weight: bold;
padding-left: 0.5rem;
padding-right: 1rem;
}
#container-notifications > div > div > div > .logo {
block-size: 16px;
display: inline-block;
inline-size: 16px;
margin-inline-end: 3px;
}
#container-notifications > div.recording > div > div {
background: #fcc;
}
#container-notifications > div.recording > div > div > .title {
color: red;
}

View file

@ -18,6 +18,7 @@ html {
}
body {
display: flex;
font-family: Roboto, Noto, "San Francisco", Ubuntu, "Segoe UI", "Fira Sans", message-box, Arial, sans-serif;
inline-size: calc(var(--overflow-size) + 299px);
max-inline-size: calc(var(--overflow-size) + 299px);
@ -246,6 +247,7 @@ table {
/* Panels keep everything together */
.panel {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
min-block-size: 400px;
@ -451,7 +453,9 @@ manage things like container crud */
}
.container-panel-controls {
display: flex;
display: grid;
grid-auto-flow: column;
grid-column-gap: var(--inline-item-space-size);
justify-content: flex-end;
margin-block-end: var(--block-line-space-size);
margin-block-start: var(--block-line-space-size);
@ -459,24 +463,51 @@ manage things like container crud */
margin-inline-start: var(--inline-item-space-size);
}
#container-panel #sort-containers-link {
#container-panel .container-panel-controls > * {
align-items: center;
block-size: var(--block-url-label-size);
border: 1px solid #d8d8d8;
border-radius: var(--small-radius);
color: var(--title-text-color);
display: flex;
flex-direction: column;
flex-wrap: wrap;
font-size: var(--small-text-size);
inline-size: var(--inline-button-size);
justify-content: center;
text-decoration: none;
}
#container-panel #sort-containers-link:hover,
#container-panel #sort-containers-link:focus {
#container-panel .container-panel-controls > a:hover,
#container-panel .container-panel-controls > a:focus,
#container-panel .container-panel-controls > .disabled {
background: #f2f2f2;
}
#container-panel .container-panel-controls > #record-link {
inline-size: var(--block-url-label-size);
}
.container-panel-controls > #record-link > .icon {
margin-block-end: 4px;
margin-block-start: 4px;
margin-inline-end: 4px;
margin-inline-start: 4px;
}
#record-link > .icon {
filter: invert(0.2);
}
#record-link.disabled > .icon {
filter: invert(0.6);
}
#record-link.active > .icon,
.container-record-banner img {
filter: invert(0.5) sepia(1) saturate(127) hue-rotate(360deg);
}
span ~ .panel-header-text {
padding-block-end: 0;
padding-block-start: 0;
@ -674,7 +705,8 @@ span ~ .panel-header-text {
inline-size: calc(var(--column-panel-inline-size) - 58px);
}
#container-info-hideorshow {
#container-info-hideorshow,
#container-record-banner {
margin-block-start: 4px;
}
@ -704,7 +736,8 @@ span ~ .panel-header-text {
}
.container-info-has-tabs,
.container-info-tab-row {
.container-info-tab-row,
.container-record-banner {
align-items: center;
display: flex;
flex: 0 0 28px;
@ -718,13 +751,25 @@ span ~ .panel-header-text {
padding-inline-start: 16px;
}
.container-record-banner {
background: #fcc;
color: red;
}
.container-info-has-tabs img,
.container-info-tab-row img {
.container-info-tab-row img,
.container-record-banner img {
block-size: 16px;
flex: 0 0 16px;
margin-inline-end: 4px;
}
.container-record-banner img {
block-size: 24px;
flex: 0 0 24px;
margin-inline-end: 6px;
}
.container-info-tab-row img[src=""] {
margin-inline-end: 0;
}
@ -749,7 +794,9 @@ span ~ .panel-header-text {
background-color: #ebebeb;
}
.edit-containers-exit-text {
.edit-containers-exit-text,
.container-record-exit-text,
.container-record-banner-text {
align-items: center;
background: var(--primary-action-color);
block-size: 100%;
@ -760,11 +807,13 @@ span ~ .panel-header-text {
padding-inline-start: 30%;
}
.edit-containers-panel-footer {
.edit-containers-panel-footer,
.container-record-panel-footer {
background: var(--primary-action-color);
}
.exit-edit-mode-link img {
.exit-edit-mode-link img,
.exit-record-mode-link img {
block-size: 16px;
display: inline;
filter: grayscale(100%) brightness(5);
@ -797,11 +846,13 @@ span ~ .panel-header-text {
overflow: hidden; /* Bugfix: issue 948 */
}
#edit-sites-assigned {
#edit-sites-assigned,
#record-sites-assigned {
flex: 1000; /* Bugfix: issue 948 */
}
#edit-sites-assigned h3 {
#edit-sites-assigned h3,
#record-sites-assigned h3 {
font-size: 14px;
font-weight: normal;
padding-block-end: 6px;

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<path d="M21 6.5l-4 4V7c0-.55-.45-1-1-1H9.82L21 17.18V6.5zM3.27 2L2 3.27 4.73 6H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.21 0 .39-.08.54-.18L19.73 21 21 19.73 3.27 2z"/>
</svg>

After

Width:  |  Height:  |  Size: 414 B

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/>
</svg>

After

Width:  |  Height:  |  Size: 358 B

View file

@ -3,10 +3,12 @@ module.exports = {
"../../.eslintrc.js"
],
"globals": {
"recordManager": "readonly",
"assignManager": true,
"badge": true,
"backgroundLogic": true,
"identityState": true,
"messageHandler": true
"messageHandler": true,
"browserAPIInjector": "readonly"
}
};

View file

@ -116,6 +116,13 @@ const assignManager = {
this.storageArea.setExempted(pageUrl, m.tabId);
return true;
},
_determineSetAssignmentDueToRecording(tabId, url, siteSettings) {
if (siteSettings) { return false; } // Assignment already set
if (!recordManager.isRecordingTabId(tabId)) { return false; }
if (!url.startsWith("http")) { return false; } // Exclude moz-extension:// requests
return true;
},
// Before a request is handled by the browser we decide if we should route through a different container
async onBeforeRequest(options) {
@ -141,6 +148,12 @@ const assignManager = {
return {};
}
const userContextId = this.getUserContextIdFromCookieStore(tab);
// Recording
if (this._determineSetAssignmentDueToRecording(tab.id, options.url, siteSettings)) {
await this._setOrRemoveAssignment(tab.id, options.url, userContextId, false);
}
if (!siteSettings
|| userContextId === siteSettings.userContextId
|| this.storageArea.isExempted(options.url, tab.id)) {
@ -374,7 +387,7 @@ const assignManager = {
// 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);
@ -394,18 +407,20 @@ const assignManager = {
userContextId,
neverAsk: false
}, exemptedTabIds);
actionName = "added";
actionName = "Successfully set to always open in this container";
} else {
await this.storageArea.remove(pageUrl);
actionName = "removed";
actionName = "Successfully removed from this container";
}
browser.tabs.sendMessage(tabId, {
text: `Successfully ${actionName} site to always open in this container`
const hostname = new window.URL(pageUrl).hostname;
messageHandler.sendTabMessage(tabId, {
title: hostname,
text: actionName
});
const tab = await browser.tabs.get(tabId);
this.calculateContextMenu(tab);
},
async _getAssignment(tab) {
const cookieStore = this.getUserContextIdFromCookieStore(tab);
// Ensure we have a cookieStore to assign to
@ -415,11 +430,11 @@ const assignManager = {
}
return false;
},
_getByContainer(userContextId) {
return this.storageArea.getByContainer(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

View file

@ -7,7 +7,7 @@ const backgroundLogic = {
"about:blank"
]),
unhideQueue: [],
async getExtensionInfo() {
const manifestPath = browser.extension.getURL("manifest.json");
const response = await fetch(manifestPath);
@ -93,6 +93,29 @@ const backgroundLogic = {
}
});
},
asPromise(value) {
if (value === undefined) { return value; }
if (value instanceof Promise) { return value; }
return Promise.resolve(value);
},
asTabId(tabId) {
if (tabId === undefined || tabId === null) {
return browser.tabs.TAB_ID_NONE;
}
return tabId;
},
async getTabOrNull(tabId) {
tabId = this.asTabId(tabId);
if (tabId !== browser.tabs.TAB_ID_NONE) {
try {
return await browser.tabs.get(tabId);
} catch(e) { /* Assume tabId is invalid */ }
}
return null;
},
async getTabs(options) {
const requiredArguments = ["cookieStoreId", "windowId"];
@ -329,5 +352,23 @@ const backgroundLogic = {
cookieStoreId(userContextId) {
return `firefox-container-${userContextId}`;
},
async invokeBrowserMethod(name, args) {
let target = browser;
let indexOfDot;
while ((indexOfDot = name.indexOf(".")) !== -1) {
const targetName = name.substring(0, indexOfDot);
target = target[targetName];
name = name.substring(indexOfDot + 1);
}
const method = target[name];
let returnValue;
if (typeof method === "function" || (args && args.length > 0)) {
returnValue = method(...args);
} else {
returnValue = method;
}
return returnValue;
}
};
};

View file

@ -14,6 +14,7 @@
]
-->
<script type="text/javascript" src="backgroundLogic.js"></script>
<script type="text/javascript" src="recordManager.js"></script>
<script type="text/javascript" src="assignManager.js"></script>
<script type="text/javascript" src="badge.js"></script>
<script type="text/javascript" src="identityState.js"></script>

View file

@ -3,7 +3,7 @@ const messageHandler = {
// 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((m) => {
@ -37,6 +37,12 @@ const messageHandler = {
return assignManager._setOrRemoveAssignment(tab.id, m.url, m.userContextId, m.value);
});
break;
case "getRecording":
response = backgroundLogic.asPromise(recordManager.getTabId());
break;
case "setOrRemoveRecording":
response = recordManager.setTabId(m.tabId);
break;
case "sortTabs":
backgroundLogic.sortTabs();
break;
@ -70,10 +76,17 @@ const messageHandler = {
case "exemptContainerAssignment":
response = assignManager._exemptTab(m);
break;
case "invokeBrowserMethod":
response = backgroundLogic.asPromise(backgroundLogic.invokeBrowserMethod(m.name, m.args));
break;
}
return response;
});
// Monitor browserAction popup
this.browserAction.init();
// Handles external messages from webextensions
const externalExtensionAllowed = {};
browser.runtime.onMessageExternal.addListener(async (message, sender) => {
@ -213,6 +226,147 @@ const messageHandler = {
}).catch((e) => {
throw e;
});
},
/**
Sends a message to a tab, with following benefits:
1. Waits until sending AND animating is fully complete
2. Keeps retrying until succeeds (or too many attempts)
3. Resends message if tab reloaded while sending/animating
4. Stops without error if tab closed while sending/animating
*/
SendTabMessage: class {
constructor(tabId, message) {
this.tabId = tabId;
this.message = message;
}
async send() {
const message = { to:"tab", content:this.message };
const MAX_ATTEMPTS = 5;
let attempts = 0;
let succeeded = false;
do {
try {
if (this.tabLoading) { await this.tabLoading.promise; }
if (this.tabRemoved) { break; }
await browser.tabs.sendMessage(this.tabId, message);
succeeded = true;
} catch (e) {
if (this.tabRemoved) { break; }
if (attempts >= MAX_ATTEMPTS) { throw e; }
attempts++;
await new Promise((resolve) => {
setTimeout(resolve, 1000);
});
}
} while (!succeeded);
}
handleTabChangedStatus(status) {
if (status === "loading") {
if (!this.tabLoading) {
this.tabLoading = {};
this.tabLoading.promise = new Promise((resolve) => {
this.tabLoading.resolve = resolve;
});
}
} else {
if (this.tabLoading) {
this.tabLoading.resolve();
this.tabLoading = null;
}
}
}
handleTabRemoved() {
this.tabRemoved = true;
this.removeTabListeners();
this.handleTabChangedStatus("complete");
}
addTabListeners() {
this.onTabsUpdated = (eventTabId, info) => {
if (this.tabId === eventTabId) {
this.handleTabChangedStatus(info.status);
}
};
this.onTabsRemoved = (eventTabId) => {
if (this.tabId === eventTabId) {
this.handleTabRemoved();
}
};
browser.tabs.onUpdated.addListener(this.onTabsUpdated, { tabId: this.tabId, properties:["status"] });
browser.tabs.onRemoved.addListener(this.onTabsRemoved);
}
removeTabListeners() {
browser.tabs.onUpdated.removeListener(this.onTabsUpdated);
browser.tabs.onRemoved.removeListener(this.onTabsRemoved);
}
},
async sendTabMessage(tabId, message) {
const tab = await backgroundLogic.getTabOrNull(tabId);
if (!tab || tab.id === browser.tabs.TAB_ID_NONE) { throw new Error(`Cannot send message to tab ${tabId}`); }
const sendMessage = new this.SendTabMessage(tabId, message);
sendMessage.addTabListeners();
try {
await sendMessage.send();
} catch (e) {
console.log(`Send Message Failed: ${e} ${tab.url}`);
throw e;
} finally {
sendMessage.removeTabListeners();
}
},
// Holds current browserAction popup state, dispatches events
browserAction: {
init() {
browser.runtime.onConnect.addListener((port) => {
if (port.name === "browserActionPopup") {
this.onLoad(port);
}
});
browser.windows.onFocusChanged.addListener((windowId) => {
this.currentWindowId = windowId;
});
},
onLoad(port) {
// Note a new connection can arrive before existing connection is disconnected.
// Happens when you click on the browserAction button on two different windows
if (this.popup) { this.onUnload(); }
const popup = this.popup = { windowId: this.currentWindowId };
port.onDisconnect.addListener(() => {
if (this.popup === popup) {
this.onUnload();
this.popup = null;
}
});
port.onMessage.addListener((msg) => {
if ("update" in msg) {
this.onUpdate(popup, msg.update);
}
});
window.dispatchEvent(new Event("BrowserActionPopupLoad"));
},
onUnload() {
window.dispatchEvent(new Event("BrowserActionPopupUnload"));
},
onUpdate(popup, update) {
if (update.width === 0) { delete update.width; }
if (update.height === 0) { delete update.height; }
Object.assign(popup, update);
}
}
};

View file

@ -0,0 +1,177 @@
const recordManager = {
recording: null,
listening: null,
Recording: class {
constructor(tab) {
if (tab) {
this.windowId = tab.windowId;
this.tabId = tab.id;
this.isTabActive = tab.active;
} else {
this.windowId = browser.windows.WINDOW_ID_NONE;
this.tabId = browser.tabs.TAB_ID_NONE;
this.isTabActive = false;
}
}
get valid() {
return this.tabId !== browser.tabs.TAB_ID_NONE;
}
async sendTabMessage() {
return messageHandler.sendTabMessage(this.tabId, this.tabMessage);
}
async stop() {
if (!this.valid) { return; }
recordManager.listening.enabled = false;
// Update GUI
this.tabMessage = { recording: false, popup: false };
const tab = await backgroundLogic.getTabOrNull(this.tabId);
// Don't try to send "stop recording" message to tab if already closed or showing an invalid page
if (tab && tab.url) {
return this.sendTabMessage();
}
}
async start() {
if (!this.valid) { return; }
recordManager.listening.enabled = true;
// Update GUI
const baPopup = messageHandler.browserAction.popup;
const tabPopup = this.isTabActive && (!baPopup || baPopup.windowId !== this.windowId);
this.tabMessage = { recording: true, popup: tabPopup, popupOptions: {tabId: this.tabId} };
const showingPage = browser.tabs.update(this.tabId, { url: browser.runtime.getURL("/recording.html") });
const messagingTab = this.sendTabMessage();
return Promise.all([showingPage, messagingTab]);
}
// Re-show recording state on page load
onTabsUpdated(tabId, changeInfo) {
if (this.tabId === tabId && changeInfo.status === "complete") {
this.sendTabMessage();
}
}
// Show/hide tabPopup on this tab show/hide
onTabsActivated(activeInfo) {
if (this.tabId === activeInfo.tabId) {
this.sendTabMessage();
}
}
// Keep track of tab's windowId
onTabsAttached(tabId, attachInfo) {
if (this.tabId === tabId) {
this.windowId = attachInfo.newWindowId;
}
}
// Stop recording on close
onTabsRemoved(tabId) {
if (this.tabId === tabId) {
recordManager.setTabId(browser.tabs.TAB_ID_NONE);
}
}
// Show/hide tabPopup on hide/show browserActionPopup
onToggleBrowserActionPopup(baPopupVisible, baPopup) {
if (this.windowId === baPopup.windowId && this.isTabActive) {
this.tabMessage.popup = !baPopupVisible;
this.tabMessage.popupOptions = { tabId:this.tabId, width:baPopup.width, height:baPopup.height };
this.sendTabMessage();
}
}
},
Listening: class {
constructor() {
this._enabled = false;
}
get enabled() { return this._enabled; }
set enabled(enabled) {
if (this._enabled === !!enabled) { return; }
this._enabled = !!enabled;
if (enabled) {
browser.tabs.onUpdated.addListener(this.onTabsUpdated, { properties: ["status"] });
browser.tabs.onActivated.addListener(this.onTabsActivated);
browser.tabs.onAttached.addListener(this.onTabsAttached);
browser.tabs.onRemoved.addListener(this.onTabsRemoved);
window.addEventListener("BrowserActionPopupLoad", this.onBrowserActionPopupLoad);
window.addEventListener("BrowserActionPopupUnload", this.onBrowserActionPopupUnload);
} else {
browser.tabs.onUpdated.removeListener(this.onTabsUpdated);
browser.tabs.onActivated.removeListener(this.onTabsActivated);
browser.tabs.onAttached.removeListener(this.onTabsAttached);
browser.tabs.onRemoved.removeListener(this.onTabsRemoved);
window.removeEventListener("BrowserActionPopupLoad", this.onBrowserActionPopupLoad);
window.removeEventListener("BrowserActionPopupUnload", this.onBrowserActionPopupUnload);
}
}
onTabsUpdated(...args) { recordManager.recording.onTabsUpdated(...args); }
onTabsActivated(...args) { recordManager.recording.onTabsActivated(...args); }
onTabsAttached(...args) { recordManager.recording.onTabsAttached(...args); }
onTabsRemoved(...args) { recordManager.recording.onTabsRemoved(...args); }
onBrowserActionPopupLoad() { recordManager.recording.onToggleBrowserActionPopup(true, messageHandler.browserAction.popup); }
onBrowserActionPopupUnload() { recordManager.recording.onToggleBrowserActionPopup(false, messageHandler.browserAction.popup); }
},
init() {
this.recording = new recordManager.Recording();
this.listening = new recordManager.Listening();
},
isRecordingTabId(tabId) {
if (!this.recording.valid) { return false; }
if (this.recording.tabId !== tabId) { return false; }
return true;
},
getTabId() {
return this.recording.tabId;
},
async setTabId(tabId) {
// Ensure tab is recordable
tabId = backgroundLogic.asTabId(tabId);
const tab = await backgroundLogic.getTabOrNull(tabId);
const wantRecordableTab = tabId !== browser.tabs.TAB_ID_NONE;
const isRecordableTab = tab && "cookieStoreId" in tab;
// Invalid tab - stop recording & throw error
if (wantRecordableTab && !isRecordableTab) {
this.setTabId(browser.tabs.TAB_ID_NONE); // Don't wait for stop
throw new Error(`Recording not possible for tab with id ${tabId}`);
}
// Already recording
if (this.recording.tabId === tabId) { return; }
const oldRecording = this.recording;
const newRecording = this.recording = new recordManager.Recording(tab);
// Don't wait for stop
oldRecording.stop();
try {
// But DO wait for start
await newRecording.start();
// If error while starting, immediately stop, but don't wait
} catch (e) {
this.setTabId(browser.tabs.TAB_ID_NONE);
throw e;
}
}
};
recordManager.init();

View file

@ -1,46 +1,395 @@
async function delayAnimation(delay = 350) {
return new Promise((resolve) => {
setTimeout(resolve, delay);
});
function asError(reason) { return reason && (reason instanceof Error) ? reason : new Error(reason); }
function resolves(value) { return (resolve) => { resolve(value); }; }
// function rejects(reason) { return (resolve, reject) => { reject(asError(reason)); }; }
// Easily build promises that:
// 1. combine reusable behaviours (e.g. onTimeout, onEvent)
// 2. have a cleanup phase (e.g. to remove listeners)
// 3. can be interrupted (e.g. on unload)
class PromiseBuilder {
constructor() {
this._promise = Promise.race([
// Interrupter
new Promise((resolve, reject) => { this.interrupt = reject; }),
// Main
new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = (reason, options) => {
(options && options.interrupt ? this.interrupt : reject)(asError(reason));
};
// Cleanup
}).finally(() => { if (this.completions) { this.completions.forEach((completion) => { completion(); }); } })
]);
}
async _tryHandler(handler, name, ...args) {
try {
await handler(...args);
} catch (e) {
console.error(`Failed: ${name}: ${e.message}`);
this.reject(e);
}
}
promise(handler) {
if (handler) { this._tryHandler(handler, "promise", this); }
return this._promise;
}
onCompletion(completion) {
if (!this.completions) { this.completions = []; }
this.completions.push(completion);
return this;
}
onTimeout(delay, timeoutHandler) {
const timer = () => { this._tryHandler(timeoutHandler, "timeout", this.resolve, this.reject); };
let timeoutId = setTimeout(() => { timeoutId = null; timer(); }, delay);
this.onCompletion(() => { clearTimeout(timeoutId); });
return this;
}
onFutureEvent(target, eventName, eventHandler) {
const listener = (event) => { this._tryHandler(eventHandler, eventName, this.resolve, this.reject, event); };
target.addEventListener(eventName, listener, {once: true});
this.onCompletion(() => { target.removeEventListener(eventName, listener); });
return this;
}
onEvent(target, eventName, eventHandler) {
if (target === window) {
eventName = eventName.toLowerCase();
if (eventName === "domcontentloaded" || eventName === "load") {
switch (document.readyState) {
case "loading": break;
case "interactive":
if (eventName === "load") { break; }
// Fall through
case "complete":
// Event already fired - run immediately
this._tryHandler(eventHandler, eventName, this.resolve, this.reject);
return this;
}
}
}
this.onFutureEvent(target, eventName, eventHandler);
return this;
}
}
async function doAnimation(element, property, value) {
return new Promise((resolve) => {
const handler = () => {
resolve();
element.removeEventListener("transitionend", handler);
class Animation {
static delay(delay = 350) {
return new Promise((resolve) => { setTimeout(resolve, delay); });
}
static async toggle(element, show, timeoutDelay = 3000) {
const shown = element.classList.contains("show");
if (shown === !!show) { return; }
const animate = () => {
if (show) {
if (!element.classList.contains("show")) {
element.classList.add("show");
}
} else {
element.classList.remove("show");
}
};
element.addEventListener("transitionend", handler);
window.requestAnimationFrame(() => {
element.style[property] = value;
});
});
return new PromiseBuilder()
.onTimeout(timeoutDelay, resolves())
.onEvent(element, "transitionend", resolves())
.promise((promise) => {
// Delay until element has been rendered
requestAnimationFrame(() => {
setTimeout(() => {
animate();
}, 10);
});
// Ensure animation always reaches final state
promise.onCompletion(animate);
});
}
}
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;
class UIRequest {
constructor (component, action, options, response) {
this.component = component;
this.action = action;
this.options = options;
this.response = response || new UIResponse();
}
}
const imageElement = document.createElement("img");
const imagePath = browser.extension.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);
class UIResponse {
constructor (value) {
let promise;
if (value instanceof Promise) { promise = value; }
if (value !== undefined) { promise = Promise.resolve(value); }
this.modifyingDOM = this.animating = promise;
}
}
document.body.appendChild(divElement);
let requests;
await delayAnimation(100);
await doAnimation(divElement, "transform", "translateY(0)");
await delayAnimation(3000);
await doAnimation(divElement, "transform", "translateY(-100%)");
class UIRequestManager {
static request(component, action, options) {
// Try for quick return
if (component.unique) {
const previous = requests && requests[component.name];
divElement.remove();
// Quick return if request already enqueued
if (previous && previous.action === action) {
// Previous request is also an add, but we've got an extra update to do as well
if (action === "add" && component.onUpdate && options) {
return new UIResponse(previous.response.animating.then((elem) => {
const updating = component.onUpdate(elem, options);
return updating ? updating.then(elem) : elem;
}));
// No update needed, so can just reuse previous request
} else {
return previous.response;
}
}
// Quick return if no request pending and element already added/removed
if (!previous) {
const element = this._get(component);
if (element) {
if (action === "add") { return new UIResponse(element); }
} else {
if (action === "remove") { return new UIResponse(null); }
}
}
}
// New request
const response = new UIResponse();
const request = new UIRequest(component, action, options, response);
// Enqueue
let previous;
if (component.unique) {
if (!requests) { requests = {}; }
previous = requests[component.name];
requests[component.name] = request;
}
// Execute
response.modifyingDOM = new Promise((resolve,reject) => {
const modifiedDOM = {resolve,reject};
response.animating = new Promise((resolve,reject) => {
const animated = {resolve,reject};
this._execute(request, previous, modifiedDOM, animated);
});
});
return response;
}
static _get(component) {
const unique = component.unique;
if (!unique) { return null; }
if (unique.id) {
return document.getElementById(unique.id);
} else {
if ("querySelector" in component.parent) {
return component.parent.querySelector(unique.selector);
} else {
const parent = this._get(component.parent);
if (parent) {
return parent.querySelector(unique.selector);
} else {
return null;
}
}
}
}
static async _execute(request, previous, modifiedDOM, animated) {
try {
if (previous) {
try { await previous.response.animating; } catch (e) { /* Ignore previous success/failure */ }
}
const component = request.component;
const options = request.options;
// Get parent
let parentElement;
if ("querySelector" in component.parent) {
parentElement = component.parent;
} else {
if (request.action === "add") {
parentElement = await this.request(component.parent, "add", options).modifyingDOM;
} else {
parentElement = this._get(component.parent);
}
}
let element;
// Add
if (request.action === "add") {
element = await component.create(options);
if (component.onUpdate) { await component.onUpdate(element, options); }
if (component.prepend) {
parentElement.prepend(element);
} else {
parentElement.appendChild(element);
}
modifiedDOM.resolve(element);
if (component.onAdd) { await component.onAdd(element, options); }
// Remove
} else {
if (parentElement) {
element = this._get(component);
if (element) {
if (component.onRemove) { await component.onRemove(element, options); }
element.remove();
}
modifiedDOM.resolve(element);
}
}
animated.resolve(element);
} catch (e) {
modifiedDOM.reject(e);
animated.reject(e);
} finally {
if (requests[request.component.name] === request) { requests[request.component.name] = null; }
}
}
}
class UI {
static async toggle(component, show, options) {
const action = show ? "add" : "remove";
const response = UIRequestManager.request(component, action, options);
return response.animating;
}
}
class Container {
static get parent() { return document.body; }
static get unique() { return { id: "container-notifications" }; }
static create() {
const elem = document.createElement("div");
elem.id = this.unique.id;
return elem;
}
}
class Popup {
static get parent() { return Container; }
static get unique() { return { selector: "iframe" }; }
static get prepend() { return true; }
static create(options) {
const elem = document.createElement("iframe");
elem.setAttribute("sandbox", "allow-scripts allow-same-origin");
elem.src = browser.runtime.getURL("/popup.html") + "?tabId=" + options.tabId;
return elem;
}
static onUpdate(elem, options) {
if (!options) { return; }
if (options.width) {
const width = options.width;
const height = options.height || 400;
elem.style.width = `${width}px`;
elem.style.height = `${height}px`;
}
}
}
class Recording {
static get parent() { return Container; }
static get unique() { return { selector: ".recording" }; }
static get prepend() { return true; }
static async create() {
const elem = await Message.create({
title: "Recording",
text: "Sites will be automatically added to this container as you browse in this tab"
});
elem.classList.add("recording");
return elem;
}
static onAdd(elem) { return Animation.toggle(elem, true); }
static onRemove(elem) { return Animation.toggle(elem, false); }
}
class Message {
static get parent() { return Container; }
static async create(options) {
// Message
const msgElem = document.createElement("div");
// Text
// Ideally we would use https://bugzilla.mozilla.org/show_bug.cgi?id=1340930 when this is available
msgElem.innerText = options.text;
// Title
if (options.title) {
const titleElem = document.createElement("span");
titleElem.classList.add("title");
titleElem.innerText = options.title;
msgElem.prepend(titleElem);
}
// Icon
const imageElem = document.createElement("div");
const imagePath = browser.extension.getURL("/img/container-site-d-24.png");
imageElem.style.background = `url("${imagePath}") no-repeat center center / cover`;
imageElem.classList.add("logo");
msgElem.prepend(imageElem);
// Real/dummy wrappers (required for stacking & sliding animations)
const dummyElem = document.createElement("div");
dummyElem.appendChild(msgElem);
const realElem = document.importNode(dummyElem, true); // Clone
dummyElem.classList.add("dummy"); // For sizing
realElem.classList.add("real"); // For display
// Outer container
const elem = document.createElement("div");
elem.appendChild(dummyElem);
elem.appendChild(realElem);
return elem;
}
static async onAdd(elem) {
await Animation.toggle(elem, true);
await Animation.delay(3000);
await Animation.toggle(elem, false);
elem.remove();
}
}
class Messages {
static async handle(message) {
let animatePopup, animateRecording, animateMessage;
if ("popup" in message) { animatePopup = UI.toggle(Popup, message.popup, message.popupOptions); }
if ("recording" in message) { animateRecording = UI.toggle(Recording, message.recording); }
if ("text" in message) { animateMessage = UI.toggle(Message, true, message); }
await Promise.all([animatePopup, animateRecording, animateMessage]);
}
static async add(message) {
return new PromiseBuilder()
.onEvent(window, "unload", (resolve, reject) => { reject("window unload", {interrupt: true}); })
.onEvent(window, "DOMContentLoaded", (resolve) => { resolve(this.handle(message)); })
.promise();
}
}
browser.runtime.onMessage.addListener((message) => {
addMessage(message);
if (message.to === "tab") {
return Messages.add(message.content);
}
});

58
src/js/popup-bootstrap.js Normal file
View file

@ -0,0 +1,58 @@
/**
Some of the Web Extension API (e.g. tabs, contextualIdentities) is unavailable
if popup is hosted in an iframe on a web page. So must forward those calls
to (privileged) background script, so that popup can be run in an iframe.
*/
const browserAPIInjector = { // eslint-disable-line no-unused-vars
async injectAPI() {
await this.injectMethods([
"tabs.get",
"tabs.query",
"contextualIdentities.query",
"contextualIdentities.get"
]);
await this.injectConstants([
"tabs.TAB_ID_NONE",
"windows.WINDOW_ID_CURRENT"
]);
await this.injectUnimplemented([
"tabs.onUpdated.addListener",
"tabs.onUpdated.removeListener"
]);
},
injectMethods(keys) { return this.inject(keys, "method"); },
injectConstants(keys) { return this.inject(keys, "constant"); },
injectUnimplemented(keys) { return this.inject(keys, "unimplemented"); },
async inject(keys, type) {
return Promise.all(keys.map(async (key) => {
const [object, property] = this.getComponents(key);
if (!(property in object)) {
if (type === "constant") {
object[property] = await this.invokeBrowserMethod(key);
} else if (type === "unimplemented") {
object[property] = () => {};
} else {
object[property] = async (...args) => { return this.invokeBrowserMethod(key, args); };
}
}
}));
},
getComponents(key) {
let object = browser;
let indexOfDot;
while ((indexOfDot = key.indexOf(".")) !== -1) {
const property = key.substring(0, indexOfDot);
if (!(property in object)) { object[property] = {}; }
object = object[property];
key = key.substring(indexOfDot + 1);
}
return [object, key];
},
async invokeBrowserMethod(name, args) {
return browser.runtime.sendMessage({ method:"invokeBrowserMethod", name, args });
}
};

View file

@ -4,6 +4,8 @@
const CONTAINER_HIDE_SRC = "/img/container-hide.svg";
const CONTAINER_UNHIDE_SRC = "/img/container-unhide.svg";
const CONTAINER_RECORD_ENABLED_SRC = "/img/container-record-enabled.svg";
const CONTAINER_RECORD_DISABLED_SRC = "/img/container-record-disabled.svg";
const DEFAULT_COLOR = "blue";
const DEFAULT_ICON = "circle";
@ -22,6 +24,7 @@ const P_CONTAINERS_EDIT = "containersEdit";
const P_CONTAINER_INFO = "containerInfo";
const P_CONTAINER_EDIT = "containerEdit";
const P_CONTAINER_DELETE = "containerDelete";
const P_CONTAINER_RECORD = "containerRecord";
const P_CONTAINERS_ACHIEVEMENT = "containersAchievement";
/**
@ -67,6 +70,25 @@ async function getExtensionInfo() {
return extensionInfo;
}
// Determine where this popup is hosted - browserAction / iframe in a tab
const Env = {
init() {
this.hasFullBrowserAPI = !!browser.tabs;
const params = new URLSearchParams(window.location.search);
const tabId = params.get("tabId");
if (tabId !== null) {
this.tabId = parseInt(tabId, 10);
this.isBrowserActionPopup = false;
} else {
this.tabId = null;
this.isBrowserActionPopup = this.hasFullBrowserAPI;
}
}
};
Env.init();
// This object controls all the panels, identities and many other things.
const Logic = {
_identities: [],
@ -77,52 +99,62 @@ const Logic = {
_onboardingVariation: null,
async init() {
// Remove browserAction "upgraded" badge when opening panel
this.clearBrowserActionBadge();
// Running in an iframe on a webpage - inject missing API methods
if (!Env.hasFullBrowserAPI) {
await this.injectAPI();
}
// API methods are ready, can continue with init
const initializingPanels = this.initializePanels();
// 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);
}
// Remove browserAction "upgraded" badge when opening panel
const clearingBadge = this.clearBrowserActionBadge();
// 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];
let settingOnboardingStage;
if (!onboarded) {
onboarded = 0;
this.setOnboardingStage(onboarded);
settingOnboardingStage = this.setOnboardingStage(onboarded);
}
let showingPanel;
switch (onboarded) {
case 5:
this.showAchievementOrContainersListPanel();
showingPanel = this.showAchievementOrContainersListOrRecordPanel();
break;
case 4:
this.showPanel(P_ONBOARDING_5);
showingPanel = this.showPanel(P_ONBOARDING_5);
break;
case 3:
this.showPanel(P_ONBOARDING_4);
showingPanel = this.showPanel(P_ONBOARDING_4);
break;
case 2:
this.showPanel(P_ONBOARDING_3);
showingPanel = this.showPanel(P_ONBOARDING_3);
break;
case 1:
this.showPanel(P_ONBOARDING_2);
showingPanel = this.showPanel(P_ONBOARDING_2);
break;
case 0:
default:
this.showPanel(P_ONBOARDING_1);
showingPanel = this.showPanel(P_ONBOARDING_1);
break;
}
return Promise.all([initializingPanels, clearingBadge, settingOnboardingStage, showingPanel]);
},
async showAchievementOrContainersListPanel() {
async showAchievementOrContainersListOrRecordPanel() {
// Do we need to show an achievement panel?
let showAchievements = false;
const achievementsStorage = await browser.storage.local.get({ achievements: [] });
@ -134,9 +166,25 @@ const Logic = {
if (showAchievements) {
this.showPanel(P_CONTAINERS_ACHIEVEMENT);
} else {
this.showPanel(P_CONTAINERS_LIST);
const currentTab = await Logic.currentTab();
const isRecordingTab = await Logic.isRecordingTab(currentTab);
if (isRecordingTab) {
this.showPanel(P_CONTAINER_RECORD);
} else {
this.showPanel(P_CONTAINERS_LIST);
}
}
},
// Used when popup is running within iframe on a webpage, so lacks privileged API
async injectAPI() {
const script = document.createElement("script");
script.src = "/js/popup-bootstrap.js";
document.body.appendChild(script);
await new Promise((resolve) => { script.addEventListener("load", resolve); });
// Above script has added browserAPIInjector
await browserAPIInjector.injectAPI();
},
// In case the user wants to click multiple actions,
// they have to click the "Done" button to stop the panel
@ -160,6 +208,8 @@ const Logic = {
},
async clearBrowserActionBadge() {
if (!Env.isBrowserActionPopup) { return; }
const extensionInfo = await getExtensionInfo();
const storage = await browser.storage.local.get({ browserActionBadgesClicked: [] });
browser.browserAction.setBadgeBackgroundColor({ color: null });
@ -207,13 +257,17 @@ const Logic = {
},
async currentTab() {
const activeTabs = await browser.tabs.query({ active: true, windowId: browser.windows.WINDOW_ID_CURRENT });
if (activeTabs.length > 0) {
return activeTabs[0];
if (Env.tabId) {
return await browser.tabs.get(Env.tabId);
} else {
const activeTabs = await browser.tabs.query({ active: true, windowId: browser.windows.WINDOW_ID_CURRENT });
if (activeTabs.length > 0) {
return activeTabs[0];
}
return false;
}
return false;
},
async numTabs() {
const activeTabs = await browser.tabs.query({ windowId: browser.windows.WINDOW_ID_CURRENT });
return activeTabs.length;
@ -309,7 +363,14 @@ const Logic = {
registerPanel(panelName, panelObject) {
this._panels[panelName] = panelObject;
panelObject.initialize();
},
initializePanels() {
return Promise.all(Object.values(this._panels).map(async (panel) => { return panel.initialize(); }));
},
getPanel(panelName) {
return this._panels[panelName];
},
identities() {
@ -322,6 +383,10 @@ const Logic = {
}
return this._currentIdentity;
},
setCurrentIdentity(identity) {
this._currentIdentity = identity;
},
currentUserContextId() {
const identity = Logic.currentIdentity();
@ -368,6 +433,24 @@ const Logic = {
});
},
async isRecordingTab(tab) {
if (!tab || tab.id === browser.tabs.TAB_ID_NONE) { return false; }
try {
const recordingTabId = await browser.runtime.sendMessage({
method: "getRecording"
});
return recordingTabId === tab.id;
} catch (e) { console.error("Failed to determine if recording: " + e.message); return false; }
},
async setRecordingTab(tab) {
const tabId = tab ? tab.id : browser.tabs.TAB_ID_NONE;
return browser.runtime.sendMessage({
method: "setOrRemoveRecording",
tabId
});
},
generateIdentityName() {
const defaultName = "Container #";
const ids = [];
@ -393,7 +476,7 @@ const Logic = {
getCurrentPanelElement() {
const panelItem = this._panels[this._currentPanel];
return document.querySelector(this.getPanelSelector(panelItem));
},
}
};
// P_ONBOARDING_1: First page for Onboarding.
@ -538,7 +621,7 @@ Logic.registerPanel(P_CONTAINERS_LIST, {
window.close();
}
});
document.addEventListener("keydown", (e) => {
const selectables = [...document.querySelectorAll("[tabindex='0'], [tabindex='-1']")];
const element = document.activeElement;
@ -630,21 +713,78 @@ Logic.registerPanel(P_CONTAINERS_LIST, {
}
assignmentCheckboxElement.disabled = disabled;
},
isRecordingEnabled() {
const recordLinkElement = document.getElementById("record-link");
if (recordLinkElement.classList.contains("disabled")) { return false; }
return true;
},
isRecordingActive() {
const recordLinkElement = document.getElementById("record-link");
if (recordLinkElement.classList.contains("active")) { return true; }
return false;
},
setRecordingActiveAndEnabled(isActive, isEnabled) {
const recordLinkElement = document.getElementById("record-link");
const recordIconElement = recordLinkElement.querySelector(".icon");
if (!isEnabled) {
recordIconElement.src = CONTAINER_RECORD_DISABLED_SRC;
recordLinkElement.classList.remove("active");
recordLinkElement.classList.add("disabled");
} else {
recordIconElement.src = CONTAINER_RECORD_ENABLED_SRC;
recordLinkElement.classList.remove("disabled");
if (isActive) {
recordLinkElement.classList.add("active");
} else {
recordLinkElement.classList.remove("active");
}
}
},
async prepareCurrentTabHeader() {
const currentTab = await Logic.currentTab();
const currentTabElement = document.getElementById("current-tab");
const assignmentCheckboxElement = document.getElementById("container-page-assigned");
const recordLinkElement = document.getElementById("record-link");
const currentTabUserContextId = Logic.userContextId(currentTab.cookieStoreId);
assignmentCheckboxElement.addEventListener("change", () => {
Logic.setOrRemoveAssignment(currentTab.id, currentTab.url, currentTabUserContextId, !assignmentCheckboxElement.checked);
});
Logic.addEnterHandler(recordLinkElement, async () => {
const currentTab = await Logic.currentTab();
if (!currentTab) { return; }
if (!this.isRecordingEnabled()) { return; }
const newRecordingTab = this.isRecordingActive() ? null : currentTab;
let showingPanel;
try {
// Show new recording started/stopped status
this.setRecordingActiveAndEnabled(!!newRecordingTab, true);
// Show recording panel
if (newRecordingTab) { showingPanel = Logic.showPanel(P_CONTAINER_RECORD); }
// Start/stop recording
await Logic.setRecordingTab(newRecordingTab);
} catch (e) {
// Failed - revert recording started/stopped status
this.setRecordingActiveAndEnabled(!newRecordingTab, true);
try { await showingPanel; } catch (e) { /* Ignore show error, as we're immediately going to change panel */ }
Logic.showPanel(P_CONTAINERS_LIST);
throw new Error("Failed to " + (newRecordingTab ? "start" : "stop") + " recording: " + e.message);
}
});
currentTabElement.hidden = !currentTab;
this.setupAssignmentCheckbox(false, currentTabUserContextId);
this.setRecordingActiveAndEnabled(false, false);
if (currentTab) {
const identity = await Logic.identity(currentTab.cookieStoreId);
const siteSettings = await Logic.getAssignment(currentTab);
this.setupAssignmentCheckbox(siteSettings, currentTabUserContextId);
const isCurrentTabRecording = await Logic.isRecordingTab(currentTab);
this.setRecordingActiveAndEnabled(isCurrentTabRecording, (currentTabUserContextId !== false));
const currentPage = document.getElementById("current-page");
currentPage.innerHTML = escaped`<span class="page-title truncate-text">${currentTab.title}</span>`;
const favIconElement = Utils.createFavIconElement(currentTab.favIconUrl || "");
@ -1011,10 +1151,10 @@ Logic.registerPanel(P_CONTAINER_EDIT, {
}
},
showAssignedContainers(assignments) {
const assignmentPanel = document.getElementById("edit-sites-assigned");
const assignmentKeys = Object.keys(assignments);
assignmentPanel.hidden = !(assignmentKeys.length > 0);
showAssignedContainers(assignments, options = {}) {
const assignmentPanel = document.getElementById(options.elementId || "edit-sites-assigned");
const assignmentKeys = assignments ? Object.keys(assignments) : [];
assignmentPanel.hidden = !(assignmentKeys.length > 0) && !options.sticky;
if (assignments) {
const tableElement = assignmentPanel.querySelector(".assigned-sites-list");
/* Remove previous assignment list,
@ -1047,7 +1187,7 @@ Logic.registerPanel(P_CONTAINER_EDIT, {
const currentTab = await Logic.currentTab();
Logic.setOrRemoveAssignment(currentTab.id, assumedUrl, userContextId, true);
delete assignments[siteKey];
that.showAssignedContainers(assignments);
that.showAssignedContainers(assignments, options);
});
trElement.classList.add("container-info-tab-row", "clickable");
tableElement.appendChild(trElement);
@ -1091,7 +1231,7 @@ Logic.registerPanel(P_CONTAINER_EDIT, {
const userContextId = Logic.currentUserContextId();
const assignments = await Logic.getAssignmentObjectByContainer(userContextId);
this.showAssignedContainers(assignments);
this.showAssignedContainers(assignments, { elementId: "edit-sites-assigned" });
document.querySelector("#edit-container-panel .panel-footer").hidden = !!userContextId;
document.querySelector("#edit-container-panel-name-input").value = identity.name || "";
@ -1164,6 +1304,45 @@ Logic.registerPanel(P_CONTAINER_DELETE, {
},
});
// P_CONTAINER_RECORD: Add assignments to a container by browsing
// ----------------------------------------------------------------------------
Logic.registerPanel(P_CONTAINER_RECORD, {
panelSelector: "#container-record-panel",
// This method is called when the object is registered.
initialize() {
Logic.addEnterHandler(document.querySelector("#exit-record-mode-link"), () => {
Logic.setRecordingTab(null);
Logic.showPanel(P_CONTAINERS_LIST);
});
},
// This method is called when the panel is shown.
async prepare() {
const currentTab = await Logic.currentTab();
const identity = await Logic.identity(currentTab.cookieStoreId);
// We only show this panel if the current tab is recording.
// So the current identity is determined by the current tab.
Logic.setCurrentIdentity(identity);
// Populating the panel: name and icon
document.getElementById("container-record-name").textContent = identity.name;
const icon = document.getElementById("container-record-icon");
icon.setAttribute("data-identity-icon", identity.icon);
icon.setAttribute("data-identity-color", identity.color);
// Assignments
const userContextId = Logic.currentUserContextId();
const assignments = await Logic.getAssignmentObjectByContainer(userContextId);
const editPanel = Logic.getPanel(P_CONTAINER_EDIT);
editPanel.showAssignedContainers(assignments, { elementId: "record-sites-assigned", sticky: true });
return Promise.resolve(null);
},
});
// P_CONTAINERS_ACHIEVEMENT: Page for achievement.
// ----------------------------------------------------------------------------
@ -1187,7 +1366,30 @@ Logic.registerPanel(P_CONTAINERS_ACHIEVEMENT, {
Logic.init();
/**
Notify backgroundPage about show/hide/resize of this popup by opening a port.
When this popup unloads, the port is automatically disconnected.
Note: only notify if this is the 'real' browserAction popup (i.e. not a 'fake' popup in an iframe)
*/
class PopupEvents {
constructor() {
this.port = browser.runtime.connect({ name: "browserActionPopup" });
this.onResize();
}
onResize() {
this.port.postMessage({
update: {
width: window.innerWidth,
height: window.innerHeight
}
});
}
}
const popupEvents = Env.isBrowserActionPopup ? new PopupEvents() : null;
window.addEventListener("resize", function () {
if (popupEvents) { popupEvents.onResize(); }
//for overflow menu
const difference = window.innerWidth - document.body.offsetWidth;
if (difference > 2) {

View file

@ -108,6 +108,9 @@
</label>
</div>
<div class="container-panel-controls">
<a href="#" class="action-link" id="record-link" title="Automatically add sites to current container">
<img class="icon" src="/img/container-record-enabled.svg" />
</a>
<a href="#" class="action-link" id="sort-containers-link" title="Sort tabs into container order">Sort Tabs</a>
</div>
<div class="scrollable panel-content" tabindex="-1">
@ -162,7 +165,7 @@
<div class="panel-footer edit-containers-panel-footer">
<a href="#" id="exit-edit-mode-link" class="exit-edit-mode-link edit-containers-exit-text">
<img src="/img/container-arrow.svg"/>Exit Edit Mode</a>
</div>
</div>
</div>
@ -212,6 +215,26 @@
<a href="#" class="button expanded primary footer-button" id="delete-container-ok-link">OK</a>
</div>
</div>
<div class="hide panel container-record-panel" id="container-record-panel" tabindex="-1">
<div class="panel-header container-record-panel-header">
<span class="usercontext-icon" id="container-record-icon"></span>
<h3 id="container-record-name" class="panel-header-text container-name truncate-text"></h3>
</div>
<div class="container-record-panel-banner container-record-banner" id="container-record-banner">
<img id="container-record-icon" alt="Container Record icon" src="/img/container-record-enabled.svg" class="icon container-record-panel-record-icon"/>
<span id="container-record-label">RECORDING</span>
</div>
<div id="record-sites-assigned" class="scrollable" hidden>
<h3>Sites assigned to this container</h3>
<div class="assigned-sites-list">
</div>
</div>
<div class="panel-footer container-record-panel-footer">
<a href="#" id="exit-record-mode-link" class="exit-record-mode-link container-record-exit-text">
<img src="/img/container-arrow.svg"/>Exit Record Mode</a>
</div>
</div>
<script src="js/utils.js"></script>
<script src="js/popup.js"></script>

11
src/recording.html Normal file
View file

@ -0,0 +1,11 @@
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<title>Multi-Account Containers Recording</title>
<link xmlns="http://www.w3.org/1999/xhtml" rel="stylesheet" href="chrome://browser/skin/aboutNetError.css" type="text/css" media="all" />
<link rel="stylesheet" href="css/content.css" />
</head>
<body>
<script src="js/content-script.js"></script>
</body>
</html>

View file

@ -0,0 +1,87 @@
describe("Recording Feature", () => {
const url1 = "http://example.com";
const url2 = "http://example2.com";
let recordingTab;
beforeEach(async () => {
recordingTab = await helper.browser.initializeWithTab({
cookieStoreId: "firefox-container-1",
url: url1
});
});
describe("click the 'Record' button in the popup", () => {
beforeEach(async () => {
await helper.popup.clickElementById("record-link");
});
describe("browse to a website", () => {
beforeEach(async () => {
await helper.browser.browseToURL(recordingTab.id, url1);
});
describe("browse to another website", () => {
beforeEach(async () => {
await helper.browser.browseToURL(recordingTab.id, url2);
});
describe("click the 'Exit Record Mode' button in the popup", () => {
beforeEach(async () => {
await helper.popup.clickElementById("exit-record-mode-link");
});
describe("in a new tab open the first website", () => {
beforeEach(async () => {
await helper.browser.openNewTab({
cookieStoreId: "firefox-default",
url: url1
}, {
options: {
webRequestError: true // because request is canceled due to reopening
}
});
});
it("should open the confirm page", async () => {
// should have created a new tab with the confirm page
background.browser.tabs.create.should.have.been.calledWithMatch({
url: "moz-extension://fake/confirm-page.html?" +
`url=${encodeURIComponent(url1)}` +
`&cookieStoreId=${recordingTab.cookieStoreId}`,
cookieStoreId: undefined,
openerTabId: null,
index: 2,
active: true
});
});
describe("in another new tab, open the second website", () => {
beforeEach(async () => {
await helper.browser.openNewTab({
cookieStoreId: "firefox-default",
url: url2
}, {
options: {
webRequestError: true // because request is canceled due to reopening
}
});
});
it("should open the confirm page", async () => {
// should have created a new tab with the confirm page
background.browser.tabs.create.should.have.been.calledWithMatch({
url: "moz-extension://fake/confirm-page.html?" +
`url=${encodeURIComponent(url2)}` +
`&cookieStoreId=${recordingTab.cookieStoreId}`,
cookieStoreId: undefined,
openerTabId: null,
index: 3,
active: true
});
});
});
});
});
});
});
});
});

View file

@ -19,6 +19,9 @@ module.exports = {
"achievements": []
});
window.browser.storage.local.set.resetHistory();
window.browser.runtime.connect.returns({
postMessage: sinon.stub()
});
}
}
}
@ -29,6 +32,15 @@ module.exports = {
async openNewTab(tab, options = {}) {
return background.browser.tabs._create(tab, options);
},
async browseToURL(tabId, url) {
const [promise] = background.browser.webRequest.onBeforeRequest.addListener.yield({
frameId: 0,
tabId: tabId,
url: url
});
return promise;
}
},