diff --git a/package.json b/package.json index 753f292a..39ebde4f 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "stylelint-config-standard": "^16.0.0", "stylelint-order": "^0.3.0", "web-ext": "^2.9.3", - "webextensions-jsdom": "^1.1.0" + "webextensions-jsdom": "^1.1.2" }, "homepage": "https://github.com/mozilla/multi-account-containers#readme", "license": "MPL-2.0", diff --git a/src/css/content.css b/src/css/content.css index 56818873..b48e2e99 100644 --- a/src/css/content.css +++ b/src/css/content.css @@ -1,27 +1,152 @@ -.container-notification { - align-items: center; +:root { + /* calculated from 12px */ + --block-line-separation-size: 0.33em; /* 10px */ + + /* Use for url and icon size */ + --icon-button-size: calc(calc(var(--block-line-separation-size) * 2) + 1.66em); /* 20px */ +} + +#container-notifications, +#container-notifications * { + all: unset; + display: block; + font-size: 16px; +} + +#container-notifications { + 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; + padding-block-end: 0; + padding-block-start: 0; + padding-inline-end: 0; + padding-inline-start: 0; + position: fixed; + z-index: 999999999999; +} + +#container-notifications > .popup { + border: 1px solid; + inset-block-start: 4px; /* stylelint-disable-line property-no-unknown */ + inset-inline-end: 3em; /* stylelint-disable-line property-no-unknown */ + position: fixed; + z-index: 2; +} + +#container-notifications > .popup > .draggable { + background: #ebebeb url('../img/drag.svg'); + background-size: 100% 100%; + block-size: var(--icon-button-size); + border-block-end: 0.5px solid darkgray; + inline-size: 100%; + z-index: 3; +} + +#container-notifications > .popup > .draggable-mask { + background-color: black; + display: none; + inset-block-end: 0; /* stylelint-disable-line property-no-unknown */ + inset-block-start: 0; /* stylelint-disable-line property-no-unknown */ + inset-inline-end: 0; /* stylelint-disable-line property-no-unknown */ + inset-inline-start: 0; /* stylelint-disable-line property-no-unknown */ + opacity: 0.5; + position: absolute; + z-index: 2; +} + +#container-notifications > .popup.drag > .draggable-mask { + display: block; +} + +#container-notifications > .message.recording { + z-index: 1; +} + +#container-notifications > .message { + max-block-size: 0; + opacity: 0; + overflow: hidden; + position: relative; + transition: opacity 0.6s ease-in, max-block-size 1s cubic-bezier(0.07, 0.95, 0, 1); +} + +#container-notifications > .message.show { + max-block-size: 500px; + opacity: 1; + transition-property: max-block-size; + transition-timing-function: ease-in; +} + +#container-notifications > .message:hover, +#container-notifications > .message:focus, +#container-notifications > .message:visited { + color: #003f07; + text-decoration: none; +} + +#container-notifications > .message > .real { + inset-block-end: 0; /* stylelint-disable-line property-no-unknown */ + position: absolute; +} + +#container-notifications > .message > .dummy { + visibility: hidden; +} + +#container-notifications > .message > div > div { + align-items: stretch; background: #efefef; + block-size: 3em; color: #003f07; display: flex; - font: 12px sans-serif; + font: 1em sans-serif; inline-size: 100vw; - justify-content: start; - offset-block-start: 0; - offset-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 { - block-size: 16px; - display: inline-block; - inline-size: 16px; +#container-notifications > .message > div > div > .logo { + align-self: center; + block-size: 1em; + flex: 0 0 1em; margin-inline-end: 3px; + margin-inline-start: 8px; +} + +#container-notifications > .message > div > div > .title { + align-self: center; + flex: 0; + font-weight: bold; + margin-inline-end: 1em; + margin-inline-start: 0.5em; + white-space: nowrap; +} + +#container-notifications > .message > div > div > .text { + align-self: center; + flex: 1; +} + +#container-notifications > .message > div > div > .close { + background: url('../img/container-close-tab.svg'); + background-position: center; + background-repeat: no-repeat; + background-size: 50%; + flex: 0 0 2em; + margin-inline-end: 0.5em; + opacity: 0.5; +} + +#container-notifications > .message > div > div > .close:hover { + opacity: 1; +} + +#container-notifications > .message.recording > div > div { + background: #fcc; +} + +#container-notifications > .message.recording > div > div > .title { + color: red; } diff --git a/src/css/popup.css b/src/css/popup.css index d5f32958..ea91c9ce 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,22 +794,27 @@ 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%; color: #fff; display: inline-block; + flex: 1; justify-content: center; padding-block-start: 6px; 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 +847,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 00000000..31fa3c47 --- /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 00000000..23f873b6 --- /dev/null +++ b/src/img/container-record-enabled.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/src/img/drag.svg b/src/img/drag.svg new file mode 100644 index 00000000..7705e402 --- /dev/null +++ b/src/img/drag.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/js/.eslintrc.js b/src/js/.eslintrc.js index f78079f9..bbef9ebd 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 b48db759..33de7a93 100644 --- a/src/js/background/assignManager.js +++ b/src/js/background/assignManager.js @@ -117,6 +117,13 @@ const assignManager = { 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) { if (options.frameId !== 0 || options.tabId === -1) { @@ -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)) { @@ -394,13 +407,15 @@ 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); diff --git a/src/js/background/backgroundLogic.js b/src/js/background/backgroundLogic.js index 5d71fecf..b0bc30f7 100644 --- a/src/js/background/backgroundLogic.js +++ b/src/js/background/backgroundLogic.js @@ -94,6 +94,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"]; this.checkArgs(requiredArguments, options, "getTabs"); @@ -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 e167f0b6..0dc95a0e 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 9578e6e2..292315ca 100644 --- a/src/js/background/messageHandler.js +++ b/src/js/background/messageHandler.js @@ -37,6 +37,15 @@ 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 "setTabPopupPosition": + response = recordManager.setTabPopupPosition(m.tabId, m.x, m.y); + break; case "sortTabs": backgroundLogic.sortTabs(); break; @@ -70,10 +79,16 @@ 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 +228,151 @@ 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); + } + + handleTabStatusLoading() { + if (!this.tabLoading) { + this.tabLoading = {}; + this.tabLoading.promise = new Promise((resolve) => { + this.tabLoading.resolve = resolve; + }); + } + } + + handleTabStatusComplete() { + if (this.tabLoading) { + this.tabLoading.resolve(); + this.tabLoading = null; + } + } + + handleTabRemoved() { + this.tabRemoved = true; + this.removeTabListeners(); + this.handleTabStatusComplete(); + } + + addTabListeners() { + this.onTabsUpdated = (eventTabId, info) => { + if (this.tabId === eventTabId) { + if (info.status === "loading") { + this.handleTabStatusLoading(); + } else { + this.handleTabStatusComplete(); + } + } + }; + + 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 00000000..915e4af1 --- /dev/null +++ b/src/js/background/recordManager.js @@ -0,0 +1,196 @@ +const recordManager = { + recording: undefined, + listening: undefined, + + Recording: class { + constructor(tab) { + if (tab) { + this.windowId = tab.windowId; + this.tabId = tab.id; + this.isTabActive = tab.active; + this.isTabReady = tab.url.startsWith("http"); + this.tabMessage = {}; + } else { + this.windowId = browser.windows.WINDOW_ID_NONE; + this.tabId = browser.tabs.TAB_ID_NONE; + this.isTabActive = false; + this.isTabReady = false; + this.tabMessage = undefined; + } + } + + get valid() { return this.tabId !== browser.tabs.TAB_ID_NONE; } + + updateTabPopup(active, opts) { + if (this.valid) { this.tabMessage.popup = active; this.updateTabPopupOptions(opts); } + } + + updateTabPopupOptions(opts) { + if (this.valid) { Object.assign(this.tabMessage.popupOptions, opts); } + } + + 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 showTabPopup = this.isTabActive && (!baPopup || baPopup.windowId !== this.windowId); + this.tabMessage = { recording: true, popup: showTabPopup, popupOptions: {tabId: this.tabId, hide:!showTabPopup} }; + const showingPage = this.isTabReady + ? Promise.resolve() + : 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.isTabActive = true; + this.sendTabMessage(); + } else if (this.windowId === activeInfo.windowId) { + this.isTabActive = false; + } + } + + // 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) { + const showTabPopup = !baPopupVisible; + this.updateTabPopup(showTabPopup, { width:baPopup.width, height:baPopup.height, hide:!showTabPopup }); + 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 + if (oldRecording.valid) { oldRecording.stop(); } + try { + // But DO wait for start + if (newRecording.valid) { await newRecording.start(); } + + // If error while starting, immediately stop, but don't wait + } catch (e) { + this.setTabId(browser.tabs.TAB_ID_NONE); + throw e; + } + }, + + async setTabPopupPosition(tabId, x, y) { + if (this.isRecordingTabId(tabId)) { this.recording.updateTabPopupOptions({x,y}); } + } +}; + +recordManager.init(); \ No newline at end of file diff --git a/src/js/content-script.js b/src/js/content-script.js index 1bf6a6e4..65bed982 100644 --- a/src/js/content-script.js +++ b/src/js/content-script.js @@ -1,46 +1,638 @@ -async function delayAnimation(delay = 350) { - return new Promise((resolve) => { - setTimeout(resolve, delay); - }); +class Defer extends Promise { + constructor() { + let resolve, reject; + super((res, rej) => { resolve = res; reject = rej; }); + this.resolve = resolve; this.reject = reject; + } + + // Fix to make then/catch/finally return a vanilla Promise, not a Defer + static get [Symbol.species]() { return Promise; } + get [Symbol.toStringTag]() { return this.constructor.name; } +} + +/** + Wraps a promise chain that: + 1. can be interrupted (e.g. to stop an animation) + 2. has a cleanup phase (e.g. to remove listeners) + + Note: interrupting is important when the browser is about to redirect. The background + script may have previously sent us a message and we returned a promise while we show + the message using an animation. If the browser now redirects, the promise is lost + and the background script hangs waiting forever on a promise that will never finish. + By interrupting the promise, we ensure the background script receives an error. + */ +class Operation extends Defer { + constructor(name) { + super(); + this.name = name; + this.finished = this.error = this.completions = undefined; + const resolveFinally = this.resolve; + const rejectFinally = this.reject; + // eslint-disable-next-line promise/catch-or-return + new Promise((resolve, reject) => { this.resolve = resolve; this.reject = reject; }).then( + v => { this._setFinished(); resolveFinally(v); }, + v => { this.error = v || new Error(`${this} failed`); this._setFinished(); rejectFinally(this.error); }); + } + + _setFinished() { + this.finished = true; + if (this.completions) { + this.completions.forEach(completion => { try { completion(this); } catch (e) { this.errored("completion", e); } }); + this.completions = undefined; + } + } + + mustBeRunning(running, optional) { + const wantFinished = running === false; + const ok = wantFinished === !!this.finished; + if (!ok && !optional) { throw new Error(`${this} ${wantFinished ? "unfinished" : "cancelled"}`); } + return ok; + } + + addFinishListener(listener) { + if (this.finished) { + listener({target: this}); + } else { + if (!(this.completions || (this.completions = [])).find(c => c === listener)) { this.completions.push(listener); } + } + } + + removeFinishListener(listener) { + if (this.completions) { this.completions = this.completions.filter(c => c !== listener); } + } + + addEventListener(type, listener) { + if (/^finish$/i.test(type)) { this.addFinishListener(listener); } + else { throw new Error(`${this} unsupported event '${type}'`); } + } + + removeEventListener(type, listener) { + if (/^finish$/i.test(type)) { this.removeFinishListener(listener); } + } + + errored(name, e) { console.error("%s error during %s: %s", this, name, e); } + toString() { return this.name || this.constructor.name; } +} + +/** + Builds an operation with concurrent tasks (e.g. onTimeout, onEvent). + */ +class Operator { + constructor(operation) { + if (operation) { + this.operation = typeof operation === "string" ? new Operation(operation) : operation; + } else { + const name = this.constructor === Operator ? undefined : `${this.constructor.name}Operation`; + this.operation = new Operation(name); + } + } + + // Performs a named task, checks if operation is already finished and handles errors. + async exec(handler, opts = {name: "exec"}, ...args) { + if (this.operation.mustBeRunning(!opts.finished, opts.optional)) { + try { + const result = await handler(this.operation, ...args); + this.operation.mustBeRunning(!opts.finished, opts.optional); + return result; + } catch (e) { + if (!this.operation.finished || opts.finished) { + this.operation.errored(name, e); + this.operation.reject(e); + } + } + } + } + + delay(millis) { + return this.exec(() => { return new Promise(resolve => setTimeout(resolve, millis)); }, {name: "delay"}); + } + + onStart(handler) { this.exec(handler, {name: "start"}); return this; } + onFinish(handler) { this.operation.addFinishListener(e => handler(e.target)); return this; } + + onTimeout(delay, handler) { + const timer = () => this.exec(handler, {name: "timeout", optional: true}); + let timeoutId = setTimeout(() => { timeoutId = null; timer(); }, delay); + this.onFinish(() => clearTimeout(timeoutId)); + return this; + } + + onEvent(target, type, handler) { + if (target) { + const options = {name: type, optional: true, finished: this.isFinishEvent(target, type)}; + if (this.isPriorEvent(target, type)) { + this.exec(handler, options, {target}); + } else { + const listener = event => this.exec(handler, options, event); + target.addEventListener(type, listener, {once: true}); + this.onFinish(() => target.removeEventListener(type, listener)); + } + } + return this; + } + + isPriorEvent(target, type) { + if (this.isWindowLoadEvent(target, type) || this.isWindowInteractiveEvent(target, type)) { + switch (document.readyState) { + case "loading": return false; + case "complete": return true; + case "interactive": return this.isWindowInteractiveEvent(target, type); + } + } else if (this.isFinishEvent(target, type)) { + return target.finished; + } + return false; + } + + isWindowLoadEvent(target, type) { return target === window && /^load$/i.test(type); } + isWindowInteractiveEvent(target, type) { return target === window && /^domcontentloaded$/i.test(type); } + isFinishEvent(target, type) { return "finished" in target && /^finish$/i.test(type); } } -async function doAnimation(element, property, value) { - return new Promise((resolve) => { - const handler = () => { - resolve(); - element.removeEventListener("transitionend", handler); +class Animator extends Operator { + static isShown(elem) { return elem && elem.classList.contains("show"); } + + toggle(elem, show, timeoutDelay = 3000) { + if (Animator.isShown(elem) === !!show) { return; } + + const animate = (operation) => { + if (!operation.finished) { + elem.classList[(show ? "add" : "remove")]("show"); + } }; - element.addEventListener("transitionend", handler); - window.requestAnimationFrame(() => { - element.style[property] = value; - }); - }); + + return new Operator(`Animate${show ? "Show" : "Hide"}`) + // Ensure animation always reaches final state on timeout + .onTimeout(timeoutDelay, operation => { animate(operation); operation.resolve(); }) + .onEvent(elem, "transitionend", operation => operation.resolve()) + .onEvent(this.operation, "finish", operation => operation.reject("Interrupted")) + .onStart(operation => { + requestAnimationFrame(() => { // Delay until element has been rendered + setTimeout(() => { + animate(operation); + }, 1); + }); + }) + .operation; + } +} + +class Draggable { + constructor(elem) { + this.elem = elem; + this.x = this.y = this.left = this.top = this.insetTop = this.insetLeft = this.insetBottom = this.insetRight = this.finishHandler = undefined; + this.onMouseDown = this.onMouseDown.bind(this); + this.onDrag = this.onDrag.bind(this); + this.onMouseUp = this.onMouseUp.bind(this); + } + + start(finishHandler) { + this.finishHandler = finishHandler; + this.elem.querySelector(".draggable").addEventListener("mousedown", this.onMouseDown); + } + + stop() { + this.onMouseUp(); + this.elem.querySelector(".draggable").removeEventListener("mousedown", this.onMouseDown); + this.finishHandler = undefined; + } + + onMouseDown(e) { + e.preventDefault(); + const rect = e.target.getBoundingClientRect(); + const minInsetX = rect.width - 30; + const minInsetY = rect.height - 30; + this.x = e.clientX; + this.y = e.clientY; + this.insetTop = e.clientY - rect.top - minInsetY; + this.insetLeft = e.clientX - rect.left - minInsetX; + this.insetBottom = rect.bottom - e.clientY - minInsetY; + this.insetRight = rect.right - e.clientX - minInsetX; + this.elem.classList.add("drag"); + document.addEventListener("mousemove", this.onDrag); + window.addEventListener("mouseup", this.onMouseUp); + } + + onDrag(e) { + e.preventDefault(); + const x = Math.max(this.insetLeft, Math.min(window.innerWidth - this.insetRight, e.clientX)); + const y = Math.max(this.insetTop, Math.min(window.innerHeight - this.insetBottom, e.clientY)); + const deltaX = this.x - x; + const deltaY = this.y - y; + this.x = x; + this.y = y; + this.left = this.elem.offsetLeft - deltaX; + this.top = this.elem.offsetTop - deltaY; + Draggable.moveTo(this.elem, this.left, this.top); + } + + onMouseUp() { + this.elem.classList.remove("drag"); + document.removeEventListener("mousemove", this.onDrag); + window.removeEventListener("mouseup", this.onMouseUp); + if (this.finishHandler) { + this.finishHandler(this.left, this.top); + } + } + + static moveTo(elem, x, y) { + let left, top; + if (x === undefined || y === undefined) { + left = top = ""; + } else { + left = `min(calc(100vw - 30px), ${x}px)`; + top = `min(calc(100vh - 30px), ${y}px)`; + } + elem.style.left = left; + elem.style.top = top; + } } -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 Component { + static async toggle(show, options) { + const action = show ? "add" : "remove"; + const response = UI.request(this, action, options); + return response.animating; + } + + static getElement(isAll) { + if (isAll) { return this.getElements(); } + const unique = this.unique; + if (unique) { + if (unique.id) { + return document.getElementById(unique.id); + } else { + const parentElem = this.getParentElement(); + return parentElem && parentElem.querySelector(unique.selector); + } + } + } + + static getElements() { + const identifier = this.all || this.unique; + if (identifier) { + const parents = this.getParentElement(true); + if (parents) { + const selector = identifier.selector || `#${identifier.id}`; + return parents.flatMap(parent => Array.from(parent.querySelectorAll(selector))); + } + } + } + + static getParentElement(isAll) { + const parent = this.parent; + return "querySelector" in parent ? (isAll ? [parent] : parent) : parent.getElement(isAll); + } + + static get options() { return this.unique || this.all ? ["all"] : []; } + static isReady() { return true; } + static toString() { return this.name; } +} + +const UI = { + requests: { + _store: [], + getRequest: function(component) { + if (component.unique) { return this._store[component.name]; } + }, + addRequest: function(request) { + if (request.component.unique) { this._store[request.component.name] = request; } + }, + removeRequest: function(request) { + if (this._store[request.component.name] === request) { + this._store[request.component.name] = undefined; + } + } + }, + + request: function(component, action, options = {}) { + const request = new UI.Request(component, action, options); + const previous = this.requests.getRequest(component); + + // Already requested + if (request.isEqual(previous)) { return previous.response; } + + // Element already added/removed + if (!previous) { + if (component.unique || request.options.all) { + const elem = component.getElement(request.options.all); + if (elem && (!request.options.all || elem.length > 0)) { + if (action === "add") { if (component.isReady(elem, options)) { return UI.Response.element(elem); } } + } else { + if (action === "remove") { return UI.Response.element(null); } + } + } + } + + if (component.unique || request.options.all) { this.requests.addRequest(request); } + return new UI.Requestor(request, previous) + .onFinish(() => this.requests.removeRequest(request)) + .submit(); + }, + + Requestor: class extends Operator { + constructor (request, previous) { + super(`${request}`); + this.request = request; + this.previous = previous; + this.modifiedDOM = new Defer(); + this.request.response = new UI.Response(this.modifiedDOM, this.operation); + this.operation.addFinishListener(() => this.modifiedDOM.reject()); // Terminate immediately on interrupt + } + + submit() { + this.performRequest(); + return this.request.response; + } + + async performRequest() { + try { + await this.stillAnimatingPrevious(); + const existing = this.request.component.getElement(this.request.options.all); + const elem = await (this.request.action === "add" ? this.addElement(existing) : this.removeElement(existing)); + this.modifiedDOM.resolve(elem); + this.operation.resolve(elem); + } catch (e) { + this.modifiedDOM.reject(e); + this.operation.reject(e); + } + } + + async addElement(elem) { + const alreadyAdded = elem; + if (!alreadyAdded) { + elem = await this.createElement(); + const parentElem = this.request.component.getParentElement() || await this.addParentElement(); + if (this.request.component.prepend) { + parentElem.prepend(elem); + } else { + parentElem.appendChild(elem); + } + } + + await this.event("onUpdate", elem); + this.modifiedDOM.resolve(elem); // Resolve before start animating + if (!alreadyAdded || !this.request.component.isReady(elem)) { + await this.event("onAdd", elem); + } + return elem; + } + + async removeElement(elem) { + if (elem) { + const removeOne = async elem => { + await this.event("onRemove", elem); + if (this.request.options.hide) { + elem.style.display = "none"; + } else { + elem.remove(); + } + }; + if (this.request.options.all) { + await Promise.all(elem.map(e => removeOne(e))); + } else { + await removeOne(elem); + } + } + return elem; + } + + async stillAnimatingPrevious() { + if (this.previous) { + try { await this.previous.response.animating; } catch (e) { /* Ignore request success/failure */ } + this.operation.mustBeRunning(); + } + } + + async addParentElement() { + const elem = await UI.request(this.request.component.parent, "add", this.request.options).modifyingDOM; + this.operation.mustBeRunning(); + return elem; + } + + async createElement() { + const elem = await this.request.component.create(this.operation, this.request.options); + this.operation.mustBeRunning(); + return elem; + } + + async event(name, elem) { + if (this.request.component[name]) { + await this.request.component[name](elem, this.operation, this.request.options); + this.operation.mustBeRunning(); + } + } + }, + + Request: class { + constructor (component, action, options) { + this.component = component; + this.action = action; + this.options = this.validateOptions(options); + this.response = undefined; + } + + validateOptions(options) { + return options && this.component.options.reduce((result, key) => { + if (key in options) { + let value = options[key]; + if (key === "all") { value = this.action === "remove" && (this.component.all || this.component.unique) ? !!value : undefined; } + if (value !== undefined) { result[key] = value; } + } + return result; + }, {}); + } + + isEqual(other) { + return other && other.component === this.component && other.action === this.action && this.isEqualOptions(other.options); + } + + isEqualOptions(options) { + return options === this.options || (options && this.options && + Object.keys(this.component.options).every(k => options[k] === this.options[k])); + } + + toString() { return `${this.component}::${this.action}::${JSON.stringify(this.options)}`; } + }, + + Response: class { + constructor (modifyingDOM, animating) { + this.modifyingDOM = modifyingDOM; + this.animating = animating || modifyingDOM; + } + + static element(elem) { + return new this(Promise.resolve(elem)); + } + }, + + Container: class extends Component { + 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; + } + }, + + Popup: class extends Component { + static get parent() { return UI.Container; } + static get unique() { return { selector: ".popup" }; } + static get prepend() { return true; } + static get options() { return ["all", "hide", "x", "y", "width", "height", "tabId"]; } + static create(operation, options) { + const popup = document.createElement("div"); + const mask = document.createElement("div"); + const draggable = document.createElement("div"); + const iframe = document.createElement("iframe"); + const popupURL = browser.runtime.getURL("/popup.html"); + const popupQueryString = options.tabId ? `?tabId=${options.tabId}` : ""; + popup.classList.add("popup"); + mask.classList.add("draggable-mask"); + draggable.classList.add("draggable"); + iframe.setAttribute("sandbox", "allow-scripts allow-same-origin"); + iframe.src = `${popupURL}${popupQueryString}`; + popup.appendChild(draggable); + popup.appendChild(iframe); + popup.appendChild(mask); + Draggable.moveTo(popup, options.x, options.y); + new Draggable(popup).start((x, y) => { + browser.runtime.sendMessage({ method:"setTabPopupPosition", tabId:options.tabId, x, y }); + }); + return popup; + } + static isReady(elem) { return elem.style.display !== "none"; } + static onUpdate(popup, operation, options) { + popup.style.display = ""; + if (!options) { return; } + if (options.width) { + const width = options.width; + const height = options.height || 400; + popup.style.width = `${width}px`; + popup.querySelector("iframe").style.height = `${height}px`; + } + if (options.x && options.y) { + Draggable.moveTo(popup, options.x, options.y); + } + } + }, + + Recording: class extends Component { + static get parent() { return UI.Container; } + static get unique() { return { selector: ".recording" }; } + static get prepend() { return true; } + static async create(operation) { + const elem = await UI.Message.create(operation, { + title: "Recording", + text: "Sites will be automatically added to this container as you browse in this tab", + component: this + }); + elem.classList.add("recording"); + return elem; + } + static isReady(elem) { return Animator.isShown(elem); } + static onAdd(elem, operation) { return new Animator(operation).toggle(elem, true); } + static onRemove(elem, operation) { return new Animator(operation).toggle(elem, false); } + }, + + Message: class extends Component { + static get parent() { return UI.Container; } + static get all() { return { selector: ".message" }; } + static get options() { return ["all", "title", "text"]; } + static async create(operation, options) { + // Ideally we would use https://bugzilla.mozilla.org/show_bug.cgi?id=1340930 when this is available + + // Message + const msgElem = document.createElement("div"); + + // 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.appendChild(imageElem); + + // Title + if (options.title) { + const titleElem = document.createElement("div"); + titleElem.classList.add("title"); + titleElem.innerText = options.title; + msgElem.appendChild(titleElem); + } + + // Text + const textElem = document.createElement("div"); + textElem.classList.add("text"); + textElem.innerText = options.text; + msgElem.appendChild(textElem); + + // Close + const closeElem = document.createElement("div"); + closeElem.classList.add("close"); + msgElem.appendChild(closeElem); + + // 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 + + // Close listener + const finishedAnimating = operation.resolve; + realElem.querySelector(".close").addEventListener("click", (e) => { + finishedAnimating(); + e.target.closest(".message").classList.remove("show"); + }); + + // Outer container + const elem = document.createElement("div"); + elem.classList.add("message"); + elem.appendChild(dummyElem); + elem.appendChild(realElem); + + return elem; + } + static async onAdd(elem, operation) { + const animator = new Animator(operation); + await animator.toggle(elem, true); + await animator.delay(3000); + await animator.toggle(elem, false); + elem.remove(); + } + static onRemove(elem, operation) { return new Animator(operation).toggle(elem, false); } + } +}; - 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 Message extends Operation { + constructor(message) { super(); this.message = message; } - document.body.appendChild(divElement); + async handleMessage() { + if (!UI.initialised) { + UI.initialised = true; + await Promise.all([UI.Popup.toggle(false, {all:true}), UI.Message.toggle(false, {all:true})]); + } - await delayAnimation(100); - await doAnimation(divElement, "transform", "translateY(0)"); - await delayAnimation(3000); - await doAnimation(divElement, "transform", "translateY(-100%)"); + const message = this.message; + let animatePopup, animateRecording, animateMessage; + if ("popup" in message) { animatePopup = UI.Popup.toggle(message.popup, message.popupOptions); } + if ("recording" in message) { animateRecording = UI.Recording.toggle(message.recording); } + if ("text" in message) { animateMessage = UI.Message.toggle(true, message); } + return Promise.all([animatePopup, animateRecording, animateMessage]); + } + toString() { return `Message: ${JSON.stringify(this.message)}`; } - divElement.remove(); + static async add(message) { + await new Operator(new Message(message)) + .onEvent(window, "unload", operation => operation.reject("window unload")) + .onEvent(window, "DOMContentLoaded", operation => operation.resolve(operation.handleMessage())) + .operation; + } } browser.runtime.onMessage.addListener((message) => { - addMessage(message); + if (message.to === "tab") { + return Message.add(message.content); + } }); diff --git a/src/js/popup-bootstrap.js b/src/js/popup-bootstrap.js new file mode 100644 index 00000000..db421cb9 --- /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" + ]); + }, + + 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 }); + } +}; \ No newline at end of file diff --git a/src/js/popup.js b/src/js/popup.js index 64dca459..e1b9c5bb 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 = -1; + this.isBrowserActionPopup = this.hasFullBrowserAPI; + } + } +}; +Env.init(); + + // This object controls all the panels, identities and many other things. const Logic = { _identities: [], @@ -77,8 +99,13 @@ 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(); @@ -89,40 +116,46 @@ const Logic = { 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,10 +167,26 @@ 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 // from showing @@ -160,6 +209,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,11 +258,15 @@ 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 >= 0) { + 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() { @@ -309,7 +364,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() { @@ -323,6 +385,10 @@ const Logic = { return this._currentIdentity; }, + setCurrentIdentity(identity) { + this._currentIdentity = identity; + }, + currentUserContextId() { const identity = Logic.currentIdentity(); return Logic.userContextId(identity.cookieStoreId); @@ -368,6 +434,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 +477,7 @@ const Logic = { getCurrentPanelElement() { const panelItem = this._panels[this._currentPanel]; return document.querySelector(this.getPanelSelector(panelItem)); - }, + } }; // P_ONBOARDING_1: First page for Onboarding. @@ -539,6 +623,36 @@ Logic.registerPanel(P_CONTAINERS_LIST, { } }); + this.getAssignmentCheckbox().addEventListener("change", async () => { + const currentTab = await Logic.currentTab(); + if (!currentTab || !currentTab.cookieStoreId) { return; } + const currentTabUserContextId = Logic.userContextId(currentTab.cookieStoreId); + Logic.setOrRemoveAssignment(currentTab.id, currentTab.url, currentTabUserContextId, !this.getAssignmentCheckbox().checked); + }); + + Logic.addEnterHandler(this.getRecordButton(), async () => { + const currentTab = await Logic.currentTab(); + if (!currentTab || !currentTab.cookieStoreId) { 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); + } + }); + document.addEventListener("keydown", (e) => { const selectables = [...document.querySelectorAll("[tabindex='0'], [tabindex='-1']")]; const element = document.activeElement; @@ -617,8 +731,12 @@ Logic.registerPanel(P_CONTAINERS_LIST, { browser.tabs.onUpdated.removeListener(this.tabUpdateHandler); }, + getAssignmentCheckbox() { + return document.getElementById("container-page-assigned"); + }, + setupAssignmentCheckbox(siteSettings, currentUserContextId) { - const assignmentCheckboxElement = document.getElementById("container-page-assigned"); + const assignmentCheckboxElement = this.getAssignmentCheckbox(); let checked = false; if (siteSettings && Number(siteSettings.userContextId) === currentUserContextId) { checked = true; @@ -631,20 +749,50 @@ Logic.registerPanel(P_CONTAINERS_LIST, { assignmentCheckboxElement.disabled = disabled; }, + getRecordButton() { + return document.getElementById("record-link"); + }, + + isRecordingEnabled() { + return !this.getRecordButton().classList.contains("disabled"); + }, + + isRecordingActive() { + return this.getRecordButton().classList.contains("active"); + }, + + setRecordingActiveAndEnabled(isActive, isEnabled) { + const recordButton = this.getRecordButton(); + const recordButtonIcon = recordButton.querySelector(".icon"); + + if (!isEnabled) { + recordButtonIcon.src = CONTAINER_RECORD_DISABLED_SRC; + recordButton.classList.remove("active"); + recordButton.classList.add("disabled"); + } else { + recordButtonIcon.src = CONTAINER_RECORD_ENABLED_SRC; + recordButton.classList.remove("disabled"); + if (isActive) { + recordButton.classList.add("active"); + } else { + recordButton.classList.remove("active"); + } + } + }, + async prepareCurrentTabHeader() { const currentTab = await Logic.currentTab(); const currentTabElement = document.getElementById("current-tab"); - const assignmentCheckboxElement = document.getElementById("container-page-assigned"); const currentTabUserContextId = Logic.userContextId(currentTab.cookieStoreId); - assignmentCheckboxElement.addEventListener("change", () => { - Logic.setOrRemoveAssignment(currentTab.id, currentTab.url, currentTabUserContextId, !assignmentCheckboxElement.checked); - }); 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 +1159,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 +1195,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 +1239,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 +1312,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 +1374,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 cf9878f0..6a46ca7d 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 00000000..334e7320 --- /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 00000000..94bf30b0 --- /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 2704bacb..8c56ced6 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; } },