forked from mirrors/gecko-dev
Bug 1885064 - Ability to send and receive close remote tab pushes r=lina,desktop-theme-reviewers,firefox-desktop-core-reviewers ,emilio,Gijs,tabbrowser-reviewers,dao
Differential Revision: https://phabricator.services.mozilla.com/D204556
This commit is contained in:
parent
c68f1faa43
commit
2cc00a036d
10 changed files with 757 additions and 22 deletions
|
|
@ -1934,6 +1934,10 @@ pref("identity.mobilepromo.ios", "https://www.mozilla.org/firefox/ios/?utm_sourc
|
||||||
// Default is 24 hours.
|
// Default is 24 hours.
|
||||||
pref("identity.fxaccounts.commands.missed.fetch_interval", 86400);
|
pref("identity.fxaccounts.commands.missed.fetch_interval", 86400);
|
||||||
|
|
||||||
|
// Controls whether this client can send and receive "close tab"
|
||||||
|
// commands from other FxA clients
|
||||||
|
pref("identity.fxaccounts.commands.remoteTabManagement.enabled", false);
|
||||||
|
|
||||||
// Note: when media.gmp-*.visible is true, provided we're running on a
|
// Note: when media.gmp-*.visible is true, provided we're running on a
|
||||||
// supported platform/OS version, the corresponding CDM appears in the
|
// supported platform/OS version, the corresponding CDM appears in the
|
||||||
// plugins list, Firefox will download the GMP/CDM if enabled, and our
|
// plugins list, Firefox will download the GMP/CDM if enabled, and our
|
||||||
|
|
|
||||||
|
|
@ -247,9 +247,30 @@ this.SyncedTabsPanelList = class SyncedTabsPanelList {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
tabs = tabs.slice(0, maxTabs);
|
tabs = tabs.slice(0, maxTabs);
|
||||||
}
|
}
|
||||||
|
// We have the client obj but we need the FxA device obj so we use the clients
|
||||||
|
// engine to get us the FxA device
|
||||||
|
let device =
|
||||||
|
fxAccounts.device.recentDeviceList &&
|
||||||
|
fxAccounts.device.recentDeviceList.find(
|
||||||
|
d =>
|
||||||
|
d.id === Weave.Service.clientsEngine.getClientFxaDeviceId(client.id)
|
||||||
|
);
|
||||||
|
let remoteTabCloseAvailable =
|
||||||
|
device && fxAccounts.commands.closeTab.isDeviceCompatible(device);
|
||||||
for (let [index, tab] of tabs.entries()) {
|
for (let [index, tab] of tabs.entries()) {
|
||||||
|
let tabEntContainer = document.createXULElement("hbox");
|
||||||
|
tabEntContainer.setAttribute("class", "PanelUI-tabitem-container");
|
||||||
|
|
||||||
let tabEnt = this._createSyncedTabElement(tab, index);
|
let tabEnt = this._createSyncedTabElement(tab, index);
|
||||||
container.appendChild(tabEnt);
|
tabEntContainer.appendChild(tabEnt);
|
||||||
|
// We should only add an X button next to tabs if the device
|
||||||
|
// is broadcasting that it can remotely close tabs
|
||||||
|
if (remoteTabCloseAvailable) {
|
||||||
|
tabEntContainer.appendChild(
|
||||||
|
this._createCloseTabElement(tab.url, device)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
container.appendChild(tabEntContainer);
|
||||||
}
|
}
|
||||||
if (numInactive) {
|
if (numInactive) {
|
||||||
let elt = this._createShowInactiveTabsElement(
|
let elt = this._createShowInactiveTabsElement(
|
||||||
|
|
@ -347,6 +368,20 @@ this.SyncedTabsPanelList = class SyncedTabsPanelList {
|
||||||
return showItem;
|
return showItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_createCloseTabElement(url, device) {
|
||||||
|
let closeBtn = document.createXULElement("image");
|
||||||
|
closeBtn.setAttribute("class", "close-icon remotetabs-close");
|
||||||
|
|
||||||
|
closeBtn.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
// The user could be hitting multiple tabs across multiple devices, with a few
|
||||||
|
// seconds in-between -- we should not immediately fire off pushes, so we
|
||||||
|
// add it to a queue and send in bulk at a later time
|
||||||
|
fxAccounts.commands.closeTab.enqueueTabToClose(device, url);
|
||||||
|
});
|
||||||
|
return closeBtn;
|
||||||
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
Services.obs.removeObserver(this, SyncedTabs.TOPIC_TABS_CHANGED);
|
Services.obs.removeObserver(this, SyncedTabs.TOPIC_TABS_CHANGED);
|
||||||
this.tabsList = null;
|
this.tabsList = null;
|
||||||
|
|
|
||||||
|
|
@ -4419,6 +4419,56 @@
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Closes tabs within the browser that match a given list of nsURIs. Returns
|
||||||
|
* any nsURIs that could not be closed successfully. This does not close any
|
||||||
|
* tabs that have a beforeUnload prompt
|
||||||
|
*
|
||||||
|
* @param {nsURI[]} urisToClose
|
||||||
|
* The set of uris to remove.
|
||||||
|
* @returns {nsURI[]}
|
||||||
|
* the nsURIs that weren't found in this browser
|
||||||
|
*/
|
||||||
|
async closeTabsByURI(urisToClose) {
|
||||||
|
let remainingURIsToClose = [...urisToClose];
|
||||||
|
let tabsToRemove = [];
|
||||||
|
for (let tab of this.tabs) {
|
||||||
|
let currentURI = tab.linkedBrowser.currentURI;
|
||||||
|
// Find any URI that matches the current tab's URI
|
||||||
|
const matchedIndex = remainingURIsToClose.findIndex(uriToClose =>
|
||||||
|
uriToClose.equals(currentURI)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchedIndex > -1) {
|
||||||
|
tabsToRemove.push(tab);
|
||||||
|
remainingURIsToClose.splice(matchedIndex, 1); // Remove the matched URI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabsToRemove.length) {
|
||||||
|
const { beforeUnloadComplete, lastToClose } = this._startRemoveTabs(
|
||||||
|
tabsToRemove,
|
||||||
|
{
|
||||||
|
animate: false,
|
||||||
|
suppressWarnAboutClosingWindow: true,
|
||||||
|
skipPermitUnload: false,
|
||||||
|
skipRemoves: false,
|
||||||
|
skipSessionStore: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for the beforeUnload handlers to complete.
|
||||||
|
await beforeUnloadComplete;
|
||||||
|
|
||||||
|
// _startRemoveTabs doesn't close the last tab in the window
|
||||||
|
// for this use case, we simply close it
|
||||||
|
if (lastToClose) {
|
||||||
|
this.removeTab(lastToClose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If we still have uris, that means we couldn't find them in this window instance
|
||||||
|
return remainingURIsToClose;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles opening a new tab with mouse middleclick.
|
* Handles opening a new tab with mouse middleclick.
|
||||||
|
|
|
||||||
|
|
@ -1149,6 +1149,9 @@ BrowserGlue.prototype = {
|
||||||
case "fxaccounts:commands:open-uri":
|
case "fxaccounts:commands:open-uri":
|
||||||
this._onDisplaySyncURIs(subject);
|
this._onDisplaySyncURIs(subject);
|
||||||
break;
|
break;
|
||||||
|
case "fxaccounts:commands:close-uri":
|
||||||
|
this._onIncomingCloseTabCommand(subject);
|
||||||
|
break;
|
||||||
case "session-save":
|
case "session-save":
|
||||||
this._setPrefToSaveSession(true);
|
this._setPrefToSaveSession(true);
|
||||||
subject.QueryInterface(Ci.nsISupportsPRBool);
|
subject.QueryInterface(Ci.nsISupportsPRBool);
|
||||||
|
|
@ -1316,6 +1319,7 @@ BrowserGlue.prototype = {
|
||||||
"fxaccounts:verify_login",
|
"fxaccounts:verify_login",
|
||||||
"fxaccounts:device_disconnected",
|
"fxaccounts:device_disconnected",
|
||||||
"fxaccounts:commands:open-uri",
|
"fxaccounts:commands:open-uri",
|
||||||
|
"fxaccounts:commands:close-uri",
|
||||||
"session-save",
|
"session-save",
|
||||||
"places-init-complete",
|
"places-init-complete",
|
||||||
"distribution-customization-complete",
|
"distribution-customization-complete",
|
||||||
|
|
@ -4785,6 +4789,32 @@ BrowserGlue.prototype = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async _onIncomingCloseTabCommand(data) {
|
||||||
|
// The payload is wrapped weirdly because of how Sync does notifications.
|
||||||
|
const wrappedObj = data.wrappedJSObject.object;
|
||||||
|
let { urls } = wrappedObj[0];
|
||||||
|
let urisToClose = [];
|
||||||
|
urls.forEach(urlString => {
|
||||||
|
try {
|
||||||
|
urisToClose.push(Services.io.newURI(urlString));
|
||||||
|
} catch (ex) {
|
||||||
|
// The url was invalid so we ignore
|
||||||
|
console.error(ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (let win of lazy.BrowserWindowTracker.orderedWindows) {
|
||||||
|
// Ensure we're operating on fully opened browser windows
|
||||||
|
if (!win.gBrowser) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
urisToClose = await win.gBrowser.closeTabsByURI(urisToClose);
|
||||||
|
// If we've successfully closed all the tabs, break early
|
||||||
|
if (!urisToClose.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async _onVerifyLoginNotification({ body, title, url }) {
|
async _onVerifyLoginNotification({ body, title, url }) {
|
||||||
let tab;
|
let tab;
|
||||||
let imageURL;
|
let imageURL;
|
||||||
|
|
|
||||||
|
|
@ -336,20 +336,28 @@ add_task(async function () {
|
||||||
node = node.firstElementChild;
|
node = node.firstElementChild;
|
||||||
is(node.getAttribute("itemtype"), "client", "node is a client entry");
|
is(node.getAttribute("itemtype"), "client", "node is a client entry");
|
||||||
is(node.textContent, "My Desktop", "correct client");
|
is(node.textContent, "My Desktop", "correct client");
|
||||||
// Next entry is the most-recent tab
|
// Next node is an hbox, that contains the tab and potentially
|
||||||
|
// a button for closing the tab remotely
|
||||||
node = node.nextElementSibling;
|
node = node.nextElementSibling;
|
||||||
is(node.getAttribute("itemtype"), "tab", "node is a tab");
|
is(node.nodeName, "hbox");
|
||||||
is(node.getAttribute("label"), "http://example.com/10");
|
// Next entry is the most-recent tab
|
||||||
|
let childNode = node.firstElementChild;
|
||||||
|
is(childNode.getAttribute("itemtype"), "tab", "node is a tab");
|
||||||
|
is(childNode.getAttribute("label"), "http://example.com/10");
|
||||||
|
|
||||||
// Next entry is the next-most-recent tab
|
// Next entry is the next-most-recent tab
|
||||||
node = node.nextElementSibling;
|
node = node.nextElementSibling;
|
||||||
is(node.getAttribute("itemtype"), "tab", "node is a tab");
|
is(node.nodeName, "hbox");
|
||||||
is(node.getAttribute("label"), "http://example.com/5");
|
childNode = node.firstElementChild;
|
||||||
|
is(childNode.getAttribute("itemtype"), "tab", "node is a tab");
|
||||||
|
is(childNode.getAttribute("label"), "http://example.com/5");
|
||||||
|
|
||||||
// Next entry is the least-recent tab from the first client.
|
// Next entry is the least-recent tab from the first client.
|
||||||
node = node.nextElementSibling;
|
node = node.nextElementSibling;
|
||||||
is(node.getAttribute("itemtype"), "tab", "node is a tab");
|
is(node.nodeName, "hbox");
|
||||||
is(node.getAttribute("label"), "http://example.com/1");
|
childNode = node.firstElementChild;
|
||||||
|
is(childNode.getAttribute("itemtype"), "tab", "node is a tab");
|
||||||
|
is(childNode.getAttribute("label"), "http://example.com/1");
|
||||||
node = node.nextElementSibling;
|
node = node.nextElementSibling;
|
||||||
is(node, null, "no more siblings");
|
is(node, null, "no more siblings");
|
||||||
|
|
||||||
|
|
@ -368,8 +376,10 @@ add_task(async function () {
|
||||||
is(node.textContent, "My Other Desktop", "correct client");
|
is(node.textContent, "My Other Desktop", "correct client");
|
||||||
// Its single tab
|
// Its single tab
|
||||||
node = node.nextElementSibling;
|
node = node.nextElementSibling;
|
||||||
is(node.getAttribute("itemtype"), "tab", "node is a tab");
|
is(node.nodeName, "hbox");
|
||||||
is(node.getAttribute("label"), "http://example.com/6");
|
childNode = node.firstElementChild;
|
||||||
|
is(childNode.getAttribute("itemtype"), "tab", "node is a tab");
|
||||||
|
is(childNode.getAttribute("label"), "http://example.com/6");
|
||||||
node = node.nextElementSibling;
|
node = node.nextElementSibling;
|
||||||
is(node, null, "no more siblings");
|
is(node, null, "no more siblings");
|
||||||
|
|
||||||
|
|
@ -479,14 +489,16 @@ add_task(async function () {
|
||||||
is(node.textContent, "My Desktop", "correct client");
|
is(node.textContent, "My Desktop", "correct client");
|
||||||
for (let i = 0; i < tabsShownCount; i++) {
|
for (let i = 0; i < tabsShownCount; i++) {
|
||||||
node = node.nextElementSibling;
|
node = node.nextElementSibling;
|
||||||
is(node.getAttribute("itemtype"), "tab", "node is a tab");
|
is(node.nodeName, "hbox");
|
||||||
|
let childNode = node.firstElementChild;
|
||||||
|
is(childNode.getAttribute("itemtype"), "tab", "node is a tab");
|
||||||
is(
|
is(
|
||||||
node.getAttribute("label"),
|
childNode.getAttribute("label"),
|
||||||
"Tab #" + (i + 1),
|
"Tab #" + (i + 1),
|
||||||
"the tab is the correct one"
|
"the tab is the correct one"
|
||||||
);
|
);
|
||||||
is(
|
is(
|
||||||
node.getAttribute("targetURI"),
|
childNode.getAttribute("targetURI"),
|
||||||
SAMPLE_TAB_URL,
|
SAMPLE_TAB_URL,
|
||||||
"url is the correct one"
|
"url is the correct one"
|
||||||
);
|
);
|
||||||
|
|
@ -509,7 +521,9 @@ add_task(async function () {
|
||||||
|
|
||||||
async function checkCanOpenURL() {
|
async function checkCanOpenURL() {
|
||||||
let tabList = document.getElementById("PanelUI-remotetabs-tabslist");
|
let tabList = document.getElementById("PanelUI-remotetabs-tabslist");
|
||||||
let node = tabList.firstElementChild.firstElementChild.nextElementSibling;
|
let node =
|
||||||
|
tabList.firstElementChild.firstElementChild.nextElementSibling
|
||||||
|
.firstElementChild;
|
||||||
let promiseTabOpened = BrowserTestUtils.waitForLocationChange(
|
let promiseTabOpened = BrowserTestUtils.waitForLocationChange(
|
||||||
gBrowser,
|
gBrowser,
|
||||||
SAMPLE_TAB_URL
|
SAMPLE_TAB_URL
|
||||||
|
|
|
||||||
|
|
@ -1299,7 +1299,7 @@ panelview .toolbarbutton-1 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.PanelUI-remotetabs-clientcontainer > toolbarbutton[itemtype="tab"],
|
.PanelUI-tabitem-container > toolbarbutton[itemtype="tab"],
|
||||||
#PanelUI-historyItems > toolbarbutton {
|
#PanelUI-historyItems > toolbarbutton {
|
||||||
list-style-image: url("chrome://global/skin/icons/defaultFavicon.svg");
|
list-style-image: url("chrome://global/skin/icons/defaultFavicon.svg");
|
||||||
-moz-context-properties: fill;
|
-moz-context-properties: fill;
|
||||||
|
|
@ -1309,7 +1309,7 @@ panelview .toolbarbutton-1 {
|
||||||
#fxa-menu-account-fxa-avatar,
|
#fxa-menu-account-fxa-avatar,
|
||||||
#appMenu-fxa-label > .toolbarbutton-icon,
|
#appMenu-fxa-label > .toolbarbutton-icon,
|
||||||
#PanelUI-containersItems > .subviewbutton > .toolbarbutton-icon,
|
#PanelUI-containersItems > .subviewbutton > .toolbarbutton-icon,
|
||||||
.PanelUI-remotetabs-clientcontainer > toolbarbutton[itemtype="tab"] > .toolbarbutton-icon,
|
.PanelUI-tabitem-container > toolbarbutton[itemtype="tab"] > .toolbarbutton-icon,
|
||||||
#PanelUI-recentlyClosedWindows > toolbarbutton > .toolbarbutton-icon,
|
#PanelUI-recentlyClosedWindows > toolbarbutton > .toolbarbutton-icon,
|
||||||
#PanelUI-recentlyClosedTabs > toolbarbutton > .toolbarbutton-icon,
|
#PanelUI-recentlyClosedTabs > toolbarbutton > .toolbarbutton-icon,
|
||||||
#PanelUI-historyItems > toolbarbutton > .toolbarbutton-icon {
|
#PanelUI-historyItems > toolbarbutton > .toolbarbutton-icon {
|
||||||
|
|
@ -1321,6 +1321,15 @@ panelview .toolbarbutton-1 {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.PanelUI-tabitem-container > toolbarbutton[itemtype="tab"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remotetabs-close {
|
||||||
|
width: 18px;
|
||||||
|
margin-inline-end: 4px;
|
||||||
|
}
|
||||||
#PanelUI-fxa-menu-account-settings-button > .toolbarbutton-icon {
|
#PanelUI-fxa-menu-account-settings-button > .toolbarbutton-icon {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,14 @@
|
||||||
import {
|
import {
|
||||||
COMMAND_SENDTAB,
|
COMMAND_SENDTAB,
|
||||||
COMMAND_SENDTAB_TAIL,
|
COMMAND_SENDTAB_TAIL,
|
||||||
|
COMMAND_CLOSETAB,
|
||||||
|
COMMAND_CLOSETAB_TAIL,
|
||||||
SCOPE_OLD_SYNC,
|
SCOPE_OLD_SYNC,
|
||||||
log,
|
log,
|
||||||
} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
|
} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
|
||||||
|
|
||||||
|
import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
|
||||||
|
|
||||||
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
|
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
|
||||||
|
|
||||||
import { Observers } from "resource://services-common/observers.sys.mjs";
|
import { Observers } from "resource://services-common/observers.sys.mjs";
|
||||||
|
|
@ -36,18 +40,32 @@ export class FxAccountsCommands {
|
||||||
constructor(fxAccountsInternal) {
|
constructor(fxAccountsInternal) {
|
||||||
this._fxai = fxAccountsInternal;
|
this._fxai = fxAccountsInternal;
|
||||||
this.sendTab = new SendTab(this, fxAccountsInternal);
|
this.sendTab = new SendTab(this, fxAccountsInternal);
|
||||||
|
this.closeTab = new CloseRemoteTab(this, fxAccountsInternal);
|
||||||
this._invokeRateLimitExpiry = 0;
|
this._invokeRateLimitExpiry = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async availableCommands() {
|
async availableCommands() {
|
||||||
|
// Invalid keys usually means the account is not verified yet.
|
||||||
const encryptedSendTabKeys = await this.sendTab.getEncryptedSendTabKeys();
|
const encryptedSendTabKeys = await this.sendTab.getEncryptedSendTabKeys();
|
||||||
if (!encryptedSendTabKeys) {
|
let commands = {};
|
||||||
// This will happen if the account is not verified yet.
|
|
||||||
return {};
|
if (encryptedSendTabKeys) {
|
||||||
|
commands[COMMAND_SENDTAB] = encryptedSendTabKeys;
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
[COMMAND_SENDTAB]: encryptedSendTabKeys,
|
// Close Tab is still a worked-on feature, so we should not broadcast it widely yet
|
||||||
};
|
let closeTabEnabled = Services.prefs.getBoolPref(
|
||||||
|
"identity.fxaccounts.commands.remoteTabManagement.enabled",
|
||||||
|
false
|
||||||
|
);
|
||||||
|
if (closeTabEnabled) {
|
||||||
|
const encryptedCloseTabKeys =
|
||||||
|
await this.closeTab.getEncryptedCloseTabKeys();
|
||||||
|
if (encryptedCloseTabKeys) {
|
||||||
|
commands[COMMAND_CLOSETAB] = encryptedCloseTabKeys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return commands;
|
||||||
}
|
}
|
||||||
|
|
||||||
async invoke(command, device, payload) {
|
async invoke(command, device, payload) {
|
||||||
|
|
@ -166,6 +184,7 @@ export class FxAccountsCommands {
|
||||||
}
|
}
|
||||||
// We debounce multiple incoming tabs so we show a single notification.
|
// We debounce multiple incoming tabs so we show a single notification.
|
||||||
const tabsReceived = [];
|
const tabsReceived = [];
|
||||||
|
const tabsToClose = [];
|
||||||
for (const { index, data } of messages) {
|
for (const { index, data } of messages) {
|
||||||
const { command, payload, sender: senderId } = data;
|
const { command, payload, sender: senderId } = data;
|
||||||
const reason = this._getReason(notifiedIndex, index);
|
const reason = this._getReason(notifiedIndex, index);
|
||||||
|
|
@ -179,6 +198,24 @@ export class FxAccountsCommands {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
switch (command) {
|
switch (command) {
|
||||||
|
case COMMAND_CLOSETAB:
|
||||||
|
try {
|
||||||
|
const { urls } = await this.closeTab.handleTabClose(
|
||||||
|
senderId,
|
||||||
|
payload,
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
log.info(
|
||||||
|
`Close Tab received with FxA commands: "${urls.length} tabs"
|
||||||
|
from ${sender ? sender.name : "Unknown device"}.`
|
||||||
|
);
|
||||||
|
// URLs are PII, so only logged at trace.
|
||||||
|
log.trace(`Close Remote Tabs received URLs: ${urls}`);
|
||||||
|
tabsToClose.push({ urls, sender });
|
||||||
|
} catch (e) {
|
||||||
|
log.error(`Error while handling incoming Close Tab payload.`, e);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case COMMAND_SENDTAB:
|
case COMMAND_SENDTAB:
|
||||||
try {
|
try {
|
||||||
const { title, uri } = await this.sendTab.handle(
|
const { title, uri } = await this.sendTab.handle(
|
||||||
|
|
@ -212,11 +249,18 @@ export class FxAccountsCommands {
|
||||||
if (tabsReceived.length) {
|
if (tabsReceived.length) {
|
||||||
this._notifyFxATabsReceived(tabsReceived);
|
this._notifyFxATabsReceived(tabsReceived);
|
||||||
}
|
}
|
||||||
|
if (tabsToClose.length) {
|
||||||
|
this._notifyFxATabsClosed(tabsToClose);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_notifyFxATabsReceived(tabsReceived) {
|
_notifyFxATabsReceived(tabsReceived) {
|
||||||
Observers.notify("fxaccounts:commands:open-uri", tabsReceived);
|
Observers.notify("fxaccounts:commands:open-uri", tabsReceived);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_notifyFxATabsClosed(tabsToClose) {
|
||||||
|
Observers.notify("fxaccounts:commands:close-uri", tabsToClose);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -458,6 +502,286 @@ export class SendTab {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close Tabs is built on-top of device commands and handles
|
||||||
|
* actions a client wants to perform on tabs found on other devices
|
||||||
|
* This class is very similar to the Send Tab component in FxAccountsCommands
|
||||||
|
*
|
||||||
|
* Devices exchange keys wrapped in the oldsync key between themselves (getEncryptedCloseTabKeys)
|
||||||
|
* during the device registration flow. The FxA server can theoretically never
|
||||||
|
* retrieve the close tab keys since it doesn't know the oldsync key.
|
||||||
|
*
|
||||||
|
* Note: Close Tabs does things slightly different from SendTab
|
||||||
|
* The sender encrypts the close-tab command using the receiver's public key,
|
||||||
|
* and the FxA server stores it (without re-encrypting).
|
||||||
|
* A web-push notifies the receiver that a new command is available.
|
||||||
|
* The receiver decrypts the payload using its private key.
|
||||||
|
*/
|
||||||
|
export class CloseRemoteTab {
|
||||||
|
constructor(commands, fxAccountsInternal) {
|
||||||
|
this._commands = commands;
|
||||||
|
this._fxai = fxAccountsInternal;
|
||||||
|
this.pendingClosedTabs = new Map();
|
||||||
|
// pushes happen per device, making a timer per device makes sending
|
||||||
|
// the pushes a little more sane
|
||||||
|
this.pushTimers = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sending a push everytime the user wants to close a tab on a remote device
|
||||||
|
* could lead to excessive notifications to the users device, push throttling, etc
|
||||||
|
* so we add the tabs to a queue and have a timer that sends the push after a certain
|
||||||
|
* amount of "inactivity"
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @param {Device} targetDevice - Device object (typically returned by fxAccounts.getDevicesList()).
|
||||||
|
* @param {String} tab - url for the tab to close
|
||||||
|
* @param {Integer} how far to delay, in miliseconds, the push for this timer
|
||||||
|
*/
|
||||||
|
enqueueTabToClose(targetDevice, tab, pushDelay = 6000) {
|
||||||
|
if (this.pendingClosedTabs.has(targetDevice.id)) {
|
||||||
|
this.pendingClosedTabs.get(targetDevice.id).tabs.push(tab);
|
||||||
|
} else {
|
||||||
|
this.pendingClosedTabs.set(targetDevice.id, {
|
||||||
|
device: targetDevice,
|
||||||
|
tabs: [tab],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// extend the timer
|
||||||
|
this._refreshPushTimer(targetDevice.id, pushDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _refreshPushTimer(deviceId, pushDelay) {
|
||||||
|
// If the user is still performing "actions" for this device
|
||||||
|
// reset the timer to send the push
|
||||||
|
if (this.pushTimers.has(deviceId)) {
|
||||||
|
clearTimeout(this.pushTimers.get(deviceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// There is a possibility that the browser closes before this actually executes
|
||||||
|
// we should catch the browser as it's closing and immediately fire these
|
||||||
|
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1888299
|
||||||
|
const timerId = setTimeout(async () => {
|
||||||
|
let { device, tabs } = this.pendingClosedTabs.get(deviceId);
|
||||||
|
// send a push notification for this specific device
|
||||||
|
await this._sendCloseTabPush(device, tabs);
|
||||||
|
|
||||||
|
// Clear the timer
|
||||||
|
this.pushTimers.delete(deviceId);
|
||||||
|
// We also need to locally store the tabs we sent so the user doesn't
|
||||||
|
// see these anymore
|
||||||
|
this.pendingClosedTabs.delete(deviceId);
|
||||||
|
|
||||||
|
// This is used for tests only, to get around timer woes
|
||||||
|
Observers.notify("test:fxaccounts:commands:close-uri:sent");
|
||||||
|
}, pushDelay);
|
||||||
|
|
||||||
|
// Store the new timer with the device
|
||||||
|
this.pushTimers.set(deviceId, timerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Device} target - Device object (typically returned by fxAccounts.getDevicesList()).
|
||||||
|
* @param {String[]} urls - array of urls that should be closed on the remote device
|
||||||
|
*/
|
||||||
|
async _sendCloseTabPush(target, urls) {
|
||||||
|
log.info(`Sending tab closures to ${target.id} device.`);
|
||||||
|
const flowID = this._fxai.telemetry.generateFlowID();
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
try {
|
||||||
|
const streamID = this._fxai.telemetry.generateFlowID();
|
||||||
|
const targetData = { flowID, streamID, urls };
|
||||||
|
const bytes = encoder.encode(JSON.stringify(targetData));
|
||||||
|
const encrypted = await this._encrypt(bytes, target);
|
||||||
|
// FxA expects an object as the payload, but we only have a single encrypted string; wrap it.
|
||||||
|
// If you add any plaintext items to this payload, please carefully consider the privacy implications
|
||||||
|
// of revealing that data to the FxA server.
|
||||||
|
const payload = { encrypted };
|
||||||
|
await this._commands.invoke(COMMAND_CLOSETAB, target, payload);
|
||||||
|
this._fxai.telemetry.recordEvent(
|
||||||
|
"command-sent",
|
||||||
|
COMMAND_CLOSETAB_TAIL,
|
||||||
|
this._fxai.telemetry.sanitizeDeviceId(target.id),
|
||||||
|
{ flowID, streamID }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// We should also show the user there was some kind've error
|
||||||
|
log.error("Error while invoking a send tab command.", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if the target device is compatible with FxA Commands Send tab.
|
||||||
|
isDeviceCompatible(device) {
|
||||||
|
let pref = Services.prefs.getBoolPref(
|
||||||
|
"identity.fxaccounts.commands.remoteTabManagement.enabled",
|
||||||
|
false
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
pref &&
|
||||||
|
device.availableCommands &&
|
||||||
|
device.availableCommands[COMMAND_CLOSETAB]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle incoming remote tab payload, called by FxAccountsCommands.
|
||||||
|
async handleTabClose(senderID, { encrypted }, reason) {
|
||||||
|
const bytes = await this._decrypt(encrypted);
|
||||||
|
const decoder = new TextDecoder("utf8");
|
||||||
|
const data = JSON.parse(decoder.decode(bytes));
|
||||||
|
// urls is an array of strings
|
||||||
|
const { flowID, streamID, urls } = data;
|
||||||
|
this._fxai.telemetry.recordEvent(
|
||||||
|
"command-received",
|
||||||
|
COMMAND_CLOSETAB_TAIL,
|
||||||
|
this._fxai.telemetry.sanitizeDeviceId(senderID),
|
||||||
|
{ flowID, streamID, reason }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
urls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async _encrypt(bytes, device) {
|
||||||
|
let bundle = device.availableCommands[COMMAND_CLOSETAB];
|
||||||
|
if (!bundle) {
|
||||||
|
throw new Error(`Device ${device.id} does not have close tab keys.`);
|
||||||
|
}
|
||||||
|
const oldsyncKey = await this._fxai.keys.getKeyForScope(SCOPE_OLD_SYNC);
|
||||||
|
const json = JSON.parse(bundle);
|
||||||
|
const wrapper = new lazy.CryptoWrapper();
|
||||||
|
wrapper.deserialize({ payload: json });
|
||||||
|
const syncKeyBundle = lazy.BulkKeyBundle.fromJWK(oldsyncKey);
|
||||||
|
let { publicKey, authSecret } = await wrapper.decrypt(syncKeyBundle);
|
||||||
|
authSecret = urlsafeBase64Decode(authSecret);
|
||||||
|
publicKey = urlsafeBase64Decode(publicKey);
|
||||||
|
|
||||||
|
const { ciphertext: encrypted } = await lazy.PushCrypto.encrypt(
|
||||||
|
bytes,
|
||||||
|
publicKey,
|
||||||
|
authSecret
|
||||||
|
);
|
||||||
|
return urlsafeBase64Encode(encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getPersistedCloseTabKeys() {
|
||||||
|
const { device } = await this._fxai.getUserAccountData(["device"]);
|
||||||
|
return device && device.closeTabKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _decrypt(ciphertext) {
|
||||||
|
let { privateKey, publicKey, authSecret } =
|
||||||
|
await this._getPersistedCloseTabKeys();
|
||||||
|
publicKey = urlsafeBase64Decode(publicKey);
|
||||||
|
authSecret = urlsafeBase64Decode(authSecret);
|
||||||
|
ciphertext = new Uint8Array(urlsafeBase64Decode(ciphertext));
|
||||||
|
return lazy.PushCrypto.decrypt(
|
||||||
|
privateKey,
|
||||||
|
publicKey,
|
||||||
|
authSecret,
|
||||||
|
// The only Push encoding we support.
|
||||||
|
{ encoding: "aes128gcm" },
|
||||||
|
ciphertext
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _generateAndPersistCloseTabKeys() {
|
||||||
|
let [publicKey, privateKey] = await lazy.PushCrypto.generateKeys();
|
||||||
|
publicKey = urlsafeBase64Encode(publicKey);
|
||||||
|
let authSecret = lazy.PushCrypto.generateAuthenticationSecret();
|
||||||
|
authSecret = urlsafeBase64Encode(authSecret);
|
||||||
|
const closeTabKeys = {
|
||||||
|
publicKey,
|
||||||
|
privateKey,
|
||||||
|
authSecret,
|
||||||
|
};
|
||||||
|
await this._fxai.withCurrentAccountState(async state => {
|
||||||
|
const { device } = await state.getUserAccountData(["device"]);
|
||||||
|
await state.updateUserAccountData({
|
||||||
|
device: {
|
||||||
|
...device,
|
||||||
|
closeTabKeys,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return closeTabKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getPersistedEncryptedCloseTabKey() {
|
||||||
|
const { encryptedCloseTabKeys } = await this._fxai.getUserAccountData([
|
||||||
|
"encryptedCloseTabKeys",
|
||||||
|
]);
|
||||||
|
return encryptedCloseTabKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _generateAndPersistEncryptedCloseTabKeys() {
|
||||||
|
let closeTabKeys = await this._getPersistedCloseTabKeys();
|
||||||
|
if (!closeTabKeys) {
|
||||||
|
log.info("Could not find closeTab keys, generating them");
|
||||||
|
closeTabKeys = await this._generateAndPersistCloseTabKeys();
|
||||||
|
}
|
||||||
|
// Strip the private key from the bundle to encrypt.
|
||||||
|
const keyToEncrypt = {
|
||||||
|
publicKey: closeTabKeys.publicKey,
|
||||||
|
authSecret: closeTabKeys.authSecret,
|
||||||
|
};
|
||||||
|
if (!(await this._fxai.keys.canGetKeyForScope(SCOPE_OLD_SYNC))) {
|
||||||
|
log.info("Can't fetch keys, so unable to determine closeTab keys");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let oldsyncKey;
|
||||||
|
try {
|
||||||
|
oldsyncKey = await this._fxai.keys.getKeyForScope(SCOPE_OLD_SYNC);
|
||||||
|
} catch (ex) {
|
||||||
|
log.warn(
|
||||||
|
"Failed to fetch keys, so unable to determine closeTab keys",
|
||||||
|
ex
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const wrapper = new lazy.CryptoWrapper();
|
||||||
|
wrapper.cleartext = keyToEncrypt;
|
||||||
|
const keyBundle = lazy.BulkKeyBundle.fromJWK(oldsyncKey);
|
||||||
|
await wrapper.encrypt(keyBundle);
|
||||||
|
const encryptedCloseTabKeys = JSON.stringify({
|
||||||
|
// This is expected in hex, due to pre-JWK sync key ids :-(
|
||||||
|
kid: this._fxai.keys.kidAsHex(oldsyncKey),
|
||||||
|
IV: wrapper.IV,
|
||||||
|
hmac: wrapper.hmac,
|
||||||
|
ciphertext: wrapper.ciphertext,
|
||||||
|
});
|
||||||
|
await this._fxai.withCurrentAccountState(async state => {
|
||||||
|
await state.updateUserAccountData({
|
||||||
|
encryptedCloseTabKeys,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return encryptedCloseTabKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEncryptedCloseTabKeys() {
|
||||||
|
let encryptedCloseTabKeys = await this._getPersistedEncryptedCloseTabKey();
|
||||||
|
const closeTabKeys = await this._getPersistedCloseTabKeys();
|
||||||
|
if (!encryptedCloseTabKeys || !closeTabKeys) {
|
||||||
|
log.info("Generating and persisting encrypted closeTab keys");
|
||||||
|
// `_generateAndPersistEncryptedCloseTabKeys` requires the sync key
|
||||||
|
// which cannot be accessed if the login manager is locked
|
||||||
|
// (i.e when the primary password is locked) or if the sync keys
|
||||||
|
// aren't accessible (account isn't verified)
|
||||||
|
// so this function could fail to retrieve the keys
|
||||||
|
// however, device registration will trigger when the account
|
||||||
|
// is verified, so it's OK
|
||||||
|
// Note that it's okay to persist those keys, because they are
|
||||||
|
// already persisted in plaintext and the encrypted bundle
|
||||||
|
// does not include the sync-key (the sync key is used to encrypt
|
||||||
|
// it though)
|
||||||
|
encryptedCloseTabKeys =
|
||||||
|
await this._generateAndPersistEncryptedCloseTabKeys();
|
||||||
|
}
|
||||||
|
return encryptedCloseTabKeys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function urlsafeBase64Encode(buffer) {
|
function urlsafeBase64Encode(buffer) {
|
||||||
return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });
|
return ChromeUtils.base64URLEncode(new Uint8Array(buffer), { pad: false });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,9 @@ export let COMMAND_PREFIX = "https://identity.mozilla.com/cmd/";
|
||||||
// The commands we support - only the _TAIL values are recorded in telemetry.
|
// The commands we support - only the _TAIL values are recorded in telemetry.
|
||||||
export let COMMAND_SENDTAB_TAIL = "open-uri";
|
export let COMMAND_SENDTAB_TAIL = "open-uri";
|
||||||
export let COMMAND_SENDTAB = COMMAND_PREFIX + COMMAND_SENDTAB_TAIL;
|
export let COMMAND_SENDTAB = COMMAND_PREFIX + COMMAND_SENDTAB_TAIL;
|
||||||
|
// A command to close a tab on this device
|
||||||
|
export let COMMAND_CLOSETAB_TAIL = "close-uri/v1";
|
||||||
|
export let COMMAND_CLOSETAB = COMMAND_PREFIX + COMMAND_CLOSETAB_TAIL;
|
||||||
|
|
||||||
// OAuth
|
// OAuth
|
||||||
export let FX_OAUTH_CLIENT_ID = "5882386c6d801776";
|
export let FX_OAUTH_CLIENT_ID = "5882386c6d801776";
|
||||||
|
|
@ -266,6 +269,7 @@ export let FXA_PWDMGR_PLAINTEXT_FIELDS = new Set([
|
||||||
"device",
|
"device",
|
||||||
"profileCache",
|
"profileCache",
|
||||||
"encryptedSendTabKeys",
|
"encryptedSendTabKeys",
|
||||||
|
"encryptedCloseTabKeys",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Fields we store in secure storage if it exists.
|
// Fields we store in secure storage if it exists.
|
||||||
|
|
|
||||||
263
services/fxaccounts/tests/xpcshell/test_commands_closetab.js
Normal file
263
services/fxaccounts/tests/xpcshell/test_commands_closetab.js
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
/* Any copyright is dedicated to the Public Domain.
|
||||||
|
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const { CloseRemoteTab } = ChromeUtils.importESModule(
|
||||||
|
"resource://gre/modules/FxAccountsCommands.sys.mjs"
|
||||||
|
);
|
||||||
|
|
||||||
|
const { COMMAND_CLOSETAB, COMMAND_CLOSETAB_TAIL } = ChromeUtils.importESModule(
|
||||||
|
"resource://gre/modules/FxAccountsCommon.sys.mjs"
|
||||||
|
);
|
||||||
|
|
||||||
|
class TelemetryMock {
|
||||||
|
constructor() {
|
||||||
|
this._events = [];
|
||||||
|
this._uuid_counter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
recordEvent(object, method, value, extra = undefined) {
|
||||||
|
this._events.push({ object, method, value, extra });
|
||||||
|
}
|
||||||
|
|
||||||
|
generateFlowID() {
|
||||||
|
this._uuid_counter += 1;
|
||||||
|
return this._uuid_counter.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizeDeviceId(id) {
|
||||||
|
return id + "-san";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function FxaInternalMock() {
|
||||||
|
return {
|
||||||
|
telemetry: new TelemetryMock(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function promiseObserver(topic) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let obs = (aSubject, aTopic) => {
|
||||||
|
Services.obs.removeObserver(obs, aTopic);
|
||||||
|
resolve(aSubject);
|
||||||
|
};
|
||||||
|
Services.obs.addObserver(obs, topic);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
add_task(async function test_closetab_isDeviceCompatible() {
|
||||||
|
const closeTab = new CloseRemoteTab(null, null);
|
||||||
|
let device = { name: "My device" };
|
||||||
|
Assert.ok(!closeTab.isDeviceCompatible(device));
|
||||||
|
device = { name: "My device", availableCommands: {} };
|
||||||
|
Assert.ok(!closeTab.isDeviceCompatible(device));
|
||||||
|
device = {
|
||||||
|
name: "My device",
|
||||||
|
availableCommands: {
|
||||||
|
"https://identity.mozilla.com/cmd/close-uri/v1": "payload",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Even though the command is available, we're keeping this feature behind a feature
|
||||||
|
// flag for now, so it should still show up as "not available"
|
||||||
|
Assert.ok(!closeTab.isDeviceCompatible(device));
|
||||||
|
|
||||||
|
// Enable the feature
|
||||||
|
Services.prefs.setBoolPref(
|
||||||
|
"identity.fxaccounts.commands.remoteTabManagement.enabled",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
Assert.ok(closeTab.isDeviceCompatible(device));
|
||||||
|
|
||||||
|
// clear it for the next test
|
||||||
|
Services.prefs.clearUserPref(
|
||||||
|
"identity.fxaccounts.commands.remoteTabManagement.enabled"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
add_task(async function test_closetab_send() {
|
||||||
|
const commands = {
|
||||||
|
invoke: sinon.spy((cmd, device, payload) => {
|
||||||
|
Assert.equal(payload.encrypted, "encryptedpayload");
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const fxai = FxaInternalMock();
|
||||||
|
const closeTab = new CloseRemoteTab(commands, fxai);
|
||||||
|
closeTab._encrypt = async () => {
|
||||||
|
return "encryptedpayload";
|
||||||
|
};
|
||||||
|
const targetDevice = { id: "dev1", name: "Device 1" };
|
||||||
|
const tab = { url: "https://foo.bar/" };
|
||||||
|
|
||||||
|
// We add a 0 delay so we can "send" the push immediately
|
||||||
|
closeTab.enqueueTabToClose(targetDevice, tab, 0);
|
||||||
|
|
||||||
|
// We have a tab queued
|
||||||
|
Assert.equal(closeTab.pendingClosedTabs.get(targetDevice.id).tabs.length, 1);
|
||||||
|
|
||||||
|
// Wait on the notification to ensure the push sent
|
||||||
|
await promiseObserver("test:fxaccounts:commands:close-uri:sent");
|
||||||
|
|
||||||
|
// The push has been sent, we should not have the tabs anymore
|
||||||
|
Assert.equal(
|
||||||
|
closeTab.pendingClosedTabs.has(targetDevice.id),
|
||||||
|
false,
|
||||||
|
"The device should be removed from the queue after sending."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Telemetry shows we sent one successfully
|
||||||
|
Assert.deepEqual(fxai.telemetry._events, [
|
||||||
|
{
|
||||||
|
object: "command-sent",
|
||||||
|
method: COMMAND_CLOSETAB_TAIL,
|
||||||
|
value: "dev1-san",
|
||||||
|
// streamID uses the same generator as flowId, so it will be 2
|
||||||
|
extra: { flowID: "1", streamID: "2" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
add_task(async function test_multiple_tabs_one_device() {
|
||||||
|
const commands = sinon.stub({
|
||||||
|
invoke: async () => {},
|
||||||
|
});
|
||||||
|
const fxai = FxaInternalMock();
|
||||||
|
const closeTab = new CloseRemoteTab(commands, fxai);
|
||||||
|
closeTab._encrypt = async () => "encryptedpayload";
|
||||||
|
|
||||||
|
const targetDevice = {
|
||||||
|
id: "dev1",
|
||||||
|
name: "Device 1",
|
||||||
|
availableCommands: { [COMMAND_CLOSETAB]: "payload" },
|
||||||
|
};
|
||||||
|
const tab1 = { url: "https://foo.bar/" };
|
||||||
|
const tab2 = { url: "https://example.com/" };
|
||||||
|
|
||||||
|
closeTab.enqueueTabToClose(targetDevice, tab1, 1000);
|
||||||
|
closeTab.enqueueTabToClose(targetDevice, tab2, 0);
|
||||||
|
|
||||||
|
// We have two tabs queued
|
||||||
|
Assert.equal(closeTab.pendingClosedTabs.get("dev1").tabs.length, 2);
|
||||||
|
|
||||||
|
// Wait on the notification to ensure the push sent
|
||||||
|
await promiseObserver("test:fxaccounts:commands:close-uri:sent");
|
||||||
|
|
||||||
|
Assert.equal(
|
||||||
|
closeTab.pendingClosedTabs.has(targetDevice.id),
|
||||||
|
false,
|
||||||
|
"The device should be removed from the queue after sending."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Telemetry shows we sent one successfully
|
||||||
|
Assert.deepEqual(fxai.telemetry._events, [
|
||||||
|
{
|
||||||
|
object: "command-sent",
|
||||||
|
method: COMMAND_CLOSETAB_TAIL,
|
||||||
|
value: "dev1-san",
|
||||||
|
extra: { flowID: "1", streamID: "2" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
add_task(async function test_timer_reset_on_new_tab() {
|
||||||
|
const commands = sinon.stub({
|
||||||
|
invoke: async () => {},
|
||||||
|
});
|
||||||
|
const fxai = FxaInternalMock();
|
||||||
|
const closeTab = new CloseRemoteTab(commands, fxai);
|
||||||
|
closeTab._encrypt = async () => "encryptedpayload";
|
||||||
|
|
||||||
|
const targetDevice = {
|
||||||
|
id: "dev1",
|
||||||
|
name: "Device 1",
|
||||||
|
availableCommands: { [COMMAND_CLOSETAB]: "payload" },
|
||||||
|
};
|
||||||
|
const tab1 = { url: "https://foo.bar/" };
|
||||||
|
const tab2 = { url: "https://example.com/" };
|
||||||
|
|
||||||
|
// default wait is 6s
|
||||||
|
closeTab.enqueueTabToClose(targetDevice, tab1);
|
||||||
|
|
||||||
|
Assert.equal(closeTab.pendingClosedTabs.get(targetDevice.id).tabs.length, 1);
|
||||||
|
|
||||||
|
// Adds a new tab and should reset timer
|
||||||
|
closeTab.enqueueTabToClose(targetDevice, tab2, 100);
|
||||||
|
|
||||||
|
// We have two tabs queued
|
||||||
|
Assert.equal(closeTab.pendingClosedTabs.get(targetDevice.id).tabs.length, 2);
|
||||||
|
|
||||||
|
// Wait on the notification to ensure the push sent
|
||||||
|
await promiseObserver("test:fxaccounts:commands:close-uri:sent");
|
||||||
|
|
||||||
|
// We only sent one push
|
||||||
|
sinon.assert.calledOnce(commands.invoke);
|
||||||
|
Assert.equal(closeTab.pendingClosedTabs.has(targetDevice.id), false);
|
||||||
|
|
||||||
|
// Telemetry shows we sent only one
|
||||||
|
Assert.deepEqual(fxai.telemetry._events, [
|
||||||
|
{
|
||||||
|
object: "command-sent",
|
||||||
|
method: COMMAND_CLOSETAB_TAIL,
|
||||||
|
value: "dev1-san",
|
||||||
|
extra: { flowID: "1", streamID: "2" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
add_task(async function test_multiple_devices() {
|
||||||
|
const commands = sinon.stub({
|
||||||
|
invoke: async () => {},
|
||||||
|
});
|
||||||
|
const fxai = FxaInternalMock();
|
||||||
|
const closeTab = new CloseRemoteTab(commands, fxai);
|
||||||
|
closeTab._encrypt = async () => "encryptedpayload";
|
||||||
|
|
||||||
|
const device1 = {
|
||||||
|
id: "dev1",
|
||||||
|
name: "Device 1",
|
||||||
|
availableCommands: { [COMMAND_CLOSETAB]: "payload" },
|
||||||
|
};
|
||||||
|
const device2 = {
|
||||||
|
id: "dev2",
|
||||||
|
name: "Device 2",
|
||||||
|
availableCommands: { [COMMAND_CLOSETAB]: "payload" },
|
||||||
|
};
|
||||||
|
const tab1 = { url: "https://foo.bar/" };
|
||||||
|
const tab2 = { url: "https://example.com/" };
|
||||||
|
|
||||||
|
closeTab.enqueueTabToClose(device1, tab1, 100);
|
||||||
|
closeTab.enqueueTabToClose(device2, tab2, 200);
|
||||||
|
|
||||||
|
Assert.equal(closeTab.pendingClosedTabs.get(device1.id).tabs.length, 1);
|
||||||
|
Assert.equal(closeTab.pendingClosedTabs.get(device2.id).tabs.length, 1);
|
||||||
|
|
||||||
|
// observe the notification to ensure the push sent
|
||||||
|
await promiseObserver("test:fxaccounts:commands:close-uri:sent");
|
||||||
|
|
||||||
|
// We should have only sent the first device
|
||||||
|
sinon.assert.calledOnce(commands.invoke);
|
||||||
|
Assert.equal(closeTab.pendingClosedTabs.has(device1.id), false);
|
||||||
|
|
||||||
|
// Wait on the notification to ensure the push sent
|
||||||
|
await promiseObserver("test:fxaccounts:commands:close-uri:sent");
|
||||||
|
|
||||||
|
// Now we've sent both pushes
|
||||||
|
sinon.assert.calledTwice(commands.invoke);
|
||||||
|
|
||||||
|
// Two telemetry events to two different devices
|
||||||
|
Assert.deepEqual(fxai.telemetry._events, [
|
||||||
|
{
|
||||||
|
object: "command-sent",
|
||||||
|
method: COMMAND_CLOSETAB_TAIL,
|
||||||
|
value: "dev1-san",
|
||||||
|
extra: { flowID: "1", streamID: "2" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
object: "command-sent",
|
||||||
|
method: COMMAND_CLOSETAB_TAIL,
|
||||||
|
value: "dev2-san",
|
||||||
|
extra: { flowID: "3", streamID: "4" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
@ -20,6 +20,8 @@ support-files = [
|
||||||
|
|
||||||
["test_commands.js"]
|
["test_commands.js"]
|
||||||
|
|
||||||
|
["test_commands_closetab.js"]
|
||||||
|
|
||||||
["test_credentials.js"]
|
["test_credentials.js"]
|
||||||
|
|
||||||
["test_device.js"]
|
["test_device.js"]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue