From abed1e9a5210572796c0a0128f6c043319460cb5 Mon Sep 17 00:00:00 2001 From: Francis McKenzie Date: Mon, 6 Jan 2020 20:49:01 +0100 Subject: [PATCH] Recording - automatically add sites to containers as you browse --- src/css/content.css | 96 +++++- src/css/popup.css | 75 ++++- src/img/container-record-disabled.svg | 5 + src/img/container-record-enabled.svg | 5 + src/js/.eslintrc.js | 4 +- src/js/background/assignManager.js | 31 +- src/js/background/backgroundLogic.js | 45 ++- src/js/background/index.html | 1 + src/js/background/messageHandler.js | 158 +++++++++- src/js/background/recordManager.js | 177 +++++++++++ src/js/content-script.js | 413 ++++++++++++++++++++++++-- src/js/popup-bootstrap.js | 58 ++++ src/js/popup.js | 262 ++++++++++++++-- src/popup.html | 25 +- src/recording.html | 11 + test/features/recording.test.js | 87 ++++++ test/helper.js | 12 + 17 files changed, 1369 insertions(+), 96 deletions(-) create mode 100644 src/img/container-record-disabled.svg create mode 100644 src/img/container-record-enabled.svg create mode 100644 src/js/background/recordManager.js create mode 100644 src/js/popup-bootstrap.js create mode 100644 src/recording.html create mode 100644 test/features/recording.test.js diff --git a/src/css/content.css b/src/css/content.css index 568188739..6433e426b 100644 --- a/src/css/content.css +++ b/src/css/content.css @@ -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; +} diff --git a/src/css/popup.css b/src/css/popup.css index d5f32958a..fc4d4fa4b 100644 --- a/src/css/popup.css +++ b/src/css/popup.css @@ -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; diff --git a/src/img/container-record-disabled.svg b/src/img/container-record-disabled.svg new file mode 100644 index 000000000..31fa3c471 --- /dev/null +++ b/src/img/container-record-disabled.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/src/img/container-record-enabled.svg b/src/img/container-record-enabled.svg new file mode 100644 index 000000000..23f873b66 --- /dev/null +++ b/src/img/container-record-enabled.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/src/js/.eslintrc.js b/src/js/.eslintrc.js index f78079f90..bbef9ebda 100644 --- a/src/js/.eslintrc.js +++ b/src/js/.eslintrc.js @@ -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" } }; diff --git a/src/js/background/assignManager.js b/src/js/background/assignManager.js index b48db7599..0d18656ce 100644 --- a/src/js/background/assignManager.js +++ b/src/js/background/assignManager.js @@ -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 diff --git a/src/js/background/backgroundLogic.js b/src/js/background/backgroundLogic.js index 5d71fecfb..b562338e2 100644 --- a/src/js/background/backgroundLogic.js +++ b/src/js/background/backgroundLogic.js @@ -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; } -}; \ No newline at end of file +}; diff --git a/src/js/background/index.html b/src/js/background/index.html index e167f0b61..0dc95a0e5 100644 --- a/src/js/background/index.html +++ b/src/js/background/index.html @@ -14,6 +14,7 @@ ] --> + diff --git a/src/js/background/messageHandler.js b/src/js/background/messageHandler.js index 9578e6e27..d619141bb 100644 --- a/src/js/background/messageHandler.js +++ b/src/js/background/messageHandler.js @@ -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); + } } }; diff --git a/src/js/background/recordManager.js b/src/js/background/recordManager.js new file mode 100644 index 000000000..7702ba183 --- /dev/null +++ b/src/js/background/recordManager.js @@ -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(); \ No newline at end of file diff --git a/src/js/content-script.js b/src/js/content-script.js index 1bf6a6e42..8af48a25f 100644 --- a/src/js/content-script.js +++ b/src/js/content-script.js @@ -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); + }); + } +} + +class UIRequest { + constructor (component, action, options, response) { + this.component = component; + this.action = action; + this.options = options; + this.response = response || new UIResponse(); + } +} + +class UIResponse { + constructor (value) { + let promise; + if (value instanceof Promise) { promise = value; } + if (value !== undefined) { promise = Promise.resolve(value); } + this.modifyingDOM = this.animating = promise; + } +} + +let requests; + +class UIRequestManager { + static request(component, action, options) { + // Try for quick return + if (component.unique) { + const previous = requests && requests[component.name]; + + // 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, just return the previous request directly + } 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); } } -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 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); + } - 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); + // 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); - document.body.appendChild(divElement); + // 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(); + } +} - await delayAnimation(100); - await doAnimation(divElement, "transform", "translateY(0)"); - await delayAnimation(3000); - await doAnimation(divElement, "transform", "translateY(-100%)"); +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]); + } - divElement.remove(); + static async add(message) { + return new PromiseBuilder("addMessage", message) + .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); + } }); diff --git a/src/js/popup-bootstrap.js b/src/js/popup-bootstrap.js new file mode 100644 index 000000000..74e2366c9 --- /dev/null +++ b/src/js/popup-bootstrap.js @@ -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" + ]); + }, + + async injectMethods(keys) { this.inject(keys, "method"); }, + async injectConstants(keys) { this.inject(keys, "constant"); }, + async injectUnimplemented(keys) { 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 }); + } +}; \ No newline at end of file diff --git a/src/js/popup.js b/src/js/popup.js index 64dca459d..2122bcedf 100644 --- a/src/js/popup.js +++ b/src/js/popup.js @@ -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`${currentTab.title}`; 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) { diff --git a/src/popup.html b/src/popup.html index cf9878f0c..6a46ca7d4 100644 --- a/src/popup.html +++ b/src/popup.html @@ -108,6 +108,9 @@

Current Tab

+ + + Sort Tabs
@@ -162,7 +165,7 @@

Edit Containers

+
@@ -212,6 +215,26 @@

Remove This Container

OK + +
+
+ +

+
+
+ Container Record icon + RECORDING +
+ + +
diff --git a/src/recording.html b/src/recording.html new file mode 100644 index 000000000..334e73202 --- /dev/null +++ b/src/recording.html @@ -0,0 +1,11 @@ + + + + Multi-Account Containers Recording + + + + + + + \ No newline at end of file diff --git a/test/features/recording.test.js b/test/features/recording.test.js new file mode 100644 index 000000000..94bf30b00 --- /dev/null +++ b/test/features/recording.test.js @@ -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 + }); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/test/helper.js b/test/helper.js index 2704bacb4..2bfd82e1f 100644 --- a/test/helper.js +++ b/test/helper.js @@ -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; } },