Recording - automatically add sites to containers as you browse
This commit is contained in:
parent
dc9e8f6399
commit
30a2601d35
17 changed files with 1370 additions and 97 deletions
|
@ -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;
|
align-items: center;
|
||||||
background: #efefef;
|
background: #efefef;
|
||||||
color: #003f07;
|
color: #003f07;
|
||||||
|
@ -6,22 +74,34 @@
|
||||||
font: 12px sans-serif;
|
font: 12px sans-serif;
|
||||||
inline-size: 100vw;
|
inline-size: 100vw;
|
||||||
justify-content: start;
|
justify-content: start;
|
||||||
offset-block-start: 0;
|
margin-block-end: 0;
|
||||||
offset-inline-start: 0;
|
margin-block-start: 0;
|
||||||
|
margin-inline-end: 0;
|
||||||
|
margin-inline-start: 0;
|
||||||
padding-block-end: 8px;
|
padding-block-end: 8px;
|
||||||
padding-block-start: 8px;
|
padding-block-start: 8px;
|
||||||
padding-inline-end: 8px;
|
padding-inline-end: 8px;
|
||||||
padding-inline-start: 8px;
|
padding-inline-start: 8px;
|
||||||
position: fixed;
|
|
||||||
text-align: start;
|
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;
|
block-size: 16px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
inline-size: 16px;
|
inline-size: 16px;
|
||||||
margin-inline-end: 3px;
|
margin-inline-end: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#container-notifications > div.recording > div > div {
|
||||||
|
background: #fcc;
|
||||||
|
}
|
||||||
|
|
||||||
|
#container-notifications > div.recording > div > div > .title {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ html {
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
display: flex;
|
||||||
font-family: Roboto, Noto, "San Francisco", Ubuntu, "Segoe UI", "Fira Sans", message-box, Arial, sans-serif;
|
font-family: Roboto, Noto, "San Francisco", Ubuntu, "Segoe UI", "Fira Sans", message-box, Arial, sans-serif;
|
||||||
inline-size: calc(var(--overflow-size) + 299px);
|
inline-size: calc(var(--overflow-size) + 299px);
|
||||||
max-inline-size: calc(var(--overflow-size) + 299px);
|
max-inline-size: calc(var(--overflow-size) + 299px);
|
||||||
|
@ -246,6 +247,7 @@ table {
|
||||||
/* Panels keep everything together */
|
/* Panels keep everything together */
|
||||||
.panel {
|
.panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
min-block-size: 400px;
|
min-block-size: 400px;
|
||||||
|
@ -451,7 +453,9 @@ manage things like container crud */
|
||||||
}
|
}
|
||||||
|
|
||||||
.container-panel-controls {
|
.container-panel-controls {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-column-gap: var(--inline-item-space-size);
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-block-end: var(--block-line-space-size);
|
margin-block-end: var(--block-line-space-size);
|
||||||
margin-block-start: 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);
|
margin-inline-start: var(--inline-item-space-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
#container-panel #sort-containers-link {
|
#container-panel .container-panel-controls > * {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
block-size: var(--block-url-label-size);
|
block-size: var(--block-url-label-size);
|
||||||
border: 1px solid #d8d8d8;
|
border: 1px solid #d8d8d8;
|
||||||
border-radius: var(--small-radius);
|
border-radius: var(--small-radius);
|
||||||
color: var(--title-text-color);
|
color: var(--title-text-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: wrap;
|
||||||
font-size: var(--small-text-size);
|
font-size: var(--small-text-size);
|
||||||
inline-size: var(--inline-button-size);
|
inline-size: var(--inline-button-size);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#container-panel #sort-containers-link:hover,
|
#container-panel .container-panel-controls > a:hover,
|
||||||
#container-panel #sort-containers-link:focus {
|
#container-panel .container-panel-controls > a:focus,
|
||||||
|
#container-panel .container-panel-controls > .disabled {
|
||||||
background: #f2f2f2;
|
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 {
|
span ~ .panel-header-text {
|
||||||
padding-block-end: 0;
|
padding-block-end: 0;
|
||||||
padding-block-start: 0;
|
padding-block-start: 0;
|
||||||
|
@ -674,7 +705,8 @@ span ~ .panel-header-text {
|
||||||
inline-size: calc(var(--column-panel-inline-size) - 58px);
|
inline-size: calc(var(--column-panel-inline-size) - 58px);
|
||||||
}
|
}
|
||||||
|
|
||||||
#container-info-hideorshow {
|
#container-info-hideorshow,
|
||||||
|
#container-record-banner {
|
||||||
margin-block-start: 4px;
|
margin-block-start: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -704,7 +736,8 @@ span ~ .panel-header-text {
|
||||||
}
|
}
|
||||||
|
|
||||||
.container-info-has-tabs,
|
.container-info-has-tabs,
|
||||||
.container-info-tab-row {
|
.container-info-tab-row,
|
||||||
|
.container-record-banner {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 28px;
|
flex: 0 0 28px;
|
||||||
|
@ -718,13 +751,25 @@ span ~ .panel-header-text {
|
||||||
padding-inline-start: 16px;
|
padding-inline-start: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container-record-banner {
|
||||||
|
background: #fcc;
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
.container-info-has-tabs img,
|
.container-info-has-tabs img,
|
||||||
.container-info-tab-row img {
|
.container-info-tab-row img,
|
||||||
|
.container-record-banner img {
|
||||||
block-size: 16px;
|
block-size: 16px;
|
||||||
flex: 0 0 16px;
|
flex: 0 0 16px;
|
||||||
margin-inline-end: 4px;
|
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=""] {
|
.container-info-tab-row img[src=""] {
|
||||||
margin-inline-end: 0;
|
margin-inline-end: 0;
|
||||||
}
|
}
|
||||||
|
@ -749,7 +794,9 @@ span ~ .panel-header-text {
|
||||||
background-color: #ebebeb;
|
background-color: #ebebeb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-containers-exit-text {
|
.edit-containers-exit-text,
|
||||||
|
.container-record-exit-text,
|
||||||
|
.container-record-banner-text {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--primary-action-color);
|
background: var(--primary-action-color);
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
|
@ -760,11 +807,13 @@ span ~ .panel-header-text {
|
||||||
padding-inline-start: 30%;
|
padding-inline-start: 30%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-containers-panel-footer {
|
.edit-containers-panel-footer,
|
||||||
|
.container-record-panel-footer {
|
||||||
background: var(--primary-action-color);
|
background: var(--primary-action-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.exit-edit-mode-link img {
|
.exit-edit-mode-link img,
|
||||||
|
.exit-record-mode-link img {
|
||||||
block-size: 16px;
|
block-size: 16px;
|
||||||
display: inline;
|
display: inline;
|
||||||
filter: grayscale(100%) brightness(5);
|
filter: grayscale(100%) brightness(5);
|
||||||
|
@ -797,11 +846,13 @@ span ~ .panel-header-text {
|
||||||
overflow: hidden; /* Bugfix: issue 948 */
|
overflow: hidden; /* Bugfix: issue 948 */
|
||||||
}
|
}
|
||||||
|
|
||||||
#edit-sites-assigned {
|
#edit-sites-assigned,
|
||||||
|
#record-sites-assigned {
|
||||||
flex: 1000; /* Bugfix: issue 948 */
|
flex: 1000; /* Bugfix: issue 948 */
|
||||||
}
|
}
|
||||||
|
|
||||||
#edit-sites-assigned h3 {
|
#edit-sites-assigned h3,
|
||||||
|
#record-sites-assigned h3 {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
padding-block-end: 6px;
|
padding-block-end: 6px;
|
||||||
|
|
5
src/img/container-record-disabled.svg
Normal file
5
src/img/container-record-disabled.svg
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
|
||||||
|
<path d="M21 6.5l-4 4V7c0-.55-.45-1-1-1H9.82L21 17.18V6.5zM3.27 2L2 3.27 4.73 6H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.21 0 .39-.08.54-.18L19.73 21 21 19.73 3.27 2z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 414 B |
5
src/img/container-record-enabled.svg
Normal file
5
src/img/container-record-enabled.svg
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
|
||||||
|
<path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 358 B |
|
@ -3,10 +3,12 @@ module.exports = {
|
||||||
"../../.eslintrc.js"
|
"../../.eslintrc.js"
|
||||||
],
|
],
|
||||||
"globals": {
|
"globals": {
|
||||||
|
"recordManager": "readonly",
|
||||||
"assignManager": true,
|
"assignManager": true,
|
||||||
"badge": true,
|
"badge": true,
|
||||||
"backgroundLogic": true,
|
"backgroundLogic": true,
|
||||||
"identityState": true,
|
"identityState": true,
|
||||||
"messageHandler": true
|
"messageHandler": true,
|
||||||
|
"browserAPIInjector": "readonly"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -117,6 +117,13 @@ const assignManager = {
|
||||||
return true;
|
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
|
// Before a request is handled by the browser we decide if we should route through a different container
|
||||||
async onBeforeRequest(options) {
|
async onBeforeRequest(options) {
|
||||||
if (options.frameId !== 0 || options.tabId === -1) {
|
if (options.frameId !== 0 || options.tabId === -1) {
|
||||||
|
@ -141,6 +148,12 @@ const assignManager = {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
const userContextId = this.getUserContextIdFromCookieStore(tab);
|
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
|
if (!siteSettings
|
||||||
|| userContextId === siteSettings.userContextId
|
|| userContextId === siteSettings.userContextId
|
||||||
|| this.storageArea.isExempted(options.url, tab.id)) {
|
|| this.storageArea.isExempted(options.url, tab.id)) {
|
||||||
|
@ -394,13 +407,15 @@ const assignManager = {
|
||||||
userContextId,
|
userContextId,
|
||||||
neverAsk: false
|
neverAsk: false
|
||||||
}, exemptedTabIds);
|
}, exemptedTabIds);
|
||||||
actionName = "added";
|
actionName = "Successfully set to always open in this container";
|
||||||
} else {
|
} else {
|
||||||
await this.storageArea.remove(pageUrl);
|
await this.storageArea.remove(pageUrl);
|
||||||
actionName = "removed";
|
actionName = "Successfully removed from this container";
|
||||||
}
|
}
|
||||||
browser.tabs.sendMessage(tabId, {
|
const hostname = new window.URL(pageUrl).hostname;
|
||||||
text: `Successfully ${actionName} site to always open in this container`
|
messageHandler.sendTabMessage(tabId, {
|
||||||
|
title: hostname,
|
||||||
|
text: actionName
|
||||||
});
|
});
|
||||||
const tab = await browser.tabs.get(tabId);
|
const tab = await browser.tabs.get(tabId);
|
||||||
this.calculateContextMenu(tab);
|
this.calculateContextMenu(tab);
|
||||||
|
|
|
@ -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) {
|
async getTabs(options) {
|
||||||
const requiredArguments = ["cookieStoreId", "windowId"];
|
const requiredArguments = ["cookieStoreId", "windowId"];
|
||||||
this.checkArgs(requiredArguments, options, "getTabs");
|
this.checkArgs(requiredArguments, options, "getTabs");
|
||||||
|
@ -329,5 +352,23 @@ const backgroundLogic = {
|
||||||
|
|
||||||
cookieStoreId(userContextId) {
|
cookieStoreId(userContextId) {
|
||||||
return `firefox-container-${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;
|
||||||
}
|
}
|
||||||
};
|
};
|
|
@ -14,6 +14,7 @@
|
||||||
]
|
]
|
||||||
-->
|
-->
|
||||||
<script type="text/javascript" src="backgroundLogic.js"></script>
|
<script type="text/javascript" src="backgroundLogic.js"></script>
|
||||||
|
<script type="text/javascript" src="recordManager.js"></script>
|
||||||
<script type="text/javascript" src="assignManager.js"></script>
|
<script type="text/javascript" src="assignManager.js"></script>
|
||||||
<script type="text/javascript" src="badge.js"></script>
|
<script type="text/javascript" src="badge.js"></script>
|
||||||
<script type="text/javascript" src="identityState.js"></script>
|
<script type="text/javascript" src="identityState.js"></script>
|
||||||
|
|
|
@ -37,6 +37,12 @@ const messageHandler = {
|
||||||
return assignManager._setOrRemoveAssignment(tab.id, m.url, m.userContextId, m.value);
|
return assignManager._setOrRemoveAssignment(tab.id, m.url, m.userContextId, m.value);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case "getRecording":
|
||||||
|
response = backgroundLogic.asPromise(recordManager.getTabId());
|
||||||
|
break;
|
||||||
|
case "setOrRemoveRecording":
|
||||||
|
response = recordManager.setTabId(m.tabId);
|
||||||
|
break;
|
||||||
case "sortTabs":
|
case "sortTabs":
|
||||||
backgroundLogic.sortTabs();
|
backgroundLogic.sortTabs();
|
||||||
break;
|
break;
|
||||||
|
@ -70,10 +76,17 @@ const messageHandler = {
|
||||||
case "exemptContainerAssignment":
|
case "exemptContainerAssignment":
|
||||||
response = assignManager._exemptTab(m);
|
response = assignManager._exemptTab(m);
|
||||||
break;
|
break;
|
||||||
|
case "invokeBrowserMethod":
|
||||||
|
response = backgroundLogic.asPromise(backgroundLogic.invokeBrowserMethod(m.name, m.args));
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Monitor browserAction popup
|
||||||
|
this.browserAction.init();
|
||||||
|
|
||||||
// Handles external messages from webextensions
|
// Handles external messages from webextensions
|
||||||
const externalExtensionAllowed = {};
|
const externalExtensionAllowed = {};
|
||||||
browser.runtime.onMessageExternal.addListener(async (message, sender) => {
|
browser.runtime.onMessageExternal.addListener(async (message, sender) => {
|
||||||
|
@ -213,6 +226,147 @@ const messageHandler = {
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
throw 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
177
src/js/background/recordManager.js
Normal file
177
src/js/background/recordManager.js
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
const recordManager = {
|
||||||
|
recording: null,
|
||||||
|
listening: null,
|
||||||
|
|
||||||
|
Recording: class {
|
||||||
|
constructor(tab) {
|
||||||
|
if (tab) {
|
||||||
|
this.windowId = tab.windowId;
|
||||||
|
this.tabId = tab.id;
|
||||||
|
this.isTabActive = tab.active;
|
||||||
|
} else {
|
||||||
|
this.windowId = browser.windows.WINDOW_ID_NONE;
|
||||||
|
this.tabId = browser.tabs.TAB_ID_NONE;
|
||||||
|
this.isTabActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get valid() {
|
||||||
|
return this.tabId !== browser.tabs.TAB_ID_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTabMessage() {
|
||||||
|
return messageHandler.sendTabMessage(this.tabId, this.tabMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
if (!this.valid) { return; }
|
||||||
|
|
||||||
|
recordManager.listening.enabled = false;
|
||||||
|
|
||||||
|
// Update GUI
|
||||||
|
this.tabMessage = { recording: false, popup: false };
|
||||||
|
const tab = await backgroundLogic.getTabOrNull(this.tabId);
|
||||||
|
// Don't try to send "stop recording" message to tab if already closed or showing an invalid page
|
||||||
|
if (tab && tab.url) {
|
||||||
|
return this.sendTabMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
if (!this.valid) { return; }
|
||||||
|
|
||||||
|
recordManager.listening.enabled = true;
|
||||||
|
|
||||||
|
// Update GUI
|
||||||
|
const baPopup = messageHandler.browserAction.popup;
|
||||||
|
const tabPopup = this.isTabActive && (!baPopup || baPopup.windowId !== this.windowId);
|
||||||
|
this.tabMessage = { recording: true, popup: tabPopup, popupOptions: {tabId: this.tabId} };
|
||||||
|
const showingPage = browser.tabs.update(this.tabId, { url: browser.runtime.getURL("/recording.html") });
|
||||||
|
const messagingTab = this.sendTabMessage();
|
||||||
|
|
||||||
|
return Promise.all([showingPage, messagingTab]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-show recording state on page load
|
||||||
|
onTabsUpdated(tabId, changeInfo) {
|
||||||
|
if (this.tabId === tabId && changeInfo.status === "complete") {
|
||||||
|
this.sendTabMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide tabPopup on this tab show/hide
|
||||||
|
onTabsActivated(activeInfo) {
|
||||||
|
if (this.tabId === activeInfo.tabId) {
|
||||||
|
this.sendTabMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep track of tab's windowId
|
||||||
|
onTabsAttached(tabId, attachInfo) {
|
||||||
|
if (this.tabId === tabId) {
|
||||||
|
this.windowId = attachInfo.newWindowId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop recording on close
|
||||||
|
onTabsRemoved(tabId) {
|
||||||
|
if (this.tabId === tabId) {
|
||||||
|
recordManager.setTabId(browser.tabs.TAB_ID_NONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide tabPopup on hide/show browserActionPopup
|
||||||
|
onToggleBrowserActionPopup(baPopupVisible, baPopup) {
|
||||||
|
if (this.windowId === baPopup.windowId && this.isTabActive) {
|
||||||
|
this.tabMessage.popup = !baPopupVisible;
|
||||||
|
this.tabMessage.popupOptions = { tabId:this.tabId, width:baPopup.width, height:baPopup.height };
|
||||||
|
this.sendTabMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
Listening: class {
|
||||||
|
constructor() {
|
||||||
|
this._enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get enabled() { return this._enabled; }
|
||||||
|
|
||||||
|
set enabled(enabled) {
|
||||||
|
if (this._enabled === !!enabled) { return; }
|
||||||
|
this._enabled = !!enabled;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
browser.tabs.onUpdated.addListener(this.onTabsUpdated, { properties: ["status"] });
|
||||||
|
browser.tabs.onActivated.addListener(this.onTabsActivated);
|
||||||
|
browser.tabs.onAttached.addListener(this.onTabsAttached);
|
||||||
|
browser.tabs.onRemoved.addListener(this.onTabsRemoved);
|
||||||
|
window.addEventListener("BrowserActionPopupLoad", this.onBrowserActionPopupLoad);
|
||||||
|
window.addEventListener("BrowserActionPopupUnload", this.onBrowserActionPopupUnload);
|
||||||
|
} else {
|
||||||
|
browser.tabs.onUpdated.removeListener(this.onTabsUpdated);
|
||||||
|
browser.tabs.onActivated.removeListener(this.onTabsActivated);
|
||||||
|
browser.tabs.onAttached.removeListener(this.onTabsAttached);
|
||||||
|
browser.tabs.onRemoved.removeListener(this.onTabsRemoved);
|
||||||
|
window.removeEventListener("BrowserActionPopupLoad", this.onBrowserActionPopupLoad);
|
||||||
|
window.removeEventListener("BrowserActionPopupUnload", this.onBrowserActionPopupUnload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTabsUpdated(...args) { recordManager.recording.onTabsUpdated(...args); }
|
||||||
|
onTabsActivated(...args) { recordManager.recording.onTabsActivated(...args); }
|
||||||
|
onTabsAttached(...args) { recordManager.recording.onTabsAttached(...args); }
|
||||||
|
onTabsRemoved(...args) { recordManager.recording.onTabsRemoved(...args); }
|
||||||
|
onBrowserActionPopupLoad() { recordManager.recording.onToggleBrowserActionPopup(true, messageHandler.browserAction.popup); }
|
||||||
|
onBrowserActionPopupUnload() { recordManager.recording.onToggleBrowserActionPopup(false, messageHandler.browserAction.popup); }
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.recording = new recordManager.Recording();
|
||||||
|
this.listening = new recordManager.Listening();
|
||||||
|
},
|
||||||
|
|
||||||
|
isRecordingTabId(tabId) {
|
||||||
|
if (!this.recording.valid) { return false; }
|
||||||
|
if (this.recording.tabId !== tabId) { return false; }
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTabId() {
|
||||||
|
return this.recording.tabId;
|
||||||
|
},
|
||||||
|
|
||||||
|
async setTabId(tabId) {
|
||||||
|
// Ensure tab is recordable
|
||||||
|
tabId = backgroundLogic.asTabId(tabId);
|
||||||
|
const tab = await backgroundLogic.getTabOrNull(tabId);
|
||||||
|
const wantRecordableTab = tabId !== browser.tabs.TAB_ID_NONE;
|
||||||
|
const isRecordableTab = tab && "cookieStoreId" in tab;
|
||||||
|
|
||||||
|
// Invalid tab - stop recording & throw error
|
||||||
|
if (wantRecordableTab && !isRecordableTab) {
|
||||||
|
this.setTabId(browser.tabs.TAB_ID_NONE); // Don't wait for stop
|
||||||
|
throw new Error(`Recording not possible for tab with id ${tabId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already recording
|
||||||
|
if (this.recording.tabId === tabId) { return; }
|
||||||
|
|
||||||
|
const oldRecording = this.recording;
|
||||||
|
const newRecording = this.recording = new recordManager.Recording(tab);
|
||||||
|
|
||||||
|
// Don't wait for stop
|
||||||
|
oldRecording.stop();
|
||||||
|
try {
|
||||||
|
// But DO wait for start
|
||||||
|
await newRecording.start();
|
||||||
|
|
||||||
|
// If error while starting, immediately stop, but don't wait
|
||||||
|
} catch (e) {
|
||||||
|
this.setTabId(browser.tabs.TAB_ID_NONE);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recordManager.init();
|
|
@ -1,46 +1,395 @@
|
||||||
async function delayAnimation(delay = 350) {
|
function asError(reason) { return reason && (reason instanceof Error) ? reason : new Error(reason); }
|
||||||
return new Promise((resolve) => {
|
function resolves(value) { return (resolve) => { resolve(value); }; }
|
||||||
setTimeout(resolve, delay);
|
// function rejects(reason) { return (resolve, reject) => { reject(asError(reason)); }; }
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doAnimation(element, property, value) {
|
// Easily build promises that:
|
||||||
return new Promise((resolve) => {
|
// 1. combine reusable behaviours (e.g. onTimeout, onEvent)
|
||||||
const handler = () => {
|
// 2. have a cleanup phase (e.g. to remove listeners)
|
||||||
resolve();
|
// 3. can be interrupted (e.g. on unload)
|
||||||
element.removeEventListener("transitionend", handler);
|
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));
|
||||||
};
|
};
|
||||||
element.addEventListener("transitionend", handler);
|
// Cleanup
|
||||||
window.requestAnimationFrame(() => {
|
}).finally(() => { if (this.completions) { this.completions.forEach((completion) => { completion(); }); } })
|
||||||
element.style[property] = value;
|
]);
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addMessage(message) {
|
async _tryHandler(handler, name, ...args) {
|
||||||
const divElement = document.createElement("div");
|
try {
|
||||||
divElement.classList.add("container-notification");
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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, so can just reuse previous request
|
||||||
|
} else {
|
||||||
|
return previous.response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick return if no request pending and element already added/removed
|
||||||
|
if (!previous) {
|
||||||
|
const element = this._get(component);
|
||||||
|
if (element) {
|
||||||
|
if (action === "add") { return new UIResponse(element); }
|
||||||
|
} else {
|
||||||
|
if (action === "remove") { return new UIResponse(null); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New request
|
||||||
|
const response = new UIResponse();
|
||||||
|
const request = new UIRequest(component, action, options, response);
|
||||||
|
|
||||||
|
// Enqueue
|
||||||
|
let previous;
|
||||||
|
if (component.unique) {
|
||||||
|
if (!requests) { requests = {}; }
|
||||||
|
previous = requests[component.name];
|
||||||
|
requests[component.name] = request;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
response.modifyingDOM = new Promise((resolve,reject) => {
|
||||||
|
const modifiedDOM = {resolve,reject};
|
||||||
|
response.animating = new Promise((resolve,reject) => {
|
||||||
|
const animated = {resolve,reject};
|
||||||
|
this._execute(request, previous, modifiedDOM, animated);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _get(component) {
|
||||||
|
const unique = component.unique;
|
||||||
|
if (!unique) { return null; }
|
||||||
|
if (unique.id) {
|
||||||
|
return document.getElementById(unique.id);
|
||||||
|
} else {
|
||||||
|
if ("querySelector" in component.parent) {
|
||||||
|
return component.parent.querySelector(unique.selector);
|
||||||
|
} else {
|
||||||
|
const parent = this._get(component.parent);
|
||||||
|
if (parent) {
|
||||||
|
return parent.querySelector(unique.selector);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async _execute(request, previous, modifiedDOM, animated) {
|
||||||
|
try {
|
||||||
|
if (previous) {
|
||||||
|
try { await previous.response.animating; } catch (e) { /* Ignore previous success/failure */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = request.component;
|
||||||
|
const options = request.options;
|
||||||
|
|
||||||
|
// Get parent
|
||||||
|
let parentElement;
|
||||||
|
if ("querySelector" in component.parent) {
|
||||||
|
parentElement = component.parent;
|
||||||
|
} else {
|
||||||
|
if (request.action === "add") {
|
||||||
|
parentElement = await this.request(component.parent, "add", options).modifyingDOM;
|
||||||
|
} else {
|
||||||
|
parentElement = this._get(component.parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let element;
|
||||||
|
|
||||||
|
// Add
|
||||||
|
if (request.action === "add") {
|
||||||
|
element = await component.create(options);
|
||||||
|
if (component.onUpdate) { await component.onUpdate(element, options); }
|
||||||
|
|
||||||
|
if (component.prepend) {
|
||||||
|
parentElement.prepend(element);
|
||||||
|
} else {
|
||||||
|
parentElement.appendChild(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
modifiedDOM.resolve(element);
|
||||||
|
|
||||||
|
if (component.onAdd) { await component.onAdd(element, options); }
|
||||||
|
|
||||||
|
// Remove
|
||||||
|
} else {
|
||||||
|
if (parentElement) {
|
||||||
|
element = this._get(component);
|
||||||
|
if (element) {
|
||||||
|
if (component.onRemove) { await component.onRemove(element, options); }
|
||||||
|
element.remove();
|
||||||
|
}
|
||||||
|
modifiedDOM.resolve(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animated.resolve(element);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
modifiedDOM.reject(e);
|
||||||
|
animated.reject(e);
|
||||||
|
} finally {
|
||||||
|
if (requests[request.component.name] === request) { requests[request.component.name] = null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UI {
|
||||||
|
static async toggle(component, show, options) {
|
||||||
|
const action = show ? "add" : "remove";
|
||||||
|
const response = UIRequestManager.request(component, action, options);
|
||||||
|
return response.animating;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Container {
|
||||||
|
static get parent() { return document.body; }
|
||||||
|
static get unique() { return { id: "container-notifications" }; }
|
||||||
|
static create() {
|
||||||
|
const elem = document.createElement("div");
|
||||||
|
elem.id = this.unique.id;
|
||||||
|
return elem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Popup {
|
||||||
|
static get parent() { return Container; }
|
||||||
|
static get unique() { return { selector: "iframe" }; }
|
||||||
|
static get prepend() { return true; }
|
||||||
|
static create(options) {
|
||||||
|
const elem = document.createElement("iframe");
|
||||||
|
elem.setAttribute("sandbox", "allow-scripts allow-same-origin");
|
||||||
|
elem.src = browser.runtime.getURL("/popup.html") + "?tabId=" + options.tabId;
|
||||||
|
return elem;
|
||||||
|
}
|
||||||
|
static onUpdate(elem, options) {
|
||||||
|
if (!options) { return; }
|
||||||
|
if (options.width) {
|
||||||
|
const width = options.width;
|
||||||
|
const height = options.height || 400;
|
||||||
|
elem.style.width = `${width}px`;
|
||||||
|
elem.style.height = `${height}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Recording {
|
||||||
|
static get parent() { return Container; }
|
||||||
|
static get unique() { return { selector: ".recording" }; }
|
||||||
|
static get prepend() { return true; }
|
||||||
|
static async create() {
|
||||||
|
const elem = await Message.create({
|
||||||
|
title: "Recording",
|
||||||
|
text: "Sites will be automatically added to this container as you browse in this tab"
|
||||||
|
});
|
||||||
|
elem.classList.add("recording");
|
||||||
|
return elem;
|
||||||
|
}
|
||||||
|
static onAdd(elem) { return Animation.toggle(elem, true); }
|
||||||
|
static onRemove(elem) { return Animation.toggle(elem, false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Message {
|
||||||
|
static get parent() { return Container; }
|
||||||
|
static async create(options) {
|
||||||
|
// Message
|
||||||
|
const msgElem = document.createElement("div");
|
||||||
|
|
||||||
|
// Text
|
||||||
// Ideally we would use https://bugzilla.mozilla.org/show_bug.cgi?id=1340930 when this is available
|
// Ideally we would use https://bugzilla.mozilla.org/show_bug.cgi?id=1340930 when this is available
|
||||||
divElement.innerText = message.text;
|
msgElem.innerText = options.text;
|
||||||
|
|
||||||
const imageElement = document.createElement("img");
|
// Title
|
||||||
|
if (options.title) {
|
||||||
|
const titleElem = document.createElement("span");
|
||||||
|
titleElem.classList.add("title");
|
||||||
|
titleElem.innerText = options.title;
|
||||||
|
msgElem.prepend(titleElem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon
|
||||||
|
const imageElem = document.createElement("div");
|
||||||
const imagePath = browser.extension.getURL("/img/container-site-d-24.png");
|
const imagePath = browser.extension.getURL("/img/container-site-d-24.png");
|
||||||
const response = await fetch(imagePath);
|
imageElem.style.background = `url("${imagePath}") no-repeat center center / cover`;
|
||||||
const blob = await response.blob();
|
imageElem.classList.add("logo");
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
msgElem.prepend(imageElem);
|
||||||
imageElement.src = objectUrl;
|
|
||||||
divElement.prepend(imageElement);
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
await delayAnimation(100);
|
// Outer container
|
||||||
await doAnimation(divElement, "transform", "translateY(0)");
|
const elem = document.createElement("div");
|
||||||
await delayAnimation(3000);
|
elem.appendChild(dummyElem);
|
||||||
await doAnimation(divElement, "transform", "translateY(-100%)");
|
elem.appendChild(realElem);
|
||||||
|
|
||||||
divElement.remove();
|
return elem;
|
||||||
|
}
|
||||||
|
static async onAdd(elem) {
|
||||||
|
await Animation.toggle(elem, true);
|
||||||
|
await Animation.delay(3000);
|
||||||
|
await Animation.toggle(elem, false);
|
||||||
|
elem.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Messages {
|
||||||
|
static async handle(message) {
|
||||||
|
let animatePopup, animateRecording, animateMessage;
|
||||||
|
if ("popup" in message) { animatePopup = UI.toggle(Popup, message.popup, message.popupOptions); }
|
||||||
|
if ("recording" in message) { animateRecording = UI.toggle(Recording, message.recording); }
|
||||||
|
if ("text" in message) { animateMessage = UI.toggle(Message, true, message); }
|
||||||
|
await Promise.all([animatePopup, animateRecording, animateMessage]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async add(message) {
|
||||||
|
return new PromiseBuilder()
|
||||||
|
.onEvent(window, "unload", (resolve, reject) => { reject("window unload", {interrupt: true}); })
|
||||||
|
.onEvent(window, "DOMContentLoaded", (resolve) => { resolve(this.handle(message)); })
|
||||||
|
.promise();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener((message) => {
|
browser.runtime.onMessage.addListener((message) => {
|
||||||
addMessage(message);
|
if (message.to === "tab") {
|
||||||
|
return Messages.add(message.content);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
58
src/js/popup-bootstrap.js
Normal file
58
src/js/popup-bootstrap.js
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
Some of the Web Extension API (e.g. tabs, contextualIdentities) is unavailable
|
||||||
|
if popup is hosted in an iframe on a web page. So must forward those calls
|
||||||
|
to (privileged) background script, so that popup can be run in an iframe.
|
||||||
|
*/
|
||||||
|
const browserAPIInjector = { // eslint-disable-line no-unused-vars
|
||||||
|
async injectAPI() {
|
||||||
|
await this.injectMethods([
|
||||||
|
"tabs.get",
|
||||||
|
"tabs.query",
|
||||||
|
"contextualIdentities.query",
|
||||||
|
"contextualIdentities.get"
|
||||||
|
]);
|
||||||
|
await this.injectConstants([
|
||||||
|
"tabs.TAB_ID_NONE",
|
||||||
|
"windows.WINDOW_ID_CURRENT"
|
||||||
|
]);
|
||||||
|
await this.injectUnimplemented([
|
||||||
|
"tabs.onUpdated.addListener",
|
||||||
|
"tabs.onUpdated.removeListener"
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
injectMethods(keys) { return this.inject(keys, "method"); },
|
||||||
|
injectConstants(keys) { return this.inject(keys, "constant"); },
|
||||||
|
injectUnimplemented(keys) { return this.inject(keys, "unimplemented"); },
|
||||||
|
|
||||||
|
async inject(keys, type) {
|
||||||
|
return Promise.all(keys.map(async (key) => {
|
||||||
|
const [object, property] = this.getComponents(key);
|
||||||
|
if (!(property in object)) {
|
||||||
|
if (type === "constant") {
|
||||||
|
object[property] = await this.invokeBrowserMethod(key);
|
||||||
|
} else if (type === "unimplemented") {
|
||||||
|
object[property] = () => {};
|
||||||
|
} else {
|
||||||
|
object[property] = async (...args) => { return this.invokeBrowserMethod(key, args); };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
getComponents(key) {
|
||||||
|
let object = browser;
|
||||||
|
let indexOfDot;
|
||||||
|
while ((indexOfDot = key.indexOf(".")) !== -1) {
|
||||||
|
const property = key.substring(0, indexOfDot);
|
||||||
|
if (!(property in object)) { object[property] = {}; }
|
||||||
|
object = object[property];
|
||||||
|
key = key.substring(indexOfDot + 1);
|
||||||
|
}
|
||||||
|
return [object, key];
|
||||||
|
},
|
||||||
|
|
||||||
|
async invokeBrowserMethod(name, args) {
|
||||||
|
return browser.runtime.sendMessage({ method:"invokeBrowserMethod", name, args });
|
||||||
|
}
|
||||||
|
};
|
240
src/js/popup.js
240
src/js/popup.js
|
@ -4,6 +4,8 @@
|
||||||
|
|
||||||
const CONTAINER_HIDE_SRC = "/img/container-hide.svg";
|
const CONTAINER_HIDE_SRC = "/img/container-hide.svg";
|
||||||
const CONTAINER_UNHIDE_SRC = "/img/container-unhide.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_COLOR = "blue";
|
||||||
const DEFAULT_ICON = "circle";
|
const DEFAULT_ICON = "circle";
|
||||||
|
@ -22,6 +24,7 @@ const P_CONTAINERS_EDIT = "containersEdit";
|
||||||
const P_CONTAINER_INFO = "containerInfo";
|
const P_CONTAINER_INFO = "containerInfo";
|
||||||
const P_CONTAINER_EDIT = "containerEdit";
|
const P_CONTAINER_EDIT = "containerEdit";
|
||||||
const P_CONTAINER_DELETE = "containerDelete";
|
const P_CONTAINER_DELETE = "containerDelete";
|
||||||
|
const P_CONTAINER_RECORD = "containerRecord";
|
||||||
const P_CONTAINERS_ACHIEVEMENT = "containersAchievement";
|
const P_CONTAINERS_ACHIEVEMENT = "containersAchievement";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -67,6 +70,25 @@ async function getExtensionInfo() {
|
||||||
return extensionInfo;
|
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.
|
// This object controls all the panels, identities and many other things.
|
||||||
const Logic = {
|
const Logic = {
|
||||||
_identities: [],
|
_identities: [],
|
||||||
|
@ -77,52 +99,62 @@ const Logic = {
|
||||||
_onboardingVariation: null,
|
_onboardingVariation: null,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
// Remove browserAction "upgraded" badge when opening panel
|
// Running in an iframe on a webpage - inject missing API methods
|
||||||
this.clearBrowserActionBadge();
|
if (!Env.hasFullBrowserAPI) {
|
||||||
|
await this.injectAPI();
|
||||||
|
}
|
||||||
|
|
||||||
|
// API methods are ready, can continue with init
|
||||||
|
const initializingPanels = this.initializePanels();
|
||||||
|
|
||||||
// Retrieve the list of identities.
|
// Retrieve the list of identities.
|
||||||
const identitiesPromise = this.refreshIdentities();
|
const identitiesPromise = this.refreshIdentities();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await identitiesPromise;
|
await identitiesPromise;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error("Failed to retrieve the identities or variation. We cannot continue. ", e.message);
|
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.
|
// Routing to the correct panel.
|
||||||
// If localStorage is disabled, we don't show the onboarding.
|
// If localStorage is disabled, we don't show the onboarding.
|
||||||
const onboardingData = await browser.storage.local.get([ONBOARDING_STORAGE_KEY]);
|
const onboardingData = await browser.storage.local.get([ONBOARDING_STORAGE_KEY]);
|
||||||
let onboarded = onboardingData[ONBOARDING_STORAGE_KEY];
|
let onboarded = onboardingData[ONBOARDING_STORAGE_KEY];
|
||||||
|
let settingOnboardingStage;
|
||||||
if (!onboarded) {
|
if (!onboarded) {
|
||||||
onboarded = 0;
|
onboarded = 0;
|
||||||
this.setOnboardingStage(onboarded);
|
settingOnboardingStage = this.setOnboardingStage(onboarded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let showingPanel;
|
||||||
switch (onboarded) {
|
switch (onboarded) {
|
||||||
case 5:
|
case 5:
|
||||||
this.showAchievementOrContainersListPanel();
|
showingPanel = this.showAchievementOrContainersListOrRecordPanel();
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 4:
|
||||||
this.showPanel(P_ONBOARDING_5);
|
showingPanel = this.showPanel(P_ONBOARDING_5);
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
this.showPanel(P_ONBOARDING_4);
|
showingPanel = this.showPanel(P_ONBOARDING_4);
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
this.showPanel(P_ONBOARDING_3);
|
showingPanel = this.showPanel(P_ONBOARDING_3);
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
this.showPanel(P_ONBOARDING_2);
|
showingPanel = this.showPanel(P_ONBOARDING_2);
|
||||||
break;
|
break;
|
||||||
case 0:
|
case 0:
|
||||||
default:
|
default:
|
||||||
this.showPanel(P_ONBOARDING_1);
|
showingPanel = this.showPanel(P_ONBOARDING_1);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Promise.all([initializingPanels, clearingBadge, settingOnboardingStage, showingPanel]);
|
||||||
},
|
},
|
||||||
|
|
||||||
async showAchievementOrContainersListPanel() {
|
async showAchievementOrContainersListOrRecordPanel() {
|
||||||
// Do we need to show an achievement panel?
|
// Do we need to show an achievement panel?
|
||||||
let showAchievements = false;
|
let showAchievements = false;
|
||||||
const achievementsStorage = await browser.storage.local.get({ achievements: [] });
|
const achievementsStorage = await browser.storage.local.get({ achievements: [] });
|
||||||
|
@ -133,9 +165,25 @@ const Logic = {
|
||||||
}
|
}
|
||||||
if (showAchievements) {
|
if (showAchievements) {
|
||||||
this.showPanel(P_CONTAINERS_ACHIEVEMENT);
|
this.showPanel(P_CONTAINERS_ACHIEVEMENT);
|
||||||
|
} else {
|
||||||
|
const currentTab = await Logic.currentTab();
|
||||||
|
const isRecordingTab = await Logic.isRecordingTab(currentTab);
|
||||||
|
if (isRecordingTab) {
|
||||||
|
this.showPanel(P_CONTAINER_RECORD);
|
||||||
} else {
|
} else {
|
||||||
this.showPanel(P_CONTAINERS_LIST);
|
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,
|
// In case the user wants to click multiple actions,
|
||||||
|
@ -160,6 +208,8 @@ const Logic = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async clearBrowserActionBadge() {
|
async clearBrowserActionBadge() {
|
||||||
|
if (!Env.isBrowserActionPopup) { return; }
|
||||||
|
|
||||||
const extensionInfo = await getExtensionInfo();
|
const extensionInfo = await getExtensionInfo();
|
||||||
const storage = await browser.storage.local.get({ browserActionBadgesClicked: [] });
|
const storage = await browser.storage.local.get({ browserActionBadgesClicked: [] });
|
||||||
browser.browserAction.setBadgeBackgroundColor({ color: null });
|
browser.browserAction.setBadgeBackgroundColor({ color: null });
|
||||||
|
@ -207,11 +257,15 @@ const Logic = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async currentTab() {
|
async currentTab() {
|
||||||
|
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 });
|
const activeTabs = await browser.tabs.query({ active: true, windowId: browser.windows.WINDOW_ID_CURRENT });
|
||||||
if (activeTabs.length > 0) {
|
if (activeTabs.length > 0) {
|
||||||
return activeTabs[0];
|
return activeTabs[0];
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async numTabs() {
|
async numTabs() {
|
||||||
|
@ -309,7 +363,14 @@ const Logic = {
|
||||||
|
|
||||||
registerPanel(panelName, panelObject) {
|
registerPanel(panelName, panelObject) {
|
||||||
this._panels[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() {
|
identities() {
|
||||||
|
@ -323,6 +384,10 @@ const Logic = {
|
||||||
return this._currentIdentity;
|
return this._currentIdentity;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setCurrentIdentity(identity) {
|
||||||
|
this._currentIdentity = identity;
|
||||||
|
},
|
||||||
|
|
||||||
currentUserContextId() {
|
currentUserContextId() {
|
||||||
const identity = Logic.currentIdentity();
|
const identity = Logic.currentIdentity();
|
||||||
return Logic.userContextId(identity.cookieStoreId);
|
return Logic.userContextId(identity.cookieStoreId);
|
||||||
|
@ -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() {
|
generateIdentityName() {
|
||||||
const defaultName = "Container #";
|
const defaultName = "Container #";
|
||||||
const ids = [];
|
const ids = [];
|
||||||
|
@ -393,7 +476,7 @@ const Logic = {
|
||||||
getCurrentPanelElement() {
|
getCurrentPanelElement() {
|
||||||
const panelItem = this._panels[this._currentPanel];
|
const panelItem = this._panels[this._currentPanel];
|
||||||
return document.querySelector(this.getPanelSelector(panelItem));
|
return document.querySelector(this.getPanelSelector(panelItem));
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// P_ONBOARDING_1: First page for Onboarding.
|
// P_ONBOARDING_1: First page for Onboarding.
|
||||||
|
@ -631,20 +714,77 @@ Logic.registerPanel(P_CONTAINERS_LIST, {
|
||||||
assignmentCheckboxElement.disabled = disabled;
|
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() {
|
async prepareCurrentTabHeader() {
|
||||||
const currentTab = await Logic.currentTab();
|
const currentTab = await Logic.currentTab();
|
||||||
const currentTabElement = document.getElementById("current-tab");
|
const currentTabElement = document.getElementById("current-tab");
|
||||||
const assignmentCheckboxElement = document.getElementById("container-page-assigned");
|
const assignmentCheckboxElement = document.getElementById("container-page-assigned");
|
||||||
|
const recordLinkElement = document.getElementById("record-link");
|
||||||
const currentTabUserContextId = Logic.userContextId(currentTab.cookieStoreId);
|
const currentTabUserContextId = Logic.userContextId(currentTab.cookieStoreId);
|
||||||
assignmentCheckboxElement.addEventListener("change", () => {
|
assignmentCheckboxElement.addEventListener("change", () => {
|
||||||
Logic.setOrRemoveAssignment(currentTab.id, currentTab.url, currentTabUserContextId, !assignmentCheckboxElement.checked);
|
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;
|
currentTabElement.hidden = !currentTab;
|
||||||
this.setupAssignmentCheckbox(false, currentTabUserContextId);
|
this.setupAssignmentCheckbox(false, currentTabUserContextId);
|
||||||
|
this.setRecordingActiveAndEnabled(false, false);
|
||||||
if (currentTab) {
|
if (currentTab) {
|
||||||
const identity = await Logic.identity(currentTab.cookieStoreId);
|
const identity = await Logic.identity(currentTab.cookieStoreId);
|
||||||
const siteSettings = await Logic.getAssignment(currentTab);
|
const siteSettings = await Logic.getAssignment(currentTab);
|
||||||
this.setupAssignmentCheckbox(siteSettings, currentTabUserContextId);
|
this.setupAssignmentCheckbox(siteSettings, currentTabUserContextId);
|
||||||
|
const isCurrentTabRecording = await Logic.isRecordingTab(currentTab);
|
||||||
|
this.setRecordingActiveAndEnabled(isCurrentTabRecording, (currentTabUserContextId !== false));
|
||||||
const currentPage = document.getElementById("current-page");
|
const currentPage = document.getElementById("current-page");
|
||||||
currentPage.innerHTML = escaped`<span class="page-title truncate-text">${currentTab.title}</span>`;
|
currentPage.innerHTML = escaped`<span class="page-title truncate-text">${currentTab.title}</span>`;
|
||||||
const favIconElement = Utils.createFavIconElement(currentTab.favIconUrl || "");
|
const favIconElement = Utils.createFavIconElement(currentTab.favIconUrl || "");
|
||||||
|
@ -1011,10 +1151,10 @@ Logic.registerPanel(P_CONTAINER_EDIT, {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
showAssignedContainers(assignments) {
|
showAssignedContainers(assignments, options = {}) {
|
||||||
const assignmentPanel = document.getElementById("edit-sites-assigned");
|
const assignmentPanel = document.getElementById(options.elementId || "edit-sites-assigned");
|
||||||
const assignmentKeys = Object.keys(assignments);
|
const assignmentKeys = assignments ? Object.keys(assignments) : [];
|
||||||
assignmentPanel.hidden = !(assignmentKeys.length > 0);
|
assignmentPanel.hidden = !(assignmentKeys.length > 0) && !options.sticky;
|
||||||
if (assignments) {
|
if (assignments) {
|
||||||
const tableElement = assignmentPanel.querySelector(".assigned-sites-list");
|
const tableElement = assignmentPanel.querySelector(".assigned-sites-list");
|
||||||
/* Remove previous assignment list,
|
/* Remove previous assignment list,
|
||||||
|
@ -1047,7 +1187,7 @@ Logic.registerPanel(P_CONTAINER_EDIT, {
|
||||||
const currentTab = await Logic.currentTab();
|
const currentTab = await Logic.currentTab();
|
||||||
Logic.setOrRemoveAssignment(currentTab.id, assumedUrl, userContextId, true);
|
Logic.setOrRemoveAssignment(currentTab.id, assumedUrl, userContextId, true);
|
||||||
delete assignments[siteKey];
|
delete assignments[siteKey];
|
||||||
that.showAssignedContainers(assignments);
|
that.showAssignedContainers(assignments, options);
|
||||||
});
|
});
|
||||||
trElement.classList.add("container-info-tab-row", "clickable");
|
trElement.classList.add("container-info-tab-row", "clickable");
|
||||||
tableElement.appendChild(trElement);
|
tableElement.appendChild(trElement);
|
||||||
|
@ -1091,7 +1231,7 @@ Logic.registerPanel(P_CONTAINER_EDIT, {
|
||||||
|
|
||||||
const userContextId = Logic.currentUserContextId();
|
const userContextId = Logic.currentUserContextId();
|
||||||
const assignments = await Logic.getAssignmentObjectByContainer(userContextId);
|
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 .panel-footer").hidden = !!userContextId;
|
||||||
|
|
||||||
document.querySelector("#edit-container-panel-name-input").value = identity.name || "";
|
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.
|
// P_CONTAINERS_ACHIEVEMENT: Page for achievement.
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ -1187,7 +1366,30 @@ Logic.registerPanel(P_CONTAINERS_ACHIEVEMENT, {
|
||||||
|
|
||||||
Logic.init();
|
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 () {
|
window.addEventListener("resize", function () {
|
||||||
|
if (popupEvents) { popupEvents.onResize(); }
|
||||||
|
|
||||||
//for overflow menu
|
//for overflow menu
|
||||||
const difference = window.innerWidth - document.body.offsetWidth;
|
const difference = window.innerWidth - document.body.offsetWidth;
|
||||||
if (difference > 2) {
|
if (difference > 2) {
|
||||||
|
|
|
@ -108,6 +108,9 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="container-panel-controls">
|
<div class="container-panel-controls">
|
||||||
|
<a href="#" class="action-link" id="record-link" title="Automatically add sites to current container">
|
||||||
|
<img class="icon" src="/img/container-record-enabled.svg" />
|
||||||
|
</a>
|
||||||
<a href="#" class="action-link" id="sort-containers-link" title="Sort tabs into container order">Sort Tabs</a>
|
<a href="#" class="action-link" id="sort-containers-link" title="Sort tabs into container order">Sort Tabs</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="scrollable panel-content" tabindex="-1">
|
<div class="scrollable panel-content" tabindex="-1">
|
||||||
|
@ -213,6 +216,26 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="hide panel container-record-panel" id="container-record-panel" tabindex="-1">
|
||||||
|
<div class="panel-header container-record-panel-header">
|
||||||
|
<span class="usercontext-icon" id="container-record-icon"></span>
|
||||||
|
<h3 id="container-record-name" class="panel-header-text container-name truncate-text"></h3>
|
||||||
|
</div>
|
||||||
|
<div class="container-record-panel-banner container-record-banner" id="container-record-banner">
|
||||||
|
<img id="container-record-icon" alt="Container Record icon" src="/img/container-record-enabled.svg" class="icon container-record-panel-record-icon"/>
|
||||||
|
<span id="container-record-label">RECORDING</span>
|
||||||
|
</div>
|
||||||
|
<div id="record-sites-assigned" class="scrollable" hidden>
|
||||||
|
<h3>Sites assigned to this container</h3>
|
||||||
|
<div class="assigned-sites-list">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-footer container-record-panel-footer">
|
||||||
|
<a href="#" id="exit-record-mode-link" class="exit-record-mode-link container-record-exit-text">
|
||||||
|
<img src="/img/container-arrow.svg"/>Exit Record Mode</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="js/utils.js"></script>
|
<script src="js/utils.js"></script>
|
||||||
<script src="js/popup.js"></script>
|
<script src="js/popup.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
11
src/recording.html
Normal file
11
src/recording.html
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="content-type" content="text/html; charset=utf-8">
|
||||||
|
<title>Multi-Account Containers Recording</title>
|
||||||
|
<link xmlns="http://www.w3.org/1999/xhtml" rel="stylesheet" href="chrome://browser/skin/aboutNetError.css" type="text/css" media="all" />
|
||||||
|
<link rel="stylesheet" href="css/content.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script src="js/content-script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
87
test/features/recording.test.js
Normal file
87
test/features/recording.test.js
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
describe("Recording Feature", () => {
|
||||||
|
const url1 = "http://example.com";
|
||||||
|
const url2 = "http://example2.com";
|
||||||
|
let recordingTab;
|
||||||
|
beforeEach(async () => {
|
||||||
|
recordingTab = await helper.browser.initializeWithTab({
|
||||||
|
cookieStoreId: "firefox-container-1",
|
||||||
|
url: url1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("click the 'Record' button in the popup", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await helper.popup.clickElementById("record-link");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("browse to a website", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await helper.browser.browseToURL(recordingTab.id, url1);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("browse to another website", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await helper.browser.browseToURL(recordingTab.id, url2);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("click the 'Exit Record Mode' button in the popup", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await helper.popup.clickElementById("exit-record-mode-link");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("in a new tab open the first website", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await helper.browser.openNewTab({
|
||||||
|
cookieStoreId: "firefox-default",
|
||||||
|
url: url1
|
||||||
|
}, {
|
||||||
|
options: {
|
||||||
|
webRequestError: true // because request is canceled due to reopening
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should open the confirm page", async () => {
|
||||||
|
// should have created a new tab with the confirm page
|
||||||
|
background.browser.tabs.create.should.have.been.calledWithMatch({
|
||||||
|
url: "moz-extension://fake/confirm-page.html?" +
|
||||||
|
`url=${encodeURIComponent(url1)}` +
|
||||||
|
`&cookieStoreId=${recordingTab.cookieStoreId}`,
|
||||||
|
cookieStoreId: undefined,
|
||||||
|
openerTabId: null,
|
||||||
|
index: 2,
|
||||||
|
active: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("in another new tab, open the second website", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await helper.browser.openNewTab({
|
||||||
|
cookieStoreId: "firefox-default",
|
||||||
|
url: url2
|
||||||
|
}, {
|
||||||
|
options: {
|
||||||
|
webRequestError: true // because request is canceled due to reopening
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should open the confirm page", async () => {
|
||||||
|
// should have created a new tab with the confirm page
|
||||||
|
background.browser.tabs.create.should.have.been.calledWithMatch({
|
||||||
|
url: "moz-extension://fake/confirm-page.html?" +
|
||||||
|
`url=${encodeURIComponent(url2)}` +
|
||||||
|
`&cookieStoreId=${recordingTab.cookieStoreId}`,
|
||||||
|
cookieStoreId: undefined,
|
||||||
|
openerTabId: null,
|
||||||
|
index: 3,
|
||||||
|
active: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -19,6 +19,9 @@ module.exports = {
|
||||||
"achievements": []
|
"achievements": []
|
||||||
});
|
});
|
||||||
window.browser.storage.local.set.resetHistory();
|
window.browser.storage.local.set.resetHistory();
|
||||||
|
window.browser.runtime.connect.returns({
|
||||||
|
postMessage: sinon.stub()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,6 +32,15 @@ module.exports = {
|
||||||
|
|
||||||
async openNewTab(tab, options = {}) {
|
async openNewTab(tab, options = {}) {
|
||||||
return background.browser.tabs._create(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;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue