diff --git a/browser/base/content/browser-sync.js b/browser/base/content/browser-sync.js --- a/browser/base/content/browser-sync.js +++ b/browser/base/content/browser-sync.js @@ -1,758 +1,757 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ // This file is loaded into the browser window scope. /* eslint-env mozilla/browser-window */ ChromeUtils.import("resource://services-sync/UIState.jsm"); ChromeUtils.defineModuleGetter(this, "FxAccounts", "resource://gre/modules/FxAccounts.jsm"); ChromeUtils.defineModuleGetter(this, "EnsureFxAccountsWebChannel", "resource://gre/modules/FxAccountsWebChannel.jsm"); ChromeUtils.defineModuleGetter(this, "Weave", "resource://services-sync/main.js"); const MIN_STATUS_ANIMATION_DURATION = 1600; var gSync = { _initialized: false, // The last sync start time. Used to calculate the leftover animation time // once syncing completes (bug 1239042). _syncStartTime: 0, _syncAnimationTimer: 0, _obs: [ "weave:engine:sync:finish", "quit-application", UIState.ON_UPDATE, ], get fxaStrings() { delete this.fxaStrings; return this.fxaStrings = Services.strings.createBundle( "chrome://browser/locale/accounts.properties" ); }, get syncStrings() { delete this.syncStrings; // XXXzpao these strings should probably be moved from /services to /browser... (bug 583381) // but for now just make it work return this.syncStrings = Services.strings.createBundle( "chrome://weave/locale/sync.properties" ); }, get syncReady() { return Cc["@mozilla.org/weave/service;1"].getService().wrappedJSObject.ready; }, // Returns true if sync is configured but hasn't loaded or is yet to determine // if any remote clients exist. get syncConfiguredAndLoading() { return UIState.get().status == UIState.STATUS_SIGNED_IN && (!this.syncReady || Weave.Service.clientsEngine.isFirstSync); }, get isSignedIn() { return UIState.get().status == UIState.STATUS_SIGNED_IN; }, - get remoteClients() { - return Weave.Service.clientsEngine.remoteClients - .sort((a, b) => a.name.localeCompare(b.name)); + get sendTabTargets() { + return Weave.Service.clientsEngine.fxaDevices + .sort((a, b) => a.name.localeCompare(b.name)) + .filter(d => !d.isCurrentDevice && (fxAccounts.commands.sendTab.isDeviceCompatible(d) || d.clientRecord)); }, get offline() { return Weave.Service.scheduler.offline; }, _generateNodeGetters() { for (let k of ["Status", "Avatar", "Label", "Container"]) { let prop = "appMenu" + k; let suffix = k.toLowerCase(); delete this[prop]; this.__defineGetter__(prop, function() { delete this[prop]; return this[prop] = document.getElementById("appMenu-fxa-" + suffix); }); } }, _definePrefGetters() { XPCOMUtils.defineLazyPreferenceGetter(this, "UNSENDABLE_URL_REGEXP", "services.sync.engine.tabs.filteredUrls", null, null, rx => { try { return new RegExp(rx, "i"); } catch (e) { Cu.reportError(`Failed to build url filter regexp for send tab: ${e}`); return null; } }); XPCOMUtils.defineLazyPreferenceGetter(this, "PRODUCT_INFO_BASE_URL", "app.productInfo.baseURL"); XPCOMUtils.defineLazyPreferenceGetter(this, "SYNC_ENABLED", "identity.fxaccounts.enabled"); }, _maybeUpdateUIState() { // Update the UI. if (UIState.isReady()) { const state = UIState.get(); // If we are not configured, the UI is already in the right state when // we open the window. We can avoid a repaint. if (state.status != UIState.STATUS_NOT_CONFIGURED) { this.updateAllUI(state); } } }, init() { if (this._initialized) { return; } this._definePrefGetters(); if (!this.SYNC_ENABLED) { this.onSyncDisabled(); return; } // Label for the sync buttons, also set on the icon for accessibility. let syncIcon = document.getElementById("appMenu-fxa-icon"); if (!syncIcon) { // We are in a window without our elements - just abort now, without // setting this._initialized, so we don't attempt to remove observers. return; } let syncNow = document.getElementById("PanelUI-remotetabs-syncnow"); let label = this.syncStrings.GetStringFromName("syncnow.label"); syncIcon.setAttribute("label", label); syncNow.setAttribute("label", label); // We start with every menuitem hidden, so that we don't need to init // the sync UI on windows like pageInfo.xul (see bug 1384856). document.getElementById("sync-setup").hidden = false; for (let topic of this._obs) { Services.obs.addObserver(this, topic, true); } this._generateNodeGetters(); this._maybeUpdateUIState(); EnsureFxAccountsWebChannel(); this._initialized = true; }, uninit() { if (!this._initialized) { return; } for (let topic of this._obs) { Services.obs.removeObserver(this, topic); } this._initialized = false; }, observe(subject, topic, data) { if (!this._initialized) { Cu.reportError("browser-sync observer called after unload: " + topic); return; } switch (topic) { case UIState.ON_UPDATE: const state = UIState.get(); this.updateAllUI(state); break; case "quit-application": // Stop the animation timer on shutdown, since we can't update the UI // after this. clearTimeout(this._syncAnimationTimer); break; case "weave:engine:sync:finish": if (data != "clients") { return; } this.onClientsSynced(); break; } }, updateAllUI(state) { this.updatePanelPopup(state); this.updateState(state); this.updateSyncButtonsTooltip(state); this.updateSyncStatus(state); }, updatePanelPopup(state) { let defaultLabel = this.appMenuStatus.getAttribute("defaultlabel"); // The localization string is for the signed in text, but it's the default text as well let defaultTooltiptext = this.appMenuStatus.getAttribute("signedinTooltiptext"); const status = state.status; // Reset the status bar to its original state. this.appMenuLabel.setAttribute("label", defaultLabel); this.appMenuStatus.setAttribute("tooltiptext", defaultTooltiptext); this.appMenuContainer.removeAttribute("fxastatus"); this.appMenuAvatar.style.removeProperty("list-style-image"); if (status == UIState.STATUS_NOT_CONFIGURED) { return; } // At this point we consider sync to be configured (but still can be in an error state). if (status == UIState.STATUS_LOGIN_FAILED) { let tooltipDescription = this.fxaStrings.formatStringFromName("reconnectDescription", [state.email], 1); let errorLabel = this.appMenuStatus.getAttribute("errorlabel"); this.appMenuContainer.setAttribute("fxastatus", "login-failed"); this.appMenuLabel.setAttribute("label", errorLabel); this.appMenuStatus.setAttribute("tooltiptext", tooltipDescription); return; } else if (status == UIState.STATUS_NOT_VERIFIED) { let tooltipDescription = this.fxaStrings.formatStringFromName("verifyDescription", [state.email], 1); let unverifiedLabel = this.appMenuStatus.getAttribute("unverifiedlabel"); this.appMenuContainer.setAttribute("fxastatus", "unverified"); this.appMenuLabel.setAttribute("label", unverifiedLabel); this.appMenuStatus.setAttribute("tooltiptext", tooltipDescription); return; } // At this point we consider sync to be logged-in. this.appMenuContainer.setAttribute("fxastatus", "signedin"); this.appMenuLabel.setAttribute("label", state.displayName || state.email); if (state.avatarURL) { let bgImage = "url(\"" + state.avatarURL + "\")"; this.appMenuAvatar.style.listStyleImage = bgImage; let img = new Image(); img.onerror = () => { // Clear the image if it has trouble loading. Since this callback is asynchronous // we check to make sure the image is still the same before we clear it. if (this.appMenuAvatar.style.listStyleImage === bgImage) { this.appMenuAvatar.style.removeProperty("list-style-image"); } }; img.src = state.avatarURL; } }, updateState(state) { for (let [status, menuId, boxId] of [ [UIState.STATUS_NOT_CONFIGURED, "sync-setup", "PanelUI-remotetabs-setupsync"], [UIState.STATUS_LOGIN_FAILED, "sync-reauthitem", "PanelUI-remotetabs-reauthsync"], [UIState.STATUS_NOT_VERIFIED, "sync-unverifieditem", "PanelUI-remotetabs-unverified"], [UIState.STATUS_SIGNED_IN, "sync-syncnowitem", "PanelUI-remotetabs-main"], ]) { document.getElementById(menuId).hidden = document.getElementById(boxId).hidden = (status != state.status); } }, updateSyncStatus(state) { let syncNow = document.getElementById("PanelUI-remotetabs-syncnow"); const syncingUI = syncNow.getAttribute("syncstatus") == "active"; if (state.syncing != syncingUI) { // Do we need to update the UI? state.syncing ? this.onActivityStart() : this.onActivityStop(); } }, onMenuPanelCommand() { switch (this.appMenuContainer.getAttribute("fxastatus")) { case "signedin": this.openPrefs("menupanel", "fxaSignedin"); break; case "error": if (this.appMenuContainer.getAttribute("fxastatus") == "unverified") { this.openPrefs("menupanel", "fxaError"); } else { this.openSignInAgainPage("menupanel"); } break; default: this.openPrefs("menupanel", "fxa"); break; } PanelUI.hide(); }, async openSignInAgainPage(entryPoint) { const url = await FxAccounts.config.promiseForceSigninURI(entryPoint); switchToTabHavingURI(url, true, { replaceQueryString: true, triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), }); }, async openDevicesManagementPage(entryPoint) { let url = await FxAccounts.config.promiseManageDevicesURI(entryPoint); switchToTabHavingURI(url, true, { replaceQueryString: true, triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), }); }, async openConnectAnotherDevice(entryPoint) { const url = await FxAccounts.config.promiseConnectDeviceURI(entryPoint); openTrustedLinkIn(url, "tab"); }, openSendToDevicePromo() { let url = this.PRODUCT_INFO_BASE_URL; url += "send-tabs/?utm_source=" + Services.appinfo.name.toLowerCase(); switchToTabHavingURI(url, true, { replaceQueryString: true }); }, - async sendTabToDevice(url, clients, title) { - let devices; - try { - devices = await fxAccounts.getDeviceList(); - } catch (e) { - console.error("Could not get the FxA device list", e); - devices = []; // We can still run in degraded mode. - } + async sendTabToDevice(url, targets, title) { const fxaCommandsDevices = []; const oldSendTabClients = []; - for (const client of clients) { - const device = devices.find(d => d.id == client.fxaDeviceId); - if (!device) { - console.error(`Could not find associated FxA device for ${client.name}`); - continue; - } else if ((await fxAccounts.commands.sendTab.isDeviceCompatible(device))) { - fxaCommandsDevices.push(device); + for (const target of targets) { + if (fxAccounts.commands.sendTab.isDeviceCompatible(target)) { + fxaCommandsDevices.push(target); + } else if (target.clientRecord) { + oldSendTabClients.push(target.clientRecord); } else { - oldSendTabClients.push(client); + console.error(`Target ${target.id} unsuitable for send tab.`); } } if (fxaCommandsDevices.length) { console.log(`Sending a tab to ${fxaCommandsDevices.map(d => d.name).join(", ")} using FxA commands.`); const report = await fxAccounts.commands.sendTab.send(fxaCommandsDevices, {url, title}); for (let {device, error} of report.failed) { console.error(`Failed to send a tab with FxA commands for ${device.name}. Falling back on the Sync back-end`, error); - const client = clients.find(c => c.fxaDeviceId == device.id); - if (!client) { + if (!device.clientRecord) { console.error(`Could not find associated Sync device for ${device.name}`); continue; } - oldSendTabClients.push(client); + oldSendTabClients.push(device.clientRecord); } } for (let client of oldSendTabClients) { try { console.log(`Sending a tab to ${client.name} using Sync.`); await Weave.Service.clientsEngine.sendURIToClientForDisplay(url, client.id, title); } catch (e) { console.error("Could not send tab to device.", e); } } }, populateSendTabToDevicesMenu(devicesPopup, url, title, multiselected, createDeviceNodeFn) { if (!createDeviceNodeFn) { - createDeviceNodeFn = (clientId, name, clientType, lastModified) => { + createDeviceNodeFn = (targetId, name, targetType, lastModified) => { let eltName = name ? "menuitem" : "menuseparator"; return document.createXULElement(eltName); }; } // remove existing menu items for (let i = devicesPopup.children.length - 1; i >= 0; --i) { let child = devicesPopup.children[i]; if (child.classList.contains("sync-menuitem")) { child.remove(); } } if (gSync.syncConfiguredAndLoading) { // We can only be in this case in the page action menu. return; } const fragment = document.createDocumentFragment(); const state = UIState.get(); - if (state.status == UIState.STATUS_SIGNED_IN && this.remoteClients.length > 0) { + if (state.status == UIState.STATUS_SIGNED_IN && this.sendTabTargets.length > 0) { this._appendSendTabDeviceList(fragment, createDeviceNodeFn, url, title, multiselected); } else if (state.status == UIState.STATUS_SIGNED_IN) { this._appendSendTabSingleDevice(fragment, createDeviceNodeFn); } else if (state.status == UIState.STATUS_NOT_VERIFIED || state.status == UIState.STATUS_LOGIN_FAILED) { this._appendSendTabVerify(fragment, createDeviceNodeFn); } else /* status is STATUS_NOT_CONFIGURED */ { this._appendSendTabUnconfigured(fragment, createDeviceNodeFn); } devicesPopup.appendChild(fragment); }, // TODO: once our transition from the old-send tab world is complete, // this list should be built using the FxA device list instead of the client // collection. _appendSendTabDeviceList(fragment, createDeviceNodeFn, url, title, multiselected) { + const targets = this.sendTabTargets; + let tabsToSend = multiselected ? gBrowser.selectedTabs.map(t => { return { url: t.linkedBrowser.currentURI.spec, title: t.linkedBrowser.contentTitle, }; }) : [{url, title}]; const onSendAllCommand = (event) => { for (let t of tabsToSend) { - this.sendTabToDevice(t.url, this.remoteClients, t.title); + this.sendTabToDevice(t.url, targets, t.title); } }; const onTargetDeviceCommand = (event) => { - const clientId = event.target.getAttribute("clientId"); - const client = this.remoteClients.find(c => c.id == clientId); + const targetId = event.target.getAttribute("clientId"); + const target = targets.find(t => t.id == targetId); for (let t of tabsToSend) { - this.sendTabToDevice(t.url, [client], t.title); + this.sendTabToDevice(t.url, [target], t.title); } }; - function addTargetDevice(clientId, name, clientType, lastModified) { - const targetDevice = createDeviceNodeFn(clientId, name, clientType, lastModified); - targetDevice.addEventListener("command", clientId ? onTargetDeviceCommand : + function addTargetDevice(targetId, name, targetType, lastModified) { + const targetDevice = createDeviceNodeFn(targetId, name, targetType, lastModified); + targetDevice.addEventListener("command", targetId ? onTargetDeviceCommand : onSendAllCommand, true); targetDevice.classList.add("sync-menuitem", "sendtab-target"); - targetDevice.setAttribute("clientId", clientId); - targetDevice.setAttribute("clientType", clientType); + targetDevice.setAttribute("clientId", targetId); + targetDevice.setAttribute("clientType", targetType); targetDevice.setAttribute("label", name); fragment.appendChild(targetDevice); } - const clients = this.remoteClients; - for (let client of clients) { - const type = Weave.Service.clientsEngine.getClientType(client.id); - addTargetDevice(client.id, client.name, type, new Date(client.serverLastModified * 1000)); + for (let target of targets) { + let type, lastModified; + if (target.clientRecord) { + type = Weave.Service.clientsEngine.getClientType(target.clientRecord.id); + lastModified = new Date(target.clientRecord.serverLastModified * 1000); + } else { + type = target.type === "desktop" ? "desktop" : "phone"; // Normalizing the FxA types just in case. + lastModified = null; + } + addTargetDevice(target.id, target.name, type, lastModified); } // "Send to All Devices" menu item - if (clients.length > 1) { + if (targets.length > 1) { const separator = createDeviceNodeFn(); separator.classList.add("sync-menuitem"); fragment.appendChild(separator); const allDevicesLabel = this.fxaStrings.GetStringFromName("sendToAllDevices.menuitem"); addTargetDevice("", allDevicesLabel, ""); } }, _appendSendTabSingleDevice(fragment, createDeviceNodeFn) { const noDevices = this.fxaStrings.GetStringFromName("sendTabToDevice.singledevice.status"); const learnMore = this.fxaStrings.GetStringFromName("sendTabToDevice.singledevice"); const connectDevice = this.fxaStrings.GetStringFromName("sendTabToDevice.connectdevice"); const actions = [{label: connectDevice, command: () => this.openConnectAnotherDevice("sendtab")}, {label: learnMore, command: () => this.openSendToDevicePromo()}]; this._appendSendTabInfoItems(fragment, createDeviceNodeFn, noDevices, actions); }, _appendSendTabVerify(fragment, createDeviceNodeFn) { const notVerified = this.fxaStrings.GetStringFromName("sendTabToDevice.verify.status"); const verifyAccount = this.fxaStrings.GetStringFromName("sendTabToDevice.verify"); const actions = [{label: verifyAccount, command: () => this.openPrefs("sendtab")}]; this._appendSendTabInfoItems(fragment, createDeviceNodeFn, notVerified, actions); }, _appendSendTabUnconfigured(fragment, createDeviceNodeFn) { const notConnected = this.fxaStrings.GetStringFromName("sendTabToDevice.unconfigured.status"); const learnMore = this.fxaStrings.GetStringFromName("sendTabToDevice.unconfigured"); const actions = [{label: learnMore, command: () => this.openSendToDevicePromo()}]; this._appendSendTabInfoItems(fragment, createDeviceNodeFn, notConnected, actions); // Now add a 'sign in to sync' item above the 'learn more' item. const signInToSync = this.fxaStrings.GetStringFromName("sendTabToDevice.signintosync"); let signInItem = createDeviceNodeFn(null, signInToSync, null); signInItem.classList.add("sync-menuitem"); signInItem.setAttribute("label", signInToSync); // Show an icon if opened in the page action panel: if (signInItem.classList.contains("subviewbutton")) { signInItem.classList.add("subviewbutton-iconic", "signintosync"); } signInItem.addEventListener("command", () => { this.openPrefs("sendtab"); }); fragment.insertBefore(signInItem, fragment.lastElementChild); }, _appendSendTabInfoItems(fragment, createDeviceNodeFn, statusLabel, actions) { const status = createDeviceNodeFn(null, statusLabel, null); status.setAttribute("label", statusLabel); status.setAttribute("disabled", true); status.classList.add("sync-menuitem"); fragment.appendChild(status); const separator = createDeviceNodeFn(null, null, null); separator.classList.add("sync-menuitem"); fragment.appendChild(separator); for (let {label, command} of actions) { const actionItem = createDeviceNodeFn(null, label, null); actionItem.addEventListener("command", command, true); actionItem.classList.add("sync-menuitem"); actionItem.setAttribute("label", label); fragment.appendChild(actionItem); } }, isSendableURI(aURISpec) { if (!aURISpec) { return false; } // Disallow sending tabs with more than 65535 characters. if (aURISpec.length > 65535) { return false; } if (this.UNSENDABLE_URL_REGEXP) { return !this.UNSENDABLE_URL_REGEXP.test(aURISpec); } // The preference has been removed, or is an invalid regexp, so we treat it // as a valid URI. We've already logged an error when trying to construct // the regexp, and the more problematic case is the length, which we've // already addressed. return true; }, // "Send Tab to Device" menu item updateTabContextMenu(aPopupMenu, aTargetTab) { // We may get here before initialisation. This situation // can lead to a empty label for 'Send To Device' Menu. this.init(); if (!this.SYNC_ENABLED) { // These items are hidden in onSyncDisabled(). No need to do anything. return; } let hasASendableURI = false; for (let tab of aTargetTab.multiselected ? gBrowser.selectedTabs : [aTargetTab]) { if (this.isSendableURI(tab.linkedBrowser.currentURI.spec)) { hasASendableURI = true; break; } } const enabled = !this.syncConfiguredAndLoading && hasASendableURI; let sendTabsToDevice = document.getElementById("context_sendTabToDevice"); sendTabsToDevice.disabled = !enabled; let tabCount = aTargetTab.multiselected ? gBrowser.multiSelectedTabsCount : 1; sendTabsToDevice.label = PluralForm.get(tabCount, gNavigatorBundle.getString("sendTabsToDevice.label")) .replace("#1", tabCount.toLocaleString()); sendTabsToDevice.accessKey = gNavigatorBundle.getString("sendTabsToDevice.accesskey"); }, // "Send Page to Device" and "Send Link to Device" menu items updateContentContextMenu(contextMenu) { if (!this.SYNC_ENABLED) { // These items are hidden by default. No need to do anything. return; } // showSendLink and showSendPage are mutually exclusive const showSendLink = contextMenu.onSaveableLink || contextMenu.onPlainTextLink; const showSendPage = !showSendLink && !(contextMenu.isContentSelected || contextMenu.onImage || contextMenu.onCanvas || contextMenu.onVideo || contextMenu.onAudio || contextMenu.onLink || contextMenu.onTextInput); // Avoids double separator on images with links. const hideSeparator = contextMenu.isContentSelected && contextMenu.onLink && contextMenu.onImage; ["context-sendpagetodevice", ...(hideSeparator ? [] : ["context-sep-sendpagetodevice"])] .forEach(id => contextMenu.showItem(id, showSendPage)); ["context-sendlinktodevice", ...(hideSeparator ? [] : ["context-sep-sendlinktodevice"])] .forEach(id => contextMenu.showItem(id, showSendLink)); if (!showSendLink && !showSendPage) { return; } const targetURI = showSendLink ? contextMenu.linkURL : contextMenu.browser.currentURI.spec; const enabled = !this.syncConfiguredAndLoading && this.isSendableURI(targetURI); contextMenu.setItemAttr(showSendPage ? "context-sendpagetodevice" : "context-sendlinktodevice", "disabled", !enabled || null); }, // Functions called by observers onActivityStart() { clearTimeout(this._syncAnimationTimer); this._syncStartTime = Date.now(); let label = this.syncStrings.GetStringFromName("syncingtabs.label"); let syncIcon = document.getElementById("appMenu-fxa-icon"); let syncNow = document.getElementById("PanelUI-remotetabs-syncnow"); syncIcon.setAttribute("syncstatus", "active"); syncIcon.setAttribute("label", label); syncIcon.setAttribute("disabled", "true"); syncNow.setAttribute("syncstatus", "active"); syncNow.setAttribute("label", label); syncNow.setAttribute("disabled", "true"); }, _onActivityStop() { if (!gBrowser) return; let label = this.syncStrings.GetStringFromName("syncnow.label"); let syncIcon = document.getElementById("appMenu-fxa-icon"); let syncNow = document.getElementById("PanelUI-remotetabs-syncnow"); syncIcon.removeAttribute("syncstatus"); syncIcon.removeAttribute("disabled"); syncIcon.setAttribute("label", label); syncNow.removeAttribute("syncstatus"); syncNow.removeAttribute("disabled"); syncNow.setAttribute("label", label); Services.obs.notifyObservers(null, "test:browser-sync:activity-stop"); }, onActivityStop() { let now = Date.now(); let syncDuration = now - this._syncStartTime; if (syncDuration < MIN_STATUS_ANIMATION_DURATION) { let animationTime = MIN_STATUS_ANIMATION_DURATION - syncDuration; clearTimeout(this._syncAnimationTimer); this._syncAnimationTimer = setTimeout(() => this._onActivityStop(), animationTime); } else { this._onActivityStop(); } }, // doSync forces a sync - it *does not* return a promise as it is called // via the various UI components. doSync() { if (!UIState.isReady()) { return; } const state = UIState.get(); if (state.status == UIState.STATUS_SIGNED_IN) { this.updateSyncStatus({ syncing: true }); Services.tm.dispatchToMainThread(() => { // We are pretty confident that push helps us pick up all FxA commands, // but some users might have issues with push, so let's unblock them // by fetching the missed FxA commands on manual sync. fxAccounts.commands.fetchMissedRemoteCommands().catch(e => { console.error("Fetching missed remote commands failed.", e); }); Weave.Service.sync(); }); } }, openPrefs(entryPoint = "syncbutton", origin = undefined) { window.openPreferences("paneSync", { origin, urlParams: { entrypoint: entryPoint } }); }, openSyncedTabsPanel() { let placement = CustomizableUI.getPlacementOfWidget("sync-button"); let area = placement && placement.area; let anchor = document.getElementById("sync-button") || document.getElementById("PanelUI-menu-button"); if (area == CustomizableUI.AREA_FIXED_OVERFLOW_PANEL) { // The button is in the overflow panel, so we need to show the panel, // then show our subview. let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); navbar.overflowable.show().then(() => { PanelUI.showSubView("PanelUI-remotetabs", anchor); }, Cu.reportError); } else { // It is placed somewhere else - just try and show it. PanelUI.showSubView("PanelUI-remotetabs", anchor); } }, refreshSyncButtonsTooltip() { const state = UIState.get(); this.updateSyncButtonsTooltip(state); }, /* Update the tooltip for the sync icon in the main menu and in Synced Tabs. If Sync is configured, the tooltip is when the last sync occurred, otherwise the tooltip reflects the fact that Sync needs to be (re-)configured. */ updateSyncButtonsTooltip(state) { const status = state.status; // This is a little messy as the Sync buttons are 1/2 Sync related and // 1/2 FxA related - so for some strings we use Sync strings, but for // others we reach into gSync for strings. let tooltiptext; if (status == UIState.STATUS_NOT_VERIFIED) { // "needs verification" tooltiptext = this.fxaStrings.formatStringFromName("verifyDescription", [state.email], 1); } else if (status == UIState.STATUS_NOT_CONFIGURED) { // "needs setup". tooltiptext = this.syncStrings.GetStringFromName("signInToSync.description"); } else if (status == UIState.STATUS_LOGIN_FAILED) { // "need to reconnect/re-enter your password" tooltiptext = this.fxaStrings.formatStringFromName("reconnectDescription", [state.email], 1); } else { // Sync appears configured - format the "last synced at" time. tooltiptext = this.formatLastSyncDate(state.lastSync); } let syncIcon = document.getElementById("appMenu-fxa-icon"); if (syncIcon) { let syncNow = document.getElementById("PanelUI-remotetabs-syncnow"); if (tooltiptext) { syncIcon.setAttribute("tooltiptext", tooltiptext); syncNow.setAttribute("tooltiptext", tooltiptext); } else { syncIcon.removeAttribute("tooltiptext"); syncNow.removeAttribute("tooltiptext"); } } }, get relativeTimeFormat() { delete this.relativeTimeFormat; return this.relativeTimeFormat = new Services.intl.RelativeTimeFormat(undefined, {style: "short"}); }, formatLastSyncDate(date) { if (!date) { // Date can be null before the first sync! return null; } const relativeDateStr = this.relativeTimeFormat.formatBestUnit(date); return this.syncStrings.formatStringFromName("lastSync2.label", [relativeDateStr], 1); }, onClientsSynced() { let element = document.getElementById("PanelUI-remotetabs-main"); if (element) { if (Weave.Service.clientsEngine.stats.numClients > 1) { element.setAttribute("devices-status", "multi"); } else { element.setAttribute("devices-status", "single"); } } }, onSyncDisabled() { const toHide = [...document.querySelectorAll(".sync-ui-item")]; for (const item of toHide) { item.hidden = true; } }, QueryInterface: ChromeUtils.generateQI([ Ci.nsIObserver, Ci.nsISupportsWeakReference, ]), }; diff --git a/browser/base/content/test/sync/browser_contextmenu_sendpage.js b/browser/base/content/test/sync/browser_contextmenu_sendpage.js --- a/browser/base/content/test/sync/browser_contextmenu_sendpage.js +++ b/browser/base/content/test/sync/browser_contextmenu_sendpage.js @@ -1,277 +1,277 @@ /* Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; -const remoteClientsFixture = [ { id: 1, name: "Foo"}, { id: 2, name: "Bar"} ]; +const targetsFixture = [ { id: 1, name: "Foo"}, { id: 2, name: "Bar"} ]; add_task(async function setup() { await promiseSyncReady(); // gSync.init() is called in a requestIdleCallback. Force its initialization. gSync.init(); sinon.stub(Weave.Service.clientsEngine, "getClientType").returns("desktop"); await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla"); }); add_task(async function test_page_contextmenu() { - const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: remoteClientsFixture, + const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, targets: targetsFixture, state: UIState.STATUS_SIGNED_IN, isSendableURI: true }); await openContentContextMenu("#moztext", "context-sendpagetodevice"); is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown"); is(document.getElementById("context-sendpagetodevice").disabled, false, "Send tab to device is enabled"); checkPopup([ { label: "Foo" }, { label: "Bar" }, "----", { label: "Send to All Devices" }, ]); await hideContentContextMenu(); sandbox.restore(); }); add_task(async function test_link_contextmenu() { - const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: remoteClientsFixture, + const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, targets: targetsFixture, state: UIState.STATUS_SIGNED_IN, isSendableURI: true }); let expectation = sandbox.mock(gSync) .expects("sendTabToDevice") .once() .withExactArgs("https://www.example.org/", [{id: 1, name: "Foo"}], "Click on me!!"); // Add a link to the page await ContentTask.spawn(gBrowser.selectedBrowser, null, () => { let a = content.document.createElement("a"); a.href = "https://www.example.org"; a.id = "testingLink"; a.textContent = "Click on me!!"; content.document.body.appendChild(a); }); await openContentContextMenu("#testingLink", "context-sendlinktodevice", "context-sendlinktodevice-popup"); is(document.getElementById("context-sendlinktodevice").hidden, false, "Send link to device is shown"); is(document.getElementById("context-sendlinktodevice").disabled, false, "Send link to device is enabled"); document.getElementById("context-sendlinktodevice-popup").querySelector("menuitem").click(); await hideContentContextMenu(); expectation.verify(); sandbox.restore(); }); add_task(async function test_page_contextmenu_no_remote_clients() { - const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: [], + const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, targets: [], state: UIState.STATUS_SIGNED_IN, isSendableURI: true }); await openContentContextMenu("#moztext", "context-sendpagetodevice"); is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown"); is(document.getElementById("context-sendpagetodevice").disabled, false, "Send tab to device is enabled"); checkPopup([ { label: "No Devices Connected", disabled: true }, "----", { label: "Connect Another Device..." }, { label: "Learn About Sending Tabs..." }, ]); await hideContentContextMenu(); sandbox.restore(); }); add_task(async function test_page_contextmenu_one_remote_client() { - const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: [{ id: 1, name: "Foo"}], + const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, targets: [{ id: 1, name: "Foo"}], state: UIState.STATUS_SIGNED_IN, isSendableURI: true }); await openContentContextMenu("#moztext", "context-sendpagetodevice"); is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown"); is(document.getElementById("context-sendpagetodevice").disabled, false, "Send tab to device is enabled"); checkPopup([ { label: "Foo" }, ]); await hideContentContextMenu(); sandbox.restore(); }); add_task(async function test_page_contextmenu_not_sendable() { - const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: remoteClientsFixture, + const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, targets: targetsFixture, state: UIState.STATUS_SIGNED_IN, isSendableURI: false }); await openContentContextMenu("#moztext"); is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown"); is(document.getElementById("context-sendpagetodevice").disabled, true, "Send tab to device is disabled"); checkPopup(); await hideContentContextMenu(); sandbox.restore(); }); add_task(async function test_page_contextmenu_not_synced_yet() { - const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: false, remoteClients: [], + const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: false, targets: [], state: UIState.STATUS_SIGNED_IN, isSendableURI: true }); await openContentContextMenu("#moztext"); is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown"); is(document.getElementById("context-sendpagetodevice").disabled, true, "Send tab to device is disabled"); checkPopup(); await hideContentContextMenu(); sandbox.restore(); }); add_task(async function test_page_contextmenu_sync_not_ready_configured() { - const sandbox = setupSendTabMocks({ syncReady: false, clientsSynced: false, remoteClients: null, + const sandbox = setupSendTabMocks({ syncReady: false, clientsSynced: false, targets: null, state: UIState.STATUS_SIGNED_IN, isSendableURI: true }); await openContentContextMenu("#moztext"); is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown"); is(document.getElementById("context-sendpagetodevice").disabled, true, "Send tab to device is disabled"); checkPopup(); await hideContentContextMenu(); sandbox.restore(); }); add_task(async function test_page_contextmenu_sync_not_ready_other_state() { - const sandbox = setupSendTabMocks({ syncReady: false, clientsSynced: false, remoteClients: null, + const sandbox = setupSendTabMocks({ syncReady: false, clientsSynced: false, targets: null, state: UIState.STATUS_NOT_VERIFIED, isSendableURI: true }); await openContentContextMenu("#moztext", "context-sendpagetodevice"); is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown"); is(document.getElementById("context-sendpagetodevice").disabled, false, "Send tab to device is enabled"); checkPopup([ { label: "Account Not Verified", disabled: true }, "----", { label: "Verify Your Account..." }, ]); await hideContentContextMenu(); sandbox.restore(); }); add_task(async function test_page_contextmenu_unconfigured() { - const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: null, + const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, targets: null, state: UIState.STATUS_NOT_CONFIGURED, isSendableURI: true }); await openContentContextMenu("#moztext", "context-sendpagetodevice"); is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown"); is(document.getElementById("context-sendpagetodevice").disabled, false, "Send tab to device is enabled"); checkPopup([ { label: "Not Connected to Sync", disabled: true }, "----", { label: "Sign in to Sync..." }, { label: "Learn About Sending Tabs..." }, ]); await hideContentContextMenu(); sandbox.restore(); }); add_task(async function test_page_contextmenu_not_verified() { - const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: null, + const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, targets: null, state: UIState.STATUS_NOT_VERIFIED, isSendableURI: true }); await openContentContextMenu("#moztext", "context-sendpagetodevice"); is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown"); is(document.getElementById("context-sendpagetodevice").disabled, false, "Send tab to device is enabled"); checkPopup([ { label: "Account Not Verified", disabled: true }, "----", { label: "Verify Your Account..." }, ]); await hideContentContextMenu(); sandbox.restore(); }); add_task(async function test_page_contextmenu_login_failed() { const syncReady = sinon.stub(gSync, "syncReady").get(() => true); const getState = sinon.stub(UIState, "get").returns({ status: UIState.STATUS_LOGIN_FAILED }); const isSendableURI = sinon.stub(gSync, "isSendableURI").returns(true); await openContentContextMenu("#moztext", "context-sendpagetodevice"); is(document.getElementById("context-sendpagetodevice").hidden, false, "Send tab to device is shown"); is(document.getElementById("context-sendpagetodevice").disabled, false, "Send tab to device is enabled"); checkPopup([ { label: "Account Not Verified", disabled: true }, "----", { label: "Verify Your Account..." }, ]); await hideContentContextMenu(); syncReady.restore(); getState.restore(); isSendableURI.restore(); }); add_task(async function test_page_contextmenu_fxa_disabled() { const getter = sinon.stub(gSync, "SYNC_ENABLED").get(() => false); gSync.onSyncDisabled(); // Would have been called on gSync initialization if SYNC_ENABLED had been set. await openContentContextMenu("#moztext"); is(document.getElementById("context-sendpagetodevice").hidden, true, "Send tab to device is hidden"); is(document.getElementById("context-sep-sendpagetodevice").hidden, true, "Separator is also hidden"); await hideContentContextMenu(); getter.restore(); [...document.querySelectorAll(".sync-ui-item")].forEach(e => e.hidden = false); }); // We are not going to bother testing the visibility of context-sendlinktodevice // since it uses the exact same code. // However, browser_contextmenu.js contains tests that verify its presence. add_task(async function teardown() { Weave.Service.clientsEngine.getClientType.restore(); gBrowser.removeCurrentTab(); }); function checkPopup(expectedItems = null) { const popup = document.getElementById("context-sendpagetodevice-popup"); if (!expectedItems) { is(popup.state, "closed", "Popup should be hidden."); return; } const menuItems = popup.children; is(menuItems.length, expectedItems.length, "Popup has the expected children count."); for (let i = 0; i < menuItems.length; i++) { const menuItem = menuItems[i]; const expectedItem = expectedItems[i]; if (expectedItem === "----") { is(menuItem.nodeName, "menuseparator", "Found a separator"); continue; } is(menuItem.nodeName, "menuitem", "Found a menu item"); // Bug workaround, menuItem.label "…" encoding is different than ours. is(menuItem.label.normalize("NFKC"), expectedItem.label, "Correct menu item label"); is(menuItem.disabled, !!expectedItem.disabled, "Correct menu item disabled state"); } } async function openContentContextMenu(selector, openSubmenuId = null) { const contextMenu = document.getElementById("contentAreaContextMenu"); is(contextMenu.state, "closed", "checking if popup is closed"); const awaitPopupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); await BrowserTestUtils.synthesizeMouse(selector, 0, 0, { type: "contextmenu", button: 2, shiftkey: false, centered: true, }, gBrowser.selectedBrowser); await awaitPopupShown; if (openSubmenuId) { const menuPopup = document.getElementById(openSubmenuId).menupopup; const menuPopupPromise = BrowserTestUtils.waitForEvent(menuPopup, "popupshown"); menuPopup.openPopup(); await menuPopupPromise; } } async function hideContentContextMenu() { const contextMenu = document.getElementById("contentAreaContextMenu"); const awaitPopupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); contextMenu.hidePopup(); await awaitPopupHidden; } diff --git a/browser/base/content/test/sync/browser_contextmenu_sendtab.js b/browser/base/content/test/sync/browser_contextmenu_sendtab.js --- a/browser/base/content/test/sync/browser_contextmenu_sendtab.js +++ b/browser/base/content/test/sync/browser_contextmenu_sendtab.js @@ -1,145 +1,145 @@ /* Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; const chrome_base = "chrome://mochitests/content/browser/browser/base/content/test/general/"; Services.scriptloader.loadSubScript(chrome_base + "head.js", this); /* import-globals-from ../general/head.js */ -const remoteClientsFixture = [ { id: 1, name: "Foo"}, { id: 2, name: "Bar"} ]; +const targetsFixture = [ { id: 1, name: "Foo"}, { id: 2, name: "Bar"} ]; let [testTab] = gBrowser.visibleTabs; function updateTabContextMenu(tab) { let menu = document.getElementById("tabContextMenu"); if (!tab) tab = gBrowser.selectedTab; var evt = new Event(""); tab.dispatchEvent(evt); menu.openPopup(tab, "end_after", 0, 0, true, false, evt); is(window.TabContextMenu.contextTab, tab, "TabContextMenu context is the expected tab"); menu.hidePopup(); } add_task(async function setup() { await promiseSyncReady(); // gSync.init() is called in a requestIdleCallback. Force its initialization. gSync.init(); sinon.stub(Weave.Service.clientsEngine, "getClientType").returns("desktop"); await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla"); registerCleanupFunction(() => { gBrowser.removeCurrentTab(); }); is(gBrowser.visibleTabs.length, 2, "there are two visible tabs"); }); add_task(async function test_tab_contextmenu() { - const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: remoteClientsFixture, + const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, targets: targetsFixture, state: UIState.STATUS_SIGNED_IN, isSendableURI: true }); let expectation = sandbox.mock(gSync) .expects("sendTabToDevice") .once() .withExactArgs("about:mozilla", [{id: 1, name: "Foo"}], "The Book of Mozilla, 11:14"); updateTabContextMenu(testTab); await openTabContextMenu("context_sendTabToDevice"); is(document.getElementById("context_sendTabToDevice").hidden, false, "Send tab to device is shown"); is(document.getElementById("context_sendTabToDevice").disabled, false, "Send tab to device is enabled"); document.getElementById("context_sendTabToDevicePopupMenu").querySelector("menuitem").click(); await hideTabContextMenu(); expectation.verify(); sandbox.restore(); }); add_task(async function test_tab_contextmenu_unconfigured() { - const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: remoteClientsFixture, + const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, targets: targetsFixture, state: UIState.STATUS_NOT_CONFIGURED, isSendableURI: true }); updateTabContextMenu(testTab); is(document.getElementById("context_sendTabToDevice").hidden, false, "Send tab to device is shown"); is(document.getElementById("context_sendTabToDevice").disabled, false, "Send tab to device is enabled"); sandbox.restore(); }); add_task(async function test_tab_contextmenu_not_sendable() { - const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, remoteClients: [{ id: 1, name: "Foo"}], + const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: true, targets: [{ id: 1, name: "Foo"}], state: UIState.STATUS_SIGNED_IN, isSendableURI: false }); updateTabContextMenu(testTab); is(document.getElementById("context_sendTabToDevice").hidden, false, "Send tab to device is shown"); is(document.getElementById("context_sendTabToDevice").disabled, true, "Send tab to device is disabled"); sandbox.restore(); }); add_task(async function test_tab_contextmenu_not_synced_yet() { - const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: false, remoteClients: [], + const sandbox = setupSendTabMocks({ syncReady: true, clientsSynced: false, targets: [], state: UIState.STATUS_SIGNED_IN, isSendableURI: true }); updateTabContextMenu(testTab); is(document.getElementById("context_sendTabToDevice").hidden, false, "Send tab to device is shown"); is(document.getElementById("context_sendTabToDevice").disabled, true, "Send tab to device is disabled"); sandbox.restore(); }); add_task(async function test_tab_contextmenu_sync_not_ready_configured() { - const sandbox = setupSendTabMocks({ syncReady: false, clientsSynced: false, remoteClients: null, + const sandbox = setupSendTabMocks({ syncReady: false, clientsSynced: false, targets: null, state: UIState.STATUS_SIGNED_IN, isSendableURI: true }); updateTabContextMenu(testTab); is(document.getElementById("context_sendTabToDevice").hidden, false, "Send tab to device is shown"); is(document.getElementById("context_sendTabToDevice").disabled, true, "Send tab to device is disabled"); sandbox.restore(); }); add_task(async function test_tab_contextmenu_sync_not_ready_other_state() { - const sandbox = setupSendTabMocks({ syncReady: false, clientsSynced: false, remoteClients: null, + const sandbox = setupSendTabMocks({ syncReady: false, clientsSynced: false, targets: null, state: UIState.STATUS_NOT_VERIFIED, isSendableURI: true }); updateTabContextMenu(testTab); is(document.getElementById("context_sendTabToDevice").hidden, false, "Send tab to device is shown"); is(document.getElementById("context_sendTabToDevice").disabled, false, "Send tab to device is enabled"); sandbox.restore(); }); add_task(async function test_tab_contextmenu_fxa_disabled() { const getter = sinon.stub(gSync, "SYNC_ENABLED").get(() => false); // Simulate onSyncDisabled() being called on window open. gSync.onSyncDisabled(); updateTabContextMenu(testTab); is(document.getElementById("context_sendTabToDevice").hidden, true, "Send tab to device is hidden"); getter.restore(); [...document.querySelectorAll(".sync-ui-item")].forEach(e => e.hidden = false); }); async function openTabContextMenu(openSubmenuId = null) { const contextMenu = document.getElementById("tabContextMenu"); is(contextMenu.state, "closed", "checking if popup is closed"); const awaitPopupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); EventUtils.synthesizeMouseAtCenter(gBrowser.selectedTab, {type: "contextmenu", button: 2}); await awaitPopupShown; if (openSubmenuId) { const menuPopup = document.getElementById(openSubmenuId).menupopup; const menuPopupPromise = BrowserTestUtils.waitForEvent(menuPopup, "popupshown"); menuPopup.openPopup(); await menuPopupPromise; } } async function hideTabContextMenu() { const contextMenu = document.getElementById("tabContextMenu"); const awaitPopupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); contextMenu.hidePopup(); await awaitPopupHidden; } diff --git a/browser/base/content/test/sync/head.js b/browser/base/content/test/sync/head.js --- a/browser/base/content/test/sync/head.js +++ b/browser/base/content/test/sync/head.js @@ -1,25 +1,25 @@ /* global sinon */ Services.scriptloader.loadSubScript("resource://testing-common/sinon-2.3.2.js"); ChromeUtils.import("resource://services-sync/UIState.jsm", this); registerCleanupFunction(function() { delete window.sinon; }); function promiseSyncReady() { let service = Cc["@mozilla.org/weave/service;1"] .getService(Ci.nsISupports) .wrappedJSObject; return service.whenLoaded(); } -function setupSendTabMocks({ syncReady, clientsSynced, remoteClients, state, isSendableURI }) { +function setupSendTabMocks({ syncReady, clientsSynced, targets, state, isSendableURI }) { const sandbox = sinon.sandbox.create(); sandbox.stub(gSync, "syncReady").get(() => syncReady); sandbox.stub(Weave.Service.clientsEngine, "isFirstSync").get(() => !clientsSynced); - sandbox.stub(gSync, "remoteClients").get(() => remoteClients); + sandbox.stub(gSync, "sendTabTargets").get(() => targets); sandbox.stub(UIState, "get").returns({ status: state }); sandbox.stub(gSync, "isSendableURI").returns(isSendableURI); return sandbox; } diff --git a/browser/base/content/test/urlbar/browser_page_action_menu.js b/browser/base/content/test/urlbar/browser_page_action_menu.js --- a/browser/base/content/test/urlbar/browser_page_action_menu.js +++ b/browser/base/content/test/urlbar/browser_page_action_menu.js @@ -1,856 +1,857 @@ "use strict"; /* global sinon, UIState */ Services.scriptloader.loadSubScript("resource://testing-common/sinon-2.3.2.js"); registerCleanupFunction(function() { delete window.sinon; }); const lastModifiedFixture = 1507655615.87; // Approx Oct 10th 2017 -const mockRemoteClients = [ - { id: "0", name: "foo", type: "mobile", serverLastModified: lastModifiedFixture }, - { id: "1", name: "bar", type: "desktop", serverLastModified: lastModifiedFixture }, - { id: "2", name: "baz", type: "mobile", serverLastModified: lastModifiedFixture }, +const mockTargets = [ + { id: "0", name: "foo", type: "phone", clientRecord: {id: "cli0", serverLastModified: lastModifiedFixture, type: "phone"} }, + { id: "1", name: "bar", type: "desktop", clientRecord: {id: "cli1", serverLastModified: lastModifiedFixture, type: "desktop"} }, + { id: "2", name: "baz", type: "phone", clientRecord: {id: "cli2", serverLastModified: lastModifiedFixture, type: "phone"} }, + { id: "3", name: "no client record device", type: "phone" }, ]; add_task(async function bookmark() { // Open a unique page. let url = "http://example.com/browser_page_action_menu"; await BrowserTestUtils.withNewTab(url, async () => { // Open the panel. await promisePageActionPanelOpen(); // The bookmark button should read "Bookmark This Page" and not be starred. let bookmarkButton = document.getElementById("pageAction-panel-bookmark"); Assert.equal(bookmarkButton.label, "Bookmark This Page"); Assert.ok(!bookmarkButton.hasAttribute("starred")); // Click the button. let hiddenPromise = promisePageActionPanelHidden(); EventUtils.synthesizeMouseAtCenter(bookmarkButton, {}); await hiddenPromise; // Make sure the edit-bookmark panel opens, then hide it. await new Promise(resolve => { if (StarUI.panel.state == "open") { resolve(); return; } StarUI.panel.addEventListener("popupshown", resolve, { once: true }); }); Assert.equal(BookmarkingUI.starBox.getAttribute("open"), "true", "Star has open attribute"); StarUI.panel.hidePopup(); Assert.ok(!BookmarkingUI.starBox.hasAttribute("open"), "Star no longer has open attribute"); // Open the panel again. await promisePageActionPanelOpen(); // The bookmark button should now read "Edit This Bookmark" and be starred. Assert.equal(bookmarkButton.label, "Edit This Bookmark"); Assert.ok(bookmarkButton.hasAttribute("starred")); Assert.equal(bookmarkButton.getAttribute("starred"), "true"); // Click it again. hiddenPromise = promisePageActionPanelHidden(); EventUtils.synthesizeMouseAtCenter(bookmarkButton, {}); await hiddenPromise; // The edit-bookmark panel should open again. await new Promise(resolve => { if (StarUI.panel.state == "open") { resolve(); return; } StarUI.panel.addEventListener("popupshown", resolve, { once: true }); }); let onItemRemovedPromise = PlacesTestUtils.waitForNotification("onItemRemoved", (id, parentId, index, type, itemUrl) => url == itemUrl.spec); // Click the remove-bookmark button in the panel. StarUI._element("editBookmarkPanelRemoveButton").click(); // Wait for the bookmark to be removed before continuing. await onItemRemovedPromise; // Open the panel again. await promisePageActionPanelOpen(); // The bookmark button should read "Bookmark This Page" and not be starred. Assert.equal(bookmarkButton.label, "Bookmark This Page"); Assert.ok(!bookmarkButton.hasAttribute("starred")); // Done. hiddenPromise = promisePageActionPanelHidden(); BrowserPageActions.panelNode.hidePopup(); await hiddenPromise; }); }); add_task(async function emailLink() { // Open an actionable page so that the main page action button appears. (It // does not appear on about:blank for example.) let url = "http://example.com/"; await BrowserTestUtils.withNewTab(url, async () => { // Replace the email-link entry point to check whether it's called. let originalFn = MailIntegration.sendLinkForBrowser; let fnCalled = false; MailIntegration.sendLinkForBrowser = () => { fnCalled = true; }; registerCleanupFunction(() => { MailIntegration.sendLinkForBrowser = originalFn; }); // Open the panel and click Email Link. await promisePageActionPanelOpen(); let emailLinkButton = document.getElementById("pageAction-panel-emailLink"); let hiddenPromise = promisePageActionPanelHidden(); EventUtils.synthesizeMouseAtCenter(emailLinkButton, {}); await hiddenPromise; Assert.ok(fnCalled); }); }); add_task(async function copyURLFromPanel() { // Open an actionable page so that the main page action button appears. (It // does not appear on about:blank for example.) let url = "http://example.com/"; await BrowserTestUtils.withNewTab(url, async () => { // Add action to URL bar. let action = PageActions._builtInActions.find(a => a.id == "copyURL"); action.pinnedToUrlbar = true; registerCleanupFunction(() => action.pinnedToUrlbar = false); // Open the panel and click Copy URL. await promisePageActionPanelOpen(); Assert.ok(true, "page action panel opened"); let copyURLButton = document.getElementById("pageAction-panel-copyURL"); let hiddenPromise = promisePageActionPanelHidden(); EventUtils.synthesizeMouseAtCenter(copyURLButton, {}); await hiddenPromise; let feedbackPanel = document.getElementById("confirmation-hint"); let feedbackShownPromise = BrowserTestUtils.waitForEvent(feedbackPanel, "popupshown"); await feedbackShownPromise; Assert.equal(feedbackPanel.anchorNode.id, "pageActionButton", "Feedback menu should be anchored on the main Page Action button"); let feedbackHiddenPromise = promisePanelHidden("confirmation-hint"); await feedbackHiddenPromise; action.pinnedToUrlbar = false; }); }); add_task(async function copyURLFromURLBar() { // Open an actionable page so that the main page action button appears. (It // does not appear on about:blank for example.) let url = "http://example.com/"; await BrowserTestUtils.withNewTab(url, async () => { // Add action to URL bar. let action = PageActions._builtInActions.find(a => a.id == "copyURL"); action.pinnedToUrlbar = true; registerCleanupFunction(() => action.pinnedToUrlbar = false); let copyURLButton = document.getElementById("pageAction-urlbar-copyURL"); let feedbackShownPromise = promisePanelShown("confirmation-hint"); EventUtils.synthesizeMouseAtCenter(copyURLButton, {}); await feedbackShownPromise; let panel = document.getElementById("confirmation-hint"); Assert.equal(panel.anchorNode.id, "pageAction-urlbar-copyURL", "Feedback menu should be anchored on the main URL bar button"); let feedbackHiddenPromise = promisePanelHidden("confirmation-hint"); await feedbackHiddenPromise; action.pinnedToUrlbar = false; }); }); add_task(async function sendToDevice_nonSendable() { // Open a tab that's not sendable but where the page action buttons still // appear. about:about is convenient. await BrowserTestUtils.withNewTab("about:about", async () => { await promiseSyncReady(); // Open the panel. Send to Device should be disabled. await promisePageActionPanelOpen(); Assert.equal(BrowserPageActions.mainButtonNode.getAttribute("open"), "true", "Main button has 'open' attribute"); let panelButton = BrowserPageActions.panelButtonNodeForActionID("sendToDevice"); Assert.equal(panelButton.disabled, true, "The panel button should be disabled"); let hiddenPromise = promisePageActionPanelHidden(); BrowserPageActions.panelNode.hidePopup(); await hiddenPromise; Assert.ok(!BrowserPageActions.mainButtonNode.hasAttribute("open"), "Main button no longer has 'open' attribute"); // The urlbar button shouldn't exist. let urlbarButton = BrowserPageActions.urlbarButtonNodeForActionID("sendToDevice"); Assert.equal(urlbarButton, null, "The urlbar button shouldn't exist"); }); }); add_task(async function sendToDevice_syncNotReady_other_states() { // Open a tab that's sendable. await BrowserTestUtils.withNewTab("http://example.com/", async () => { await promiseSyncReady(); const sandbox = sinon.sandbox.create(); sandbox.stub(gSync, "syncReady").get(() => false); sandbox.stub(Weave.Service.clientsEngine, "isFirstSync").get(() => true); sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_NOT_VERIFIED }); sandbox.stub(gSync, "isSendableURI").returns(true); let cleanUp = () => { sandbox.restore(); }; registerCleanupFunction(cleanUp); // Open the panel. await promisePageActionPanelOpen(); let sendToDeviceButton = document.getElementById("pageAction-panel-sendToDevice"); Assert.ok(!sendToDeviceButton.disabled); // Click Send to Device. let viewPromise = promisePageActionViewShown(); EventUtils.synthesizeMouseAtCenter(sendToDeviceButton, {}); let view = await viewPromise; Assert.equal(view.id, "pageAction-panel-sendToDevice-subview"); let expectedItems = [ { className: "pageAction-sendToDevice-notReady", display: "none", disabled: true, }, { attrs: { label: "Account Not Verified", }, disabled: true, }, null, { attrs: { label: "Verify Your Account...", }, }, ]; checkSendToDeviceItems(expectedItems); // Done, hide the panel. let hiddenPromise = promisePageActionPanelHidden(); BrowserPageActions.panelNode.hidePopup(); await hiddenPromise; cleanUp(); }); }); add_task(async function sendToDevice_syncNotReady_configured() { // Open a tab that's sendable. await BrowserTestUtils.withNewTab("http://example.com/", async () => { await promiseSyncReady(); const sandbox = sinon.sandbox.create(); const syncReady = sandbox.stub(gSync, "syncReady").get(() => false); const lastSync = sandbox.stub(Weave.Service.clientsEngine, "isFirstSync").get(() => true); sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN }); sandbox.stub(gSync, "isSendableURI").returns(true); sandbox.stub(Weave.Service, "sync").callsFake(() => { syncReady.get(() => true); lastSync.get(() => Date.now()); - sandbox.stub(gSync, "remoteClients").get(() => mockRemoteClients); - sandbox.stub(Weave.Service.clientsEngine, "getClientType").callsFake(id => mockRemoteClients.find(c => c.id == id).type); + sandbox.stub(gSync, "sendTabTargets").get(() => mockTargets); + sandbox.stub(Weave.Service.clientsEngine, "getClientType").callsFake(id => mockTargets.find(c => c.clientRecord && c.clientRecord.id == id).clientRecord.type); }); let onShowingSubview = BrowserPageActions.sendToDevice.onShowingSubview; sandbox.stub(BrowserPageActions.sendToDevice, "onShowingSubview").callsFake((...args) => { this.numCall++ || (this.numCall = 1); onShowingSubview.call(BrowserPageActions.sendToDevice, ...args); testSendTabToDeviceMenu(this.numCall); }); let cleanUp = () => { sandbox.restore(); }; registerCleanupFunction(cleanUp); // Open the panel. await promisePageActionPanelOpen(); let sendToDeviceButton = document.getElementById("pageAction-panel-sendToDevice"); Assert.ok(!sendToDeviceButton.disabled); // Click Send to Device. let viewPromise = promisePageActionViewShown(); EventUtils.synthesizeMouseAtCenter(sendToDeviceButton, {}); let view = await viewPromise; Assert.equal(view.id, "pageAction-panel-sendToDevice-subview"); function testSendTabToDeviceMenu(numCall) { if (numCall == 1) { // "Syncing devices" should be shown. checkSendToDeviceItems([ { className: "pageAction-sendToDevice-notReady", disabled: true, }, ]); } else if (numCall == 2) { // The devices should be shown in the subview. let expectedItems = [ { className: "pageAction-sendToDevice-notReady", display: "none", disabled: true, }, ]; - for (let client of mockRemoteClients) { + for (let target of mockTargets) { expectedItems.push({ attrs: { - clientId: client.id, - label: client.name, - clientType: client.type, - tooltiptext: gSync.formatLastSyncDate(new Date(lastModifiedFixture * 1000)), + clientId: target.id, + label: target.name, + clientType: target.type, + tooltiptext: target.clientRecord ? gSync.formatLastSyncDate(new Date(lastModifiedFixture * 1000)) : "", }, }); } expectedItems.push( null, { attrs: { label: "Send to All Devices", }, } ); checkSendToDeviceItems(expectedItems); } else { ok(false, "This should never happen"); } } // Done, hide the panel. let hiddenPromise = promisePageActionPanelHidden(); BrowserPageActions.panelNode.hidePopup(); await hiddenPromise; cleanUp(); }); }); add_task(async function sendToDevice_notSignedIn() { // Open a tab that's sendable. await BrowserTestUtils.withNewTab("http://example.com/", async () => { await promiseSyncReady(); // Open the panel. await promisePageActionPanelOpen(); let sendToDeviceButton = document.getElementById("pageAction-panel-sendToDevice"); Assert.ok(!sendToDeviceButton.disabled); // Click Send to Device. let viewPromise = promisePageActionViewShown(); EventUtils.synthesizeMouseAtCenter(sendToDeviceButton, {}); let view = await viewPromise; Assert.equal(view.id, "pageAction-panel-sendToDevice-subview"); let expectedItems = [ { className: "pageAction-sendToDevice-notReady", display: "none", disabled: true, }, { attrs: { label: "Not Connected to Sync", }, disabled: true, }, null, { attrs: { label: "Sign in to Sync...", }, }, { attrs: { label: "Learn About Sending Tabs...", }, }, ]; checkSendToDeviceItems(expectedItems); // Done, hide the panel. let hiddenPromise = promisePageActionPanelHidden(); BrowserPageActions.panelNode.hidePopup(); await hiddenPromise; }); }); add_task(async function sendToDevice_noDevices() { // Open a tab that's sendable. await BrowserTestUtils.withNewTab("http://example.com/", async () => { await promiseSyncReady(); const sandbox = sinon.sandbox.create(); sandbox.stub(gSync, "syncReady").get(() => true); sandbox.stub(Weave.Service.clientsEngine, "isFirstSync").get(() => false); sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN }); sandbox.stub(gSync, "isSendableURI").returns(true); - sandbox.stub(gSync, "remoteClients").get(() => []); - sandbox.stub(Weave.Service.clientsEngine, "getClientType").callsFake(id => mockRemoteClients.find(c => c.id == id).type); + sandbox.stub(gSync, "sendTabTargets").get(() => []); + sandbox.stub(Weave.Service.clientsEngine, "getClientType").callsFake(id => mockTargets.find(c => c.clientRecord && c.clientRecord.id == id).clientRecord.type); let cleanUp = () => { sandbox.restore(); }; registerCleanupFunction(cleanUp); // Open the panel. await promisePageActionPanelOpen(); let sendToDeviceButton = document.getElementById("pageAction-panel-sendToDevice"); Assert.ok(!sendToDeviceButton.disabled); // Click Send to Device. let viewPromise = promisePageActionViewShown(); EventUtils.synthesizeMouseAtCenter(sendToDeviceButton, {}); let view = await viewPromise; Assert.equal(view.id, "pageAction-panel-sendToDevice-subview"); let expectedItems = [ { className: "pageAction-sendToDevice-notReady", display: "none", disabled: true, }, { attrs: { label: "No Devices Connected", }, disabled: true, }, null, { attrs: { label: "Connect Another Device...", }, }, { attrs: { label: "Learn About Sending Tabs...", }, }, ]; checkSendToDeviceItems(expectedItems); // Done, hide the panel. let hiddenPromise = promisePageActionPanelHidden(); BrowserPageActions.panelNode.hidePopup(); await hiddenPromise; cleanUp(); await UIState.reset(); }); }); add_task(async function sendToDevice_devices() { // Open a tab that's sendable. await BrowserTestUtils.withNewTab("http://example.com/", async () => { await promiseSyncReady(); const sandbox = sinon.sandbox.create(); sandbox.stub(gSync, "syncReady").get(() => true); sandbox.stub(Weave.Service.clientsEngine, "isFirstSync").get(() => false); sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN }); sandbox.stub(gSync, "isSendableURI").returns(true); - sandbox.stub(gSync, "remoteClients").get(() => mockRemoteClients); - sandbox.stub(Weave.Service.clientsEngine, "getClientType").callsFake(id => mockRemoteClients.find(c => c.id == id).type); + sandbox.stub(gSync, "sendTabTargets").get(() => mockTargets); + sandbox.stub(Weave.Service.clientsEngine, "getClientType").callsFake(id => mockTargets.find(c => c.clientRecord && c.clientRecord.id == id).clientRecord.type); let cleanUp = () => { sandbox.restore(); }; registerCleanupFunction(cleanUp); // Open the panel. await promisePageActionPanelOpen(); let sendToDeviceButton = document.getElementById("pageAction-panel-sendToDevice"); Assert.ok(!sendToDeviceButton.disabled); // Click Send to Device. let viewPromise = promisePageActionViewShown(); EventUtils.synthesizeMouseAtCenter(sendToDeviceButton, {}); let view = await viewPromise; Assert.equal(view.id, "pageAction-panel-sendToDevice-subview"); // The devices should be shown in the subview. let expectedItems = [ { className: "pageAction-sendToDevice-notReady", display: "none", disabled: true, }, ]; - for (let client of mockRemoteClients) { + for (let target of mockTargets) { expectedItems.push({ attrs: { - clientId: client.id, - label: client.name, - clientType: client.type, + clientId: target.id, + label: target.name, + clientType: target.type, }, }); } expectedItems.push( null, { attrs: { label: "Send to All Devices", }, } ); checkSendToDeviceItems(expectedItems); // Done, hide the panel. let hiddenPromise = promisePageActionPanelHidden(); BrowserPageActions.panelNode.hidePopup(); await hiddenPromise; cleanUp(); }); }); add_task(async function sendToDevice_title() { // Open two tabs that are sendable. await BrowserTestUtils.withNewTab("http://example.com/a", async otherBrowser => { await BrowserTestUtils.withNewTab("http://example.com/b", async () => { await promiseSyncReady(); const sandbox = sinon.sandbox.create(); sandbox.stub(gSync, "syncReady").get(() => true); sandbox.stub(Weave.Service.clientsEngine, "isFirstSync").get(() => false); sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN }); sandbox.stub(gSync, "isSendableURI").returns(true); - sandbox.stub(gSync, "remoteClients").get(() => []); - sandbox.stub(Weave.Service.clientsEngine, "getClientType").callsFake(id => mockRemoteClients.find(c => c.id == id).type); + sandbox.stub(gSync, "sendTabTargets").get(() => []); + sandbox.stub(Weave.Service.clientsEngine, "getClientType").callsFake(id => mockTargets.find(c => c.clientRecord && c.clientRecord.id == id).clientRecord.type); let cleanUp = () => { sandbox.restore(); }; registerCleanupFunction(cleanUp); // Open the panel. Only one tab is selected, so the action's title should // be "Send Tab to Device". await promisePageActionPanelOpen(); let sendToDeviceButton = document.getElementById("pageAction-panel-sendToDevice"); Assert.ok(!sendToDeviceButton.disabled); Assert.equal(sendToDeviceButton.label, "Send Tab to Device"); Assert.equal(PageActions.actionForID("sendToDevice").getTitle(window), "Send Tab to Device"); // Hide the panel. let hiddenPromise = promisePageActionPanelHidden(); BrowserPageActions.panelNode.hidePopup(); await hiddenPromise; // Add the other tab to the selection. gBrowser.addToMultiSelectedTabs(gBrowser.getTabForBrowser(otherBrowser), false); // Open the panel again. Now the action's title should be "Send 2 Tabs to // Device". await promisePageActionPanelOpen(); Assert.ok(!sendToDeviceButton.disabled); Assert.equal(sendToDeviceButton.label, "Send 2 Tabs to Device"); Assert.equal(PageActions.actionForID("sendToDevice").getTitle(window), "Send 2 Tabs to Device"); // Hide the panel. hiddenPromise = promisePageActionPanelHidden(); BrowserPageActions.panelNode.hidePopup(); await hiddenPromise; cleanUp(); await UIState.reset(); }); }); }); add_task(async function sendToDevice_inUrlbar() { // Open a tab that's sendable. await BrowserTestUtils.withNewTab("http://example.com/", async () => { await promiseSyncReady(); const sandbox = sinon.sandbox.create(); sandbox.stub(gSync, "syncReady").get(() => true); sandbox.stub(Weave.Service.clientsEngine, "isFirstSync").get(() => false); sandbox.stub(UIState, "get").returns({ status: UIState.STATUS_SIGNED_IN }); sandbox.stub(gSync, "isSendableURI").returns(true); - sandbox.stub(gSync, "remoteClients").get(() => mockRemoteClients); - sandbox.stub(Weave.Service.clientsEngine, "getClientType").callsFake(id => mockRemoteClients.find(c => c.id == id).type); + sandbox.stub(gSync, "sendTabTargets").get(() => mockTargets); + sandbox.stub(Weave.Service.clientsEngine, "getClientType").callsFake(id => mockTargets.find(c => c.clientRecord && c.clientRecord.id == id).clientRecord.type); let cleanUp = () => { sandbox.restore(); }; registerCleanupFunction(cleanUp); // Add Send to Device to the urlbar. let action = PageActions.actionForID("sendToDevice"); action.pinnedToUrlbar = true; // Click it to open its panel. let urlbarButton = document.getElementById( BrowserPageActions.urlbarButtonNodeIDForActionID(action.id) ); Assert.notEqual(urlbarButton, null, "The urlbar button should exist"); Assert.ok(!urlbarButton.disabled, "The urlbar button should not be disabled"); EventUtils.synthesizeMouseAtCenter(urlbarButton, {}); // The panel element for _activatedActionPanelID is created synchronously // only after the associated button has been clicked. await promisePanelShown(BrowserPageActions._activatedActionPanelID); Assert.equal(urlbarButton.getAttribute("open"), "true", "Button has open attribute"); // The devices should be shown in the subview. let expectedItems = [ { className: "pageAction-sendToDevice-notReady", display: "none", disabled: true, }, ]; - for (let client of mockRemoteClients) { + for (let target of mockTargets) { expectedItems.push({ attrs: { - clientId: client.id, - label: client.name, - clientType: client.type, + clientId: target.id, + label: target.name, + clientType: target.type, }, }); } expectedItems.push( null, { attrs: { label: "Send to All Devices", }, } ); checkSendToDeviceItems(expectedItems, true); // Get the first device menu item in the panel. let bodyID = BrowserPageActions._panelViewNodeIDForActionID("sendToDevice", true) + "-body"; let body = document.getElementById(bodyID); let deviceMenuItem = body.querySelector(".sendtab-target"); Assert.notEqual(deviceMenuItem, null); // For good measure, wait until it's visible. let dwu = window.windowUtils; await BrowserTestUtils.waitForCondition(() => { let bounds = dwu.getBoundsWithoutFlushing(deviceMenuItem); return bounds.height > 0 && bounds.width > 0; }, "Waiting for first device menu item to appear"); // Click it, which should cause the panel to close. let hiddenPromise = promisePanelHidden(BrowserPageActions._activatedActionPanelID); EventUtils.synthesizeMouseAtCenter(deviceMenuItem, {}); info("Waiting for Send to Device panel to close after clicking a device"); await hiddenPromise; Assert.ok(!urlbarButton.hasAttribute("open"), "URL bar button no longer has open attribute"); // And then the "Sent!" notification panel should open and close by itself // after a moment. info("Waiting for the Sent! notification panel to open"); await promisePanelShown(ConfirmationHint._panel.id); Assert.equal( ConfirmationHint._panel.anchorNode.id, urlbarButton.id ); info("Waiting for the Sent! notification panel to close"); await promisePanelHidden(ConfirmationHint._panel.id); // Remove Send to Device from the urlbar. action.pinnedToUrlbar = false; cleanUp(); }); }); add_task(async function contextMenu() { // Open an actionable page so that the main page action button appears. let url = "http://example.com/"; await BrowserTestUtils.withNewTab(url, async () => { // Open the panel and then open the context menu on the bookmark button. await promisePageActionPanelOpen(); let bookmarkButton = document.getElementById("pageAction-panel-bookmark"); let contextMenuPromise = promisePanelShown("pageActionContextMenu"); EventUtils.synthesizeMouseAtCenter(bookmarkButton, { type: "contextmenu", button: 2, }); await contextMenuPromise; // The context menu should show the "remove" item. Click it. let menuItems = collectContextMenuItems(); Assert.equal(menuItems.length, 1, "Context menu has one child"); Assert.equal(menuItems[0].label, "Remove from Address Bar", "Context menu is in the 'remove' state"); contextMenuPromise = promisePanelHidden("pageActionContextMenu"); EventUtils.synthesizeMouseAtCenter(menuItems[0], {}); await contextMenuPromise; // The action should be removed from the urlbar. In this case, the bookmark // star, the node in the urlbar should be hidden. let starButtonBox = document.getElementById("star-button-box"); await BrowserTestUtils.waitForCondition(() => { return starButtonBox.hidden; }, "Waiting for star button to become hidden"); // Open the context menu again on the bookmark button. (The page action // panel remains open.) contextMenuPromise = promisePanelShown("pageActionContextMenu"); EventUtils.synthesizeMouseAtCenter(bookmarkButton, { type: "contextmenu", button: 2, }); await contextMenuPromise; // The context menu should show the "add" item. Click it. menuItems = collectContextMenuItems(); Assert.equal(menuItems.length, 1, "Context menu has one child"); Assert.equal(menuItems[0].label, "Add to Address Bar", "Context menu is in the 'add' state"); contextMenuPromise = promisePanelHidden("pageActionContextMenu"); EventUtils.synthesizeMouseAtCenter(menuItems[0], {}); await contextMenuPromise; // The action should be added to the urlbar. await BrowserTestUtils.waitForCondition(() => { return !starButtonBox.hidden; }, "Waiting for star button to become unhidden"); // Open the context menu on the bookmark star in the urlbar. contextMenuPromise = promisePanelShown("pageActionContextMenu"); EventUtils.synthesizeMouseAtCenter(starButtonBox, { type: "contextmenu", button: 2, }); await contextMenuPromise; // The context menu should show the "remove" item. Click it. menuItems = collectContextMenuItems(); Assert.equal(menuItems.length, 1, "Context menu has one child"); Assert.equal(menuItems[0].label, "Remove from Address Bar", "Context menu is in the 'remove' state"); contextMenuPromise = promisePanelHidden("pageActionContextMenu"); EventUtils.synthesizeMouseAtCenter(menuItems[0], {}); await contextMenuPromise; // The action should be removed from the urlbar. await BrowserTestUtils.waitForCondition(() => { return starButtonBox.hidden; }, "Waiting for star button to become hidden"); // Finally, add the bookmark star back to the urlbar so that other tests // that rely on it are OK. await promisePageActionPanelOpen(); contextMenuPromise = promisePanelShown("pageActionContextMenu"); EventUtils.synthesizeMouseAtCenter(bookmarkButton, { type: "contextmenu", button: 2, }); await contextMenuPromise; menuItems = collectContextMenuItems(); Assert.equal(menuItems.length, 1, "Context menu has one child"); Assert.equal(menuItems[0].label, "Add to Address Bar", "Context menu is in the 'add' state"); contextMenuPromise = promisePanelHidden("pageActionContextMenu"); EventUtils.synthesizeMouseAtCenter(menuItems[0], {}); await contextMenuPromise; await BrowserTestUtils.waitForCondition(() => { return !starButtonBox.hidden; }, "Waiting for star button to become unhidden"); }); // urlbar tests that run after this one can break if the mouse is left over // the area where the urlbar popup appears, which seems to happen due to the // above synthesized mouse events. Move it over the urlbar. EventUtils.synthesizeMouseAtCenter(gURLBar, { type: "mousemove" }); gURLBar.focus(); }); function promiseSyncReady() { let service = Cc["@mozilla.org/weave/service;1"] .getService(Ci.nsISupports) .wrappedJSObject; return service.whenLoaded().then(() => { UIState.isReady(); return UIState.refresh(); }); } function checkSendToDeviceItems(expectedItems, forUrlbar = false) { let bodyID = BrowserPageActions._panelViewNodeIDForActionID("sendToDevice", forUrlbar) + "-body"; let body = document.getElementById(bodyID); Assert.equal(body.children.length, expectedItems.length); for (let i = 0; i < expectedItems.length; i++) { let expected = expectedItems[i]; let actual = body.children[i]; if (!expected) { Assert.equal(actual.localName, "toolbarseparator"); continue; } if ("id" in expected) { Assert.equal(actual.id, expected.id); } if ("className" in expected) { let expectedNames = expected.className.split(/\s+/); for (let name of expectedNames) { Assert.ok(actual.classList.contains(name), `classList contains: ${name}`); } } let display = "display" in expected ? expected.display : "-moz-box"; Assert.equal(getComputedStyle(actual).display, display); let disabled = "disabled" in expected ? expected.disabled : false; Assert.equal(actual.disabled, disabled); if ("attrs" in expected) { for (let name in expected.attrs) { Assert.ok(actual.hasAttribute(name)); let attrVal = actual.getAttribute(name); if (name == "label") { attrVal = attrVal.normalize("NFKC"); // There's a bug with … } Assert.equal(attrVal, expected.attrs[name]); } } } } function collectContextMenuItems() { let contextMenu = document.getElementById("pageActionContextMenu"); return Array.filter(contextMenu.children, node => { return window.getComputedStyle(node).visibility == "visible"; }); } diff --git a/services/fxaccounts/FxAccountsCommands.js b/services/fxaccounts/FxAccountsCommands.js --- a/services/fxaccounts/FxAccountsCommands.js +++ b/services/fxaccounts/FxAccountsCommands.js @@ -1,305 +1,298 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const EXPORTED_SYMBOLS = ["SendTab", "FxAccountsCommands"]; ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js"); ChromeUtils.import("resource://gre/modules/Preferences.jsm"); ChromeUtils.defineModuleGetter(this, "PushCrypto", "resource://gre/modules/PushCrypto.jsm"); ChromeUtils.import("resource://gre/modules/Services.jsm"); ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); ChromeUtils.import("resource://services-common/observers.js"); XPCOMUtils.defineLazyModuleGetters(this, { BulkKeyBundle: "resource://services-sync/keys.js", CommonUtils: "resource://services-common/utils.js", CryptoUtils: "resource://services-crypto/utils.js", CryptoWrapper: "resource://services-sync/record.js", }); class FxAccountsCommands { constructor(fxAccounts) { this._fxAccounts = fxAccounts; this.sendTab = new SendTab(this, fxAccounts); } async invoke(command, device, payload) { const userData = await this._fxAccounts.getSignedInUser(); if (!userData) { throw new Error("No user."); } const {sessionToken} = userData; if (!sessionToken) { throw new Error("_send called without a session token."); } const client = this._fxAccounts.getAccountsClient(); await client.invokeCommand(sessionToken, command, device.id, payload); log.info(`Payload sent to device ${device.id}.`); } async consumeRemoteCommand(index) { if (!Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true)) { return false; } log.info(`Consuming command with index ${index}.`); const {messages} = await this._fetchRemoteCommands(index, 1); if (messages.length != 1) { log.warn(`Should have retrieved 1 and only 1 message, got ${messages.length}.`); } return this._fxAccounts._withCurrentAccountState(async (getUserData, updateUserData) => { const {device} = await getUserData(["device"]); if (!device) { throw new Error("No device registration."); } const handledCommands = (device.handledCommands || []).concat(messages.map(m => m.index)); await updateUserData({ device: {...device, handledCommands}, }); await this._handleCommands(messages); // Once the handledCommands array length passes a threshold, check the // potentially missed remote commands in order to clear it. if (handledCommands.length > 20) { await this.fetchMissedRemoteCommands(); } }); } async fetchMissedRemoteCommands() { if (!Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true)) { return false; } log.info(`Consuming missed commands.`); await this._fxAccounts._withCurrentAccountState(async (getUserData, updateUserData) => { const {device} = await getUserData(["device"]); if (!device) { throw new Error("No device registration."); } const lastCommandIndex = device.lastCommandIndex || 0; const handledCommands = device.handledCommands || []; handledCommands.push(lastCommandIndex); // Because the server also returns this command. const {index, messages} = await this._fetchRemoteCommands(lastCommandIndex); const missedMessages = messages.filter(m => !handledCommands.includes(m.index)); await updateUserData({ device: {...device, lastCommandIndex: index, handledCommands: []}, }); if (missedMessages.length) { log.info(`Handling ${missedMessages.length} missed messages`); Services.telemetry.scalarAdd("identity.fxaccounts.missed_commands_fetched", missedMessages.length); await this._handleCommands(missedMessages); } }); return true; } async _fetchRemoteCommands(index, limit = null) { const userData = await this._fxAccounts.getSignedInUser(); if (!userData) { throw new Error("No user."); } const {sessionToken} = userData; if (!sessionToken) { throw new Error("No session token."); } const client = this._fxAccounts.getAccountsClient(); const opts = {index}; if (limit != null) { opts.limit = limit; } return client.getCommands(sessionToken, opts); } async _handleCommands(messages) { const fxaDevices = await this._fxAccounts.getDeviceList(); for (const {data} of messages) { let {command, payload, sender} = data; if (sender) { sender = fxaDevices.find(d => d.id == sender); } switch (command) { case COMMAND_SENDTAB: try { await this.sendTab.handle(sender, payload); } catch (e) { log.error(`Error while handling incoming Send Tab payload.`, e); } break; default: log.info(`Unknown command: ${command}.`); } } } } /** * Send Tab is built on top of FxA commands. * * Devices exchange keys wrapped in kSync between themselves (getEncryptedKey) * during the device registration flow. The FxA server can theorically never * retrieve the send tab keys since it doesn't know kSync. */ class SendTab { constructor(commands, fxAccounts) { this._commands = commands; this._fxAccounts = fxAccounts; } /** * @param {Device[]} to - Device objects (typically returned by fxAccounts.getDevicesList()). * @param {Object} tab * @param {string} tab.url * @param {string} tab.title * @returns A report object, in the shape of * {succeded: [Device], error: [{device: Device, error: Exception}]} */ async send(to, tab) { log.info(`Sending a tab to ${to.length} devices.`); const encoder = new TextEncoder("utf8"); const data = { entries: [{title: tab.title, url: tab.url}], }; const bytes = encoder.encode(JSON.stringify(data)); const report = { succeeded: [], failed: [], }; for (let device of to) { try { const encrypted = await this._encrypt(bytes, device); const payload = {encrypted}; await this._commands.invoke(COMMAND_SENDTAB, device, payload); // FxA needs an object. report.succeeded.push(device); } catch (error) { log.error("Error while invoking a send tab command.", error); report.failed.push({device, error}); } } return report; } // Returns true if the target device is compatible with FxA Commands Send tab. - async isDeviceCompatible(device) { - if (!Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true) || - !device.availableCommands || !device.availableCommands[COMMAND_SENDTAB]) { - return false; - } - const {kid: theirKid} = JSON.parse(device.availableCommands[COMMAND_SENDTAB]); - const ourKid = await this._getKid(); - return theirKid == ourKid; + isDeviceCompatible(device) { + return Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true) && + device.availableCommands && device.availableCommands[COMMAND_SENDTAB]; } // Handle incoming send tab payload, called by FxAccountsCommands. async handle(sender, {encrypted}) { if (!sender) { log.warn("Incoming tab is from an unknown device (maybe disconnected?)"); } const bytes = await this._decrypt(encrypted); const decoder = new TextDecoder("utf8"); const data = JSON.parse(decoder.decode(bytes)); const current = data.hasOwnProperty("current") ? data.current : data.entries.length - 1; const tabSender = { id: sender ? sender.id : "", name: sender ? sender.name : "", }; const {title, url: uri} = data.entries[current]; log.info(`Tab received with FxA commands: ${title} from ${tabSender.name}.`); Observers.notify("fxaccounts:commands:open-uri", [{uri, title, sender: tabSender}]); } - async _getKid() { - let {kXCS} = await this._fxAccounts.getKeys(); - return kXCS; - } - async _encrypt(bytes, device) { let bundle = device.availableCommands[COMMAND_SENDTAB]; if (!bundle) { throw new Error(`Device ${device.id} does not have send tab keys.`); } + const {kSync, kXCS: ourKid} = await this._fxAccounts.getKeys(); + const {kid: theirKid} = JSON.parse(device.availableCommands[COMMAND_SENDTAB]); + if (theirKid != ourKid) { + throw new Error("Target Send Tab key ID is different from ours"); + } const json = JSON.parse(bundle); const wrapper = new CryptoWrapper(); wrapper.deserialize({payload: json}); - const {kSync} = await this._fxAccounts.getKeys(); const syncKeyBundle = BulkKeyBundle.fromHexKey(kSync); let {publicKey, authSecret} = await wrapper.decrypt(syncKeyBundle); authSecret = urlsafeBase64Decode(authSecret); publicKey = urlsafeBase64Decode(publicKey); const {ciphertext: encrypted} = await PushCrypto.encrypt(bytes, publicKey, authSecret); return urlsafeBase64Encode(encrypted); } async _getKeys() { const {device} = await this._fxAccounts.getSignedInUser(); return device && device.sendTabKeys; } async _decrypt(ciphertext) { let {privateKey, publicKey, authSecret} = await this._getKeys(); publicKey = urlsafeBase64Decode(publicKey); authSecret = urlsafeBase64Decode(authSecret); ciphertext = new Uint8Array(urlsafeBase64Decode(ciphertext)); return PushCrypto.decrypt(privateKey, publicKey, authSecret, // The only Push encoding we support. {encoding: "aes128gcm"}, ciphertext); } async _generateAndPersistKeys() { let [publicKey, privateKey] = await PushCrypto.generateKeys(); publicKey = urlsafeBase64Encode(publicKey); let authSecret = PushCrypto.generateAuthenticationSecret(); authSecret = urlsafeBase64Encode(authSecret); const sendTabKeys = { publicKey, privateKey, authSecret, }; await this._fxAccounts._withCurrentAccountState(async (getUserData, updateUserData) => { const {device} = await getUserData(); await updateUserData({ device: { ...device, sendTabKeys, }, }); }); return sendTabKeys; } async getEncryptedKey() { let sendTabKeys = await this._getKeys(); if (!sendTabKeys) { sendTabKeys = await this._generateAndPersistKeys(); } // Strip the private key from the bundle to encrypt. const keyToEncrypt = { publicKey: sendTabKeys.publicKey, authSecret: sendTabKeys.authSecret, }; - const {kSync} = await this._fxAccounts.getSignedInUser(); - if (!kSync) { + const {kSync, kXCS} = await this._fxAccounts.getKeys(); + if (!kSync || !kXCS) { return null; } const wrapper = new CryptoWrapper(); wrapper.cleartext = keyToEncrypt; const keyBundle = BulkKeyBundle.fromHexKey(kSync); await wrapper.encrypt(keyBundle); - const kid = await this._getKid(); return JSON.stringify({ - kid, + kid: kXCS, IV: wrapper.IV, hmac: wrapper.hmac, ciphertext: wrapper.ciphertext, }); } } function urlsafeBase64Encode(buffer) { return ChromeUtils.base64URLEncode(new Uint8Array(buffer), {pad: false}); } function urlsafeBase64Decode(str) { return ChromeUtils.base64URLDecode(str, {padding: "reject"}); } diff --git a/services/fxaccounts/tests/xpcshell/test_commands.js b/services/fxaccounts/tests/xpcshell/test_commands.js --- a/services/fxaccounts/tests/xpcshell/test_commands.js +++ b/services/fxaccounts/tests/xpcshell/test_commands.js @@ -1,108 +1,93 @@ /* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; ChromeUtils.import("resource://testing-common/Assert.jsm"); ChromeUtils.import("resource://gre/modules/FxAccountsCommands.js"); add_task(async function test_sendtab_isDeviceCompatible() { - const fxAccounts = { - getKeys() { - return { - kXCS: "abcd", - }; - }, - }; - const sendTab = new SendTab(null, fxAccounts); + const sendTab = new SendTab(null, null); let device = {name: "My device"}; - Assert.ok(!(await sendTab.isDeviceCompatible(device))); + Assert.ok(!sendTab.isDeviceCompatible(device)); device = {name: "My device", availableCommands: {}}; - Assert.ok(!(await sendTab.isDeviceCompatible(device))); + Assert.ok(!sendTab.isDeviceCompatible(device)); device = {name: "My device", availableCommands: { - "https://identity.mozilla.com/cmd/open-uri": JSON.stringify({ - kid: "dcba", - }), + "https://identity.mozilla.com/cmd/open-uri": "payload", }}; - Assert.ok(!(await sendTab.isDeviceCompatible(device))); - device = {name: "My device", availableCommands: { - "https://identity.mozilla.com/cmd/open-uri": JSON.stringify({ - kid: "abcd", - }), - }}; - Assert.ok((await sendTab.isDeviceCompatible(device))); + Assert.ok(sendTab.isDeviceCompatible(device)); }); add_task(async function test_sendtab_send() { const commands = { invoke: sinon.spy((cmd, device, payload) => { if (device.name == "Device 1") { throw new Error("Invoke error!"); } Assert.equal(payload.encrypted, "encryptedpayload"); }), }; const sendTab = new SendTab(commands, null); sendTab._encrypt = (bytes, device) => { if (device.name == "Device 2") { throw new Error("Encrypt error!"); } return "encryptedpayload"; }; const to = [ {name: "Device 1"}, {name: "Device 2"}, {name: "Device 3"}, ]; const tab = {title: "Foo", url: "https://foo.bar/"}; const report = await sendTab.send(to, tab); Assert.equal(report.succeeded.length, 1); Assert.equal(report.failed.length, 2); Assert.equal(report.succeeded[0].name, "Device 3"); Assert.equal(report.failed[0].device.name, "Device 1"); Assert.equal(report.failed[0].error.message, "Invoke error!"); Assert.equal(report.failed[1].device.name, "Device 2"); Assert.equal(report.failed[1].error.message, "Encrypt error!"); Assert.ok(commands.invoke.calledTwice); }); add_task(async function test_commands_fetchMissedRemoteCommands() { const accountState = { data: { device: { handledCommands: [8, 9, 10, 11], lastCommandIndex: 11, }, }, }; const fxAccounts = { async _withCurrentAccountState(cb) { const get = () => accountState.data; const set = (val) => { accountState.data = val; }; await cb(get, set); }, }; const commands = new FxAccountsCommands(fxAccounts); commands._fetchRemoteCommands = () => { return { index: 12, messages: [ { index: 11, data: {}, }, { index: 12, data: {}, }, ], }; }; commands._handleCommands = sinon.spy(); await commands.fetchMissedRemoteCommands(); Assert.equal(accountState.data.device.handledCommands.length, 0); Assert.equal(accountState.data.device.lastCommandIndex, 12); const callArgs = commands._handleCommands.args[0][0]; Assert.equal(callArgs[0].index, 12); }); diff --git a/services/sync/modules/engines/clients.js b/services/sync/modules/engines/clients.js --- a/services/sync/modules/engines/clients.js +++ b/services/sync/modules/engines/clients.js @@ -1,1097 +1,1111 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** * How does the clients engine work? * * - We use 2 files - commands.json and commands-syncing.json. * * - At sync upload time, we attempt a rename of commands.json to * commands-syncing.json, and ignore errors (helps for crash during sync!). * - We load commands-syncing.json and stash the contents in * _currentlySyncingCommands which lives for the duration of the upload process. * - We use _currentlySyncingCommands to build the outgoing records * - Immediately after successful upload, we delete commands-syncing.json from * disk (and clear _currentlySyncingCommands). We reconcile our local records * with what we just wrote in the server, and add failed IDs commands * back in commands.json * - Any time we need to "save" a command for future syncs, we load * commands.json, update it, and write it back out. */ var EXPORTED_SYMBOLS = [ "ClientEngine", "ClientsRec", ]; ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); ChromeUtils.import("resource://gre/modules/Services.jsm"); ChromeUtils.import("resource://services-common/async.js"); ChromeUtils.import("resource://services-sync/constants.js"); ChromeUtils.import("resource://services-sync/engines.js"); ChromeUtils.import("resource://services-sync/record.js"); ChromeUtils.import("resource://services-sync/resource.js"); ChromeUtils.import("resource://services-sync/util.js"); ChromeUtils.defineModuleGetter(this, "fxAccounts", "resource://gre/modules/FxAccounts.jsm"); ChromeUtils.defineModuleGetter(this, "getRepairRequestor", "resource://services-sync/collection_repair.js"); ChromeUtils.defineModuleGetter(this, "getRepairResponder", "resource://services-sync/collection_repair.js"); const CLIENTS_TTL = 1814400; // 21 days const CLIENTS_TTL_REFRESH = 604800; // 7 days const STALE_CLIENT_REMOTE_AGE = 604800; // 7 days // TTL of the message sent to another device when sending a tab const NOTIFY_TAB_SENT_TTL_SECS = 1 * 3600; // 1 hour +// This is to avoid multiple sequential syncs ending up calling +// this expensive endpoint multiple times in a row. +const TIME_BETWEEN_FXA_DEVICES_FETCH_MS = 10 * 1000; + // Reasons behind sending collection_changed push notifications. const COLLECTION_MODIFIED_REASON_SENDTAB = "sendtab"; const COLLECTION_MODIFIED_REASON_FIRSTSYNC = "firstsync"; const SUPPORTED_PROTOCOL_VERSIONS = [SYNC_API_VERSION]; const LAST_MODIFIED_ON_PROCESS_COMMAND_PREF = "services.sync.clients.lastModifiedOnProcessCommands"; function hasDupeCommand(commands, action) { if (!commands) { return false; } return commands.some(other => other.command == action.command && Utils.deepEquals(other.args, action.args)); } function ClientsRec(collection, id) { CryptoWrapper.call(this, collection, id); } ClientsRec.prototype = { __proto__: CryptoWrapper.prototype, _logName: "Sync.Record.Clients", ttl: CLIENTS_TTL, }; Utils.deferGetSet(ClientsRec, "cleartext", ["name", "type", "commands", "version", "protocols", "formfactor", "os", "appPackage", "application", "device", "fxaDeviceId"]); function ClientEngine(service) { SyncEngine.call(this, "Clients", service); this.fxAccounts = fxAccounts; this.addClientCommandQueue = Async.asyncQueueCaller(this._log); Utils.defineLazyIDProperty(this, "localID", "services.sync.client.GUID"); } ClientEngine.prototype = { __proto__: SyncEngine.prototype, _storeObj: ClientStore, _recordObj: ClientsRec, _trackerObj: ClientsTracker, allowSkippedRecord: false, _knownStaleFxADeviceIds: null, _lastDeviceCounts: null, async initialize() { // Reset the last sync timestamp on every startup so that we fetch all clients await this.resetLastSync(); }, // These two properties allow us to avoid replaying the same commands // continuously if we cannot manage to upload our own record. _localClientLastModified: 0, get _lastModifiedOnProcessCommands() { return Services.prefs.getIntPref(LAST_MODIFIED_ON_PROCESS_COMMAND_PREF, -1); }, set _lastModifiedOnProcessCommands(value) { Services.prefs.setIntPref(LAST_MODIFIED_ON_PROCESS_COMMAND_PREF, value); }, get isFirstSync() { return !this.lastRecordUpload; }, // Always sync client data as it controls other sync behavior get enabled() { return true; }, get lastRecordUpload() { return Svc.Prefs.get(this.name + ".lastRecordUpload", 0); }, set lastRecordUpload(value) { Svc.Prefs.set(this.name + ".lastRecordUpload", Math.floor(value)); }, + get fxaDevices() { + return this._fxaDevices; + }, + get remoteClients() { // return all non-stale clients for external consumption. return Object.values(this._store._remoteClients).filter(v => !v.stale); }, remoteClient(id) { let client = this._store._remoteClients[id]; return client && !client.stale ? client : null; }, remoteClientExists(id) { return !!this.remoteClient(id); }, // Aggregate some stats on the composition of clients on this account get stats() { let stats = { hasMobile: this.localType == DEVICE_TYPE_MOBILE, names: [this.localName], numClients: 1, }; for (let id in this._store._remoteClients) { let {name, type, stale} = this._store._remoteClients[id]; if (!stale) { stats.hasMobile = stats.hasMobile || type == DEVICE_TYPE_MOBILE; stats.names.push(name); stats.numClients++; } } return stats; }, /** * Obtain information about device types. * * Returns a Map of device types to integer counts. Guaranteed to include * "desktop" (which will have at least 1 - this device) and "mobile" (which * may have zero) counts. It almost certainly will include only these 2. */ get deviceTypes() { let counts = new Map(); counts.set(this.localType, 1); // currently this must be DEVICE_TYPE_DESKTOP counts.set(DEVICE_TYPE_MOBILE, 0); for (let id in this._store._remoteClients) { let record = this._store._remoteClients[id]; if (record.stale) { continue; // pretend "stale" records don't exist. } let type = record.type; if (!counts.has(type)) { counts.set(type, 0); } counts.set(type, counts.get(type) + 1); } return counts; }, get brandName() { let brand = Services.strings.createBundle( "chrome://branding/locale/brand.properties"); return brand.GetStringFromName("brandShortName"); }, get localName() { return Utils.getDeviceName(); }, set localName(value) { Svc.Prefs.set("client.name", value); // Update the registration in the background. this.fxAccounts.updateDeviceRegistration().catch(error => { this._log.warn("failed to update fxa device registration", error); }); }, get localType() { return Utils.getDeviceType(); }, set localType(value) { Svc.Prefs.set("client.type", value); }, getClientName(id) { if (id == this.localID) { return this.localName; } let client = this._store._remoteClients[id]; return client ? client.name : ""; }, getClientFxaDeviceId(id) { if (this._store._remoteClients[id]) { return this._store._remoteClients[id].fxaDeviceId; } return null; }, getClientType(id) { const client = this._store._remoteClients[id]; if (client.type == DEVICE_TYPE_DESKTOP) { return "desktop"; } if (client.formfactor && client.formfactor.includes("tablet")) { return "tablet"; } return "phone"; }, async _readCommands() { let commands = await Utils.jsonLoad("commands", this); return commands || {}; }, /** * Low level function, do not use directly (use _addClientCommand instead). */ async _saveCommands(commands) { try { await Utils.jsonSave("commands", this, commands); } catch (error) { this._log.error("Failed to save JSON outgoing commands", error); } }, async _prepareCommandsForUpload() { try { await Utils.jsonMove("commands", "commands-syncing", this); } catch (e) { // Ignore errors } let commands = await Utils.jsonLoad("commands-syncing", this); return commands || {}; }, async _deleteUploadedCommands() { delete this._currentlySyncingCommands; try { await Utils.jsonRemove("commands-syncing", this); } catch (err) { this._log.error("Failed to delete syncing-commands file", err); } }, // Gets commands for a client we are yet to write to the server. Doesn't // include commands for that client which are already on the server. // We should rename this! async getClientCommands(clientId) { const allCommands = await this._readCommands(); return allCommands[clientId] || []; }, async removeLocalCommand(command) { // the implementation of this engine is such that adding a command to // the local client is how commands are deleted! ¯\_(ツ)_/¯ await this._addClientCommand(this.localID, command); }, async _addClientCommand(clientId, command) { this.addClientCommandQueue.enqueueCall(async () => { try { const localCommands = await this._readCommands(); const localClientCommands = localCommands[clientId] || []; const remoteClient = this._store._remoteClients[clientId]; let remoteClientCommands = []; if (remoteClient && remoteClient.commands) { remoteClientCommands = remoteClient.commands; } const clientCommands = localClientCommands.concat(remoteClientCommands); if (hasDupeCommand(clientCommands, command)) { return false; } localCommands[clientId] = localClientCommands.concat(command); await this._saveCommands(localCommands); return true; } catch (e) { // Failing to save a command should not "break the queue" of pending operations. this._log.error(e); return false; } }); return this.addClientCommandQueue.promiseCallsComplete(); }, async _removeClientCommands(clientId) { const allCommands = await this._readCommands(); delete allCommands[clientId]; await this._saveCommands(allCommands); }, async updateKnownStaleClients() { this._log.debug("Updating the known stale clients"); - await this._refreshKnownStaleClients(); + // _fetchFxADevices side effect updates this._knownStaleFxADeviceIds. + await this._fetchFxADevices(); let localFxADeviceId = await fxAccounts.getDeviceId(); // Process newer records first, so that if we hit a record with a device ID // we've seen before, we can mark it stale immediately. let clientList = Object.values(this._store._remoteClients).sort((a, b) => b.serverLastModified - a.serverLastModified); let seenDeviceIds = new Set([localFxADeviceId]); for (let client of clientList) { // Clients might not have an `fxaDeviceId` if they fail the FxA // registration process. if (!client.fxaDeviceId) { continue; } if (this._knownStaleFxADeviceIds.includes(client.fxaDeviceId)) { this._log.info(`Hiding stale client ${client.id} - in known stale clients list`); client.stale = true; } else if (seenDeviceIds.has(client.fxaDeviceId)) { this._log.info(`Hiding stale client ${client.id}` + ` - duplicate device id ${client.fxaDeviceId}`); client.stale = true; } else { seenDeviceIds.add(client.fxaDeviceId); } } }, - // We assume that clients not present in the FxA Device Manager list have been - // disconnected and so are stale - async _refreshKnownStaleClients() { + async _fetchFxADevices() { + const now = new Date().getTime(); + if ((this._lastFxADevicesFetch || 0) + TIME_BETWEEN_FXA_DEVICES_FETCH_MS >= now) { + return; + } + const remoteClients = Object.values(this.remoteClients); + try { + this._fxaDevices = await this.fxAccounts.getDeviceList(); + for (const device of this._fxaDevices) { + device.clientRecord = remoteClients.find(c => c.fxaDeviceId == device.id); + } + } catch (e) { + this._log.error("Could not retrieve the FxA device list", e); + this._fxaDevices = []; + } + this._lastFxADevicesFetch = now; + + // We assume that clients not present in the FxA Device Manager list have been + // disconnected and so are stale this._log.debug("Refreshing the known stale clients list"); let localClients = Object.values(this._store._remoteClients) .filter(client => client.fxaDeviceId) // iOS client records don't have fxaDeviceId .map(client => client.fxaDeviceId); - let fxaClients; - try { - let deviceList = await this.fxAccounts.getDeviceList(); - fxaClients = deviceList.map(device => device.id); - } catch (ex) { - this._log.error("Could not retrieve the FxA device list", ex); - this._knownStaleFxADeviceIds = []; - return; - } + const fxaClients = this._fxaDevices.map(device => device.id); this._knownStaleFxADeviceIds = Utils.arraySub(localClients, fxaClients); }, async _syncStartup() { // Reupload new client record periodically. if (Date.now() / 1000 - this.lastRecordUpload > CLIENTS_TTL_REFRESH) { await this._tracker.addChangedID(this.localID); } return SyncEngine.prototype._syncStartup.call(this); }, async _processIncoming() { // Fetch all records from the server. await this.resetLastSync(); this._incomingClients = {}; try { await SyncEngine.prototype._processIncoming.call(this); - // Refresh the known stale clients list at startup and when we receive - // "device connected/disconnected" push notifications. - if (!this._knownStaleFxADeviceIds) { - await this._refreshKnownStaleClients(); - } + // Update FxA Device list. + await this._fetchFxADevices(); // Since clients are synced unconditionally, any records in the local store // that don't exist on the server must be for disconnected clients. Remove // them, so that we don't upload records with commands for clients that will // never see them. We also do this to filter out stale clients from the // tabs collection, since showing their list of tabs is confusing. for (let id in this._store._remoteClients) { if (!this._incomingClients[id]) { this._log.info(`Removing local state for deleted client ${id}`); await this._removeRemoteClient(id); } } let localFxADeviceId = await fxAccounts.getDeviceId(); // Bug 1264498: Mobile clients don't remove themselves from the clients // collection when the user disconnects Sync, so we mark as stale clients // with the same name that haven't synced in over a week. // (Note we can't simply delete them, or we re-apply them next sync - see // bug 1287687) this._localClientLastModified = Math.round(this._incomingClients[this.localID]); delete this._incomingClients[this.localID]; let names = new Set([this.localName]); let seenDeviceIds = new Set([localFxADeviceId]); let idToLastModifiedList = Object.entries(this._incomingClients) .sort((a, b) => b[1] - a[1]); for (let [id, serverLastModified] of idToLastModifiedList) { let record = this._store._remoteClients[id]; // stash the server last-modified time on the record. record.serverLastModified = serverLastModified; if (record.fxaDeviceId && this._knownStaleFxADeviceIds.includes(record.fxaDeviceId)) { this._log.info(`Hiding stale client ${id} - in known stale clients list`); record.stale = true; } if (!names.has(record.name)) { if (record.fxaDeviceId) { seenDeviceIds.add(record.fxaDeviceId); } names.add(record.name); continue; } let remoteAge = Resource.serverTime - this._incomingClients[id]; if (remoteAge > STALE_CLIENT_REMOTE_AGE) { this._log.info(`Hiding stale client ${id} with age ${remoteAge}`); record.stale = true; continue; } if (record.fxaDeviceId && seenDeviceIds.has(record.fxaDeviceId)) { this._log.info(`Hiding stale client ${record.id}` + ` - duplicate device id ${record.fxaDeviceId}`); record.stale = true; } else if (record.fxaDeviceId) { seenDeviceIds.add(record.fxaDeviceId); } } } finally { this._incomingClients = null; } }, async _uploadOutgoing() { this._currentlySyncingCommands = await this._prepareCommandsForUpload(); const clientWithPendingCommands = Object.keys(this._currentlySyncingCommands); for (let clientId of clientWithPendingCommands) { if (this._store._remoteClients[clientId] || this.localID == clientId) { this._modified.set(clientId, 0); } } let updatedIDs = this._modified.ids(); await SyncEngine.prototype._uploadOutgoing.call(this); // Record the response time as the server time for each item we uploaded. let lastSync = await this.getLastSync(); for (let id of updatedIDs) { if (id == this.localID) { this.lastRecordUpload = lastSync; } else { this._store._remoteClients[id].serverLastModified = lastSync; } } }, async _onRecordsWritten(succeeded, failed) { // Reconcile the status of the local records with what we just wrote on the // server for (let id of succeeded) { const commandChanges = this._currentlySyncingCommands[id]; if (id == this.localID) { if (this.isFirstSync) { this._log.info("Uploaded our client record for the first time, notifying other clients."); this._notifyClientRecordUploaded(); } if (this.localCommands) { this.localCommands = this.localCommands.filter(command => !hasDupeCommand(commandChanges, command)); } } else { const clientRecord = this._store._remoteClients[id]; if (!commandChanges || !clientRecord) { // should be impossible, else we wouldn't have been writing it. this._log.warn("No command/No record changes for a client we uploaded"); continue; } // fixup the client record, so our copy of _remoteClients matches what we uploaded. this._store._remoteClients[id] = await this._store.createRecord(id); // we could do better and pass the reference to the record we just uploaded, // but this will do for now } } // Re-add failed commands for (let id of failed) { const commandChanges = this._currentlySyncingCommands[id]; if (!commandChanges) { continue; } await this._addClientCommand(id, commandChanges); } await this._deleteUploadedCommands(); // Notify other devices that their own client collection changed const idsToNotify = succeeded.reduce((acc, id) => { if (id == this.localID) { return acc; } const fxaDeviceId = this.getClientFxaDeviceId(id); return fxaDeviceId ? acc.concat(fxaDeviceId) : acc; }, []); if (idsToNotify.length > 0) { this._notifyOtherClientsModified(idsToNotify); } }, _notifyOtherClientsModified(ids) { // We are not waiting on this promise on purpose. this._notifyCollectionChanged(ids, NOTIFY_TAB_SENT_TTL_SECS, COLLECTION_MODIFIED_REASON_SENDTAB); }, _notifyClientRecordUploaded() { // We are not waiting on this promise on purpose. this._notifyCollectionChanged(null, 0, COLLECTION_MODIFIED_REASON_FIRSTSYNC); }, /** * @param {?string[]} ids FxA Client IDs to notify. null means everyone else. * @param {number} ttl TTL of the push notification. * @param {string} reason Reason for sending this push notification. */ async _notifyCollectionChanged(ids, ttl, reason) { const message = { version: 1, command: "sync:collection_changed", data: { collections: ["clients"], reason, }, }; let excludedIds = null; if (!ids) { const localFxADeviceId = await fxAccounts.getDeviceId(); excludedIds = [localFxADeviceId]; } try { await this.fxAccounts.notifyDevices(ids, excludedIds, message, ttl); } catch (e) { this._log.error("Could not notify of changes in the collection", e); } }, async _syncFinish() { // Record histograms for our device types, and also write them to a pref // so non-histogram telemetry (eg, UITelemetry) and the sync scheduler // has easy access to them, and so they are accurate even before we've // successfully synced the first time after startup. let deviceTypeCounts = this.deviceTypes; for (let [deviceType, count] of deviceTypeCounts) { let hid; let prefName = this.name + ".devices."; switch (deviceType) { case DEVICE_TYPE_DESKTOP: hid = "WEAVE_DEVICE_COUNT_DESKTOP"; prefName += "desktop"; break; case DEVICE_TYPE_MOBILE: hid = "WEAVE_DEVICE_COUNT_MOBILE"; prefName += "mobile"; break; default: this._log.warn(`Unexpected deviceType "${deviceType}" recording device telemetry.`); continue; } Services.telemetry.getHistogramById(hid).add(count); // Optimization: only write the pref if it changed since our last sync. if (this._lastDeviceCounts == null || this._lastDeviceCounts.get(prefName) != count) { Svc.Prefs.set(prefName, count); } } this._lastDeviceCounts = deviceTypeCounts; return SyncEngine.prototype._syncFinish.call(this); }, async _reconcile(item) { // Every incoming record is reconciled, so we use this to track the // contents of the collection on the server. this._incomingClients[item.id] = item.modified; if (!(await this._store.itemExists(item.id))) { return true; } // Clients are synced unconditionally, so we'll always have new records. // Unfortunately, this will cause the scheduler to use the immediate sync // interval for the multi-device case, instead of the active interval. We // work around this by updating the record during reconciliation, and // returning false to indicate that the record doesn't need to be applied // later. await this._store.update(item); return false; }, // Treat reset the same as wiping for locally cached clients async _resetClient() { await this._wipeClient(); }, async _wipeClient() { await SyncEngine.prototype._resetClient.call(this); this._knownStaleFxADeviceIds = null; delete this.localCommands; await this._store.wipe(); try { await Utils.jsonRemove("commands", this); } catch (err) { this._log.warn("Could not delete commands.json", err); } try { await Utils.jsonRemove("commands-syncing", this); } catch (err) { this._log.warn("Could not delete commands-syncing.json", err); } }, async removeClientData() { let res = this.service.resource(this.engineURL + "/" + this.localID); await res.delete(); }, // Override the default behavior to delete bad records from the server. async handleHMACMismatch(item, mayRetry) { this._log.debug("Handling HMAC mismatch for " + item.id); let base = await SyncEngine.prototype.handleHMACMismatch.call(this, item, mayRetry); if (base != SyncEngine.kRecoveryStrategy.error) { return base; } // It's a bad client record. Save it to be deleted at the end of the sync. this._log.debug("Bad client record detected. Scheduling for deletion."); await this._deleteId(item.id); // Neither try again nor error; we're going to delete it. return SyncEngine.kRecoveryStrategy.ignore; }, /** * A hash of valid commands that the client knows about. The key is a command * and the value is a hash containing information about the command such as * number of arguments, description, and importance (lower importance numbers * indicate higher importance. */ _commands: { resetAll: { args: 0, importance: 0, desc: "Clear temporary local data for all engines" }, resetEngine: { args: 1, importance: 0, desc: "Clear temporary local data for engine" }, wipeAll: { args: 0, importance: 0, desc: "Delete all client data for all engines" }, wipeEngine: { args: 1, importance: 0, desc: "Delete all client data for engine" }, logout: { args: 0, importance: 0, desc: "Log out client" }, displayURI: { args: 3, importance: 1, desc: "Instruct a client to display a URI" }, repairRequest: { args: 1, importance: 2, desc: "Instruct a client to initiate a repair" }, repairResponse: { args: 1, importance: 2, desc: "Instruct a client a repair request is complete" }, }, /** * Sends a command+args pair to a specific client. * * @param command Command string * @param args Array of arguments/data for command * @param clientId Client to send command to */ async _sendCommandToClient(command, args, clientId, telemetryExtra) { this._log.trace("Sending " + command + " to " + clientId); let client = this._store._remoteClients[clientId]; if (!client) { throw new Error("Unknown remote client ID: '" + clientId + "'."); } if (client.stale) { throw new Error("Stale remote client ID: '" + clientId + "'."); } let action = { command, args, // We send the flowID to the other client so *it* can report it in its // telemetry - we record it in ours below. flowID: telemetryExtra.flowID, }; if ((await this._addClientCommand(clientId, action))) { this._log.trace(`Client ${clientId} got a new action`, [command, args]); await this._tracker.addChangedID(clientId); try { telemetryExtra.deviceID = this.service.identity.hashedDeviceID(clientId); } catch (_) {} this.service.recordTelemetryEvent("sendcommand", command, undefined, telemetryExtra); } else { this._log.trace(`Client ${clientId} got a duplicate action`, [command, args]); } }, /** * Check if the local client has any remote commands and perform them. * * @return false to abort sync */ async processIncomingCommands() { return this._notify("clients:process-commands", "", async function() { if (!this.localCommands || (this._lastModifiedOnProcessCommands == this._localClientLastModified && !this.ignoreLastModifiedOnProcessCommands)) { return true; } this._lastModifiedOnProcessCommands = this._localClientLastModified; const clearedCommands = await this._readCommands()[this.localID]; const commands = this.localCommands.filter(command => !hasDupeCommand(clearedCommands, command)); let didRemoveCommand = false; let URIsToDisplay = []; // Process each command in order. for (let rawCommand of commands) { let shouldRemoveCommand = true; // most commands are auto-removed. let {command, args, flowID} = rawCommand; this._log.debug("Processing command " + command, args); this.service.recordTelemetryEvent("processcommand", command, undefined, { flowID }); let engines = [args[0]]; switch (command) { case "resetAll": engines = null; // Fallthrough case "resetEngine": await this.service.resetClient(engines); break; case "wipeAll": engines = null; // Fallthrough case "wipeEngine": await this.service.wipeClient(engines); break; case "logout": this.service.logout(); return false; case "displayURI": let [uri, clientId, title] = args; URIsToDisplay.push({ uri, clientId, title }); break; case "repairResponse": { // When we send a repair request to another device that understands // it, that device will send a response indicating what it did. let response = args[0]; let requestor = getRepairRequestor(response.collection); if (!requestor) { this._log.warn("repairResponse for unknown collection", response); break; } if (!(await requestor.continueRepairs(response))) { this._log.warn("repairResponse couldn't continue the repair", response); } break; } case "repairRequest": { // Another device has sent us a request to make some repair. let request = args[0]; let responder = getRepairResponder(request.collection); if (!responder) { this._log.warn("repairRequest for unknown collection", request); break; } try { if ((await responder.repair(request, rawCommand))) { // We've started a repair - once that collection has synced it // will write a "response" command and arrange for this repair // request to be removed from the local command list - if we // removed it now we might fail to write a response in cases of // premature shutdown etc. shouldRemoveCommand = false; } } catch (ex) { if (Async.isShutdownException(ex)) { // Let's assume this error was caused by the shutdown, so let // it try again next time. throw ex; } // otherwise there are no second chances - the command is removed // and will not be tried again. // (Note that this shouldn't be hit in the normal case - it's // expected the responder will handle all reasonable failures and // write a response indicating that it couldn't do what was asked.) this._log.error("Failed to handle a repair request", ex); } break; } default: this._log.warn("Received an unknown command: " + command); break; } // Add the command to the "cleared" commands list if (shouldRemoveCommand) { await this.removeLocalCommand(rawCommand); didRemoveCommand = true; } } if (didRemoveCommand) { await this._tracker.addChangedID(this.localID); } if (URIsToDisplay.length) { this._handleDisplayURIs(URIsToDisplay); } return true; })(); }, /** * Validates and sends a command to a client or all clients. * * Calling this does not actually sync the command data to the server. If the * client already has the command/args pair, it won't receive a duplicate * command. * This method is async since it writes the command to a file. * * @param command * Command to invoke on remote clients * @param args * Array of arguments to give to the command * @param clientId * Client ID to send command to. If undefined, send to all remote * clients. * @param flowID * A unique identifier used to track success for this operation across * devices. */ async sendCommand(command, args, clientId = null, telemetryExtra = {}) { let commandData = this._commands[command]; // Don't send commands that we don't know about. if (!commandData) { this._log.error("Unknown command to send: " + command); return; } else if (!args || args.length != commandData.args) { // Don't send a command with the wrong number of arguments. this._log.error("Expected " + commandData.args + " args for '" + command + "', but got " + args); return; } // We allocate a "flowID" here, so it is used for each client. telemetryExtra = Object.assign({}, telemetryExtra); // don't clobber the caller's object if (!telemetryExtra.flowID) { telemetryExtra.flowID = Utils.makeGUID(); } if (clientId) { await this._sendCommandToClient(command, args, clientId, telemetryExtra); } else { for (let [id, record] of Object.entries(this._store._remoteClients)) { if (!record.stale) { await this._sendCommandToClient(command, args, id, telemetryExtra); } } } }, /** * Send a URI to another client for display. * * A side effect is the score is increased dramatically to incur an * immediate sync. * * If an unknown client ID is specified, sendCommand() will throw an * Error object. * * @param uri * URI (as a string) to send and display on the remote client * @param clientId * ID of client to send the command to. If not defined, will be sent * to all remote clients. * @param title * Title of the page being sent. */ async sendURIToClientForDisplay(uri, clientId, title) { this._log.trace("Sending URI to client: " + uri + " -> " + clientId + " (" + title + ")"); await this.sendCommand("displayURI", [uri, this.localID, title], clientId); this._tracker.score += SCORE_INCREMENT_XLARGE; }, /** * Handle a bunch of received 'displayURI' commands. * * Interested parties should observe the "weave:engine:clients:display-uris" * topic. The callback will receive an array as the subject parameter * containing objects with the following keys: * * uri URI (string) that is requested for display. * sender.id ID of client that sent the command. * sender.name Name of client that sent the command. * title Title of page that loaded URI (likely) corresponds to. * * The 'data' parameter to the callback will not be defined. * * @param uris * An array containing URI objects to display * @param uris[].uri * String URI that was received * @param uris[].clientId * ID of client that sent URI * @param uris[].title * String title of page that URI corresponds to. Older clients may not * send this. */ _handleDisplayURIs(uris) { uris.forEach(uri => { uri.sender = { id: uri.clientId, name: this.getClientName(uri.clientId), }; }); Svc.Obs.notify("weave:engine:clients:display-uris", uris); }, async _removeRemoteClient(id) { delete this._store._remoteClients[id]; await this._tracker.removeChangedID(id); await this._removeClientCommands(id); this._modified.delete(id); }, }; function ClientStore(name, engine) { Store.call(this, name, engine); } ClientStore.prototype = { __proto__: Store.prototype, _remoteClients: {}, async create(record) { await this.update(record); }, async update(record) { if (record.id == this.engine.localID) { // Only grab commands from the server; local name/type always wins this.engine.localCommands = record.commands; } else { this._remoteClients[record.id] = record.cleartext; } }, async createRecord(id, collection) { let record = new ClientsRec(collection, id); const commandsChanges = this.engine._currentlySyncingCommands ? this.engine._currentlySyncingCommands[id] : []; // Package the individual components into a record for the local client if (id == this.engine.localID) { try { record.fxaDeviceId = await this.engine.fxAccounts.getDeviceId(); } catch (error) { this._log.warn("failed to get fxa device id", error); } record.name = this.engine.localName; record.type = this.engine.localType; record.version = Services.appinfo.version; record.protocols = SUPPORTED_PROTOCOL_VERSIONS; // Substract the commands we recorded that we've already executed if (commandsChanges && commandsChanges.length && this.engine.localCommands && this.engine.localCommands.length) { record.commands = this.engine.localCommands.filter(command => !hasDupeCommand(commandsChanges, command)); } // Optional fields. record.os = Services.appinfo.OS; // "Darwin" record.appPackage = Services.appinfo.ID; record.application = this.engine.brandName; // "Nightly" // We can't compute these yet. // record.device = ""; // Bug 1100723 // record.formfactor = ""; // Bug 1100722 } else { record.cleartext = Object.assign({}, this._remoteClients[id]); delete record.cleartext.serverLastModified; // serverLastModified is a local only attribute. // Add the commands we have to send if (commandsChanges && commandsChanges.length) { const recordCommands = record.cleartext.commands || []; const newCommands = commandsChanges.filter(command => !hasDupeCommand(recordCommands, command)); record.cleartext.commands = recordCommands.concat(newCommands); } if (record.cleartext.stale) { // It's almost certainly a logic error for us to upload a record we // consider stale, so make log noise, but still remove the flag. this._log.error(`Preparing to upload record ${id} that we consider stale`); delete record.cleartext.stale; } } if (record.commands) { const maxPayloadSize = this.engine.service.getMemcacheMaxRecordPayloadSize(); let origOrder = new Map(record.commands.map((c, i) => [c, i])); // we sort first by priority, and second by age (indicated by order in the // original list) let commands = record.commands.slice().sort((a, b) => { let infoA = this.engine._commands[a.command]; let infoB = this.engine._commands[b.command]; // Treat unknown command types as highest priority, to allow us to add // high priority commands in the future without worrying about clients // removing them on each-other unnecessarially. let importA = infoA ? infoA.importance : 0; let importB = infoB ? infoB.importance : 0; // Higher importantance numbers indicate that we care less, so they // go to the end of the list where they'll be popped off. let importDelta = importA - importB; if (importDelta != 0) { return importDelta; } let origIdxA = origOrder.get(a); let origIdxB = origOrder.get(b); // Within equivalent priorities, we put older entries near the end // of the list, so that they are removed first. return origIdxB - origIdxA; }); let truncatedCommands = Utils.tryFitItems(commands, maxPayloadSize); if (truncatedCommands.length != record.commands.length) { this._log.warn(`Removing commands from client ${id} (from ${record.commands.length} to ${truncatedCommands.length})`); // Restore original order. record.commands = truncatedCommands.sort((a, b) => origOrder.get(a) - origOrder.get(b)); } } return record; }, async itemExists(id) { return id in (await this.getAllIDs()); }, async getAllIDs() { let ids = {}; ids[this.engine.localID] = true; for (let id in this._remoteClients) { ids[id] = true; } return ids; }, async wipe() { this._remoteClients = {}; }, }; function ClientsTracker(name, engine) { Tracker.call(this, name, engine); } ClientsTracker.prototype = { __proto__: Tracker.prototype, _enabled: false, onStart() { Svc.Obs.add("fxaccounts:new_device_id", this.asyncObserver); Svc.Prefs.observe("client.name", this.asyncObserver); }, onStop() { Svc.Prefs.ignore("client.name", this.asyncObserver); Svc.Obs.remove("fxaccounts:new_device_id", this.asyncObserver); }, async observe(subject, topic, data) { switch (topic) { case "nsPref:changed": this._log.debug("client.name preference changed"); // Fallthrough intended. case "fxaccounts:new_device_id": await this.addChangedID(this.engine.localID); this.score += SINGLE_USER_THRESHOLD + 1; // ALWAYS SYNC NOW. break; } }, }; diff --git a/services/sync/tests/unit/test_clients_engine.js b/services/sync/tests/unit/test_clients_engine.js --- a/services/sync/tests/unit/test_clients_engine.js +++ b/services/sync/tests/unit/test_clients_engine.js @@ -1,1954 +1,1919 @@ /* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ ChromeUtils.import("resource://services-sync/constants.js"); ChromeUtils.import("resource://services-sync/engines.js"); ChromeUtils.import("resource://services-sync/engines/clients.js"); ChromeUtils.import("resource://services-sync/record.js"); ChromeUtils.import("resource://services-sync/service.js"); ChromeUtils.import("resource://services-sync/util.js"); const MORE_THAN_CLIENTS_TTL_REFRESH = 691200; // 8 days const LESS_THAN_CLIENTS_TTL_REFRESH = 86400; // 1 day let engine; /** * Unpack the record with this ID, and verify that it has the same version that * we should be putting into records. */ async function check_record_version(user, id) { let payload = user.collection("clients").wbo(id).data; let rec = new CryptoWrapper(); rec.id = id; rec.collection = "clients"; rec.ciphertext = payload.ciphertext; rec.hmac = payload.hmac; rec.IV = payload.IV; let cleartext = await rec.decrypt(Service.collectionKeys.keyForCollection("clients")); _("Payload is " + JSON.stringify(cleartext)); equal(Services.appinfo.version, cleartext.version); equal(1, cleartext.protocols.length); equal("1.5", cleartext.protocols[0]); } // compare 2 different command arrays, taking into account that a flowID // attribute must exist, be unique in the commands, but isn't specified in // "expected" as the value isn't known. function compareCommands(actual, expected, description) { let tweakedActual = JSON.parse(JSON.stringify(actual)); tweakedActual.map(elt => delete elt.flowID); deepEqual(tweakedActual, expected, description); // each item must have a unique flowID. let allIDs = new Set(actual.map(elt => elt.flowID).filter(fid => !!fid)); equal(allIDs.size, actual.length, "all items have unique IDs"); } async function syncClientsEngine(server) { + engine._lastFxADevicesFetch = 0; engine.lastModified = server.getCollection("foo", "clients").timestamp; await engine._sync(); } add_task(async function setup() { engine = Service.clientsEngine; }); async function cleanup() { Svc.Prefs.resetBranch(""); await engine._tracker.clearChangedIDs(); await engine._resetClient(); // un-cleanup the logs (the resetBranch will have reset their levels), since // not all the tests use SyncTestingInfrastructure, and it's cheap. syncTestLogging(); // We don't finalize storage at cleanup, since we use the same clients engine // instance across all tests. } add_task(async function test_bad_hmac() { _("Ensure that Clients engine deletes corrupt records."); let deletedCollections = []; let deletedItems = []; let callback = { __proto__: SyncServerCallback, onItemDeleted(username, coll, wboID) { deletedItems.push(coll + "/" + wboID); }, onCollectionDeleted(username, coll) { deletedCollections.push(coll); }, }; let server = await serverForFoo(engine, callback); let user = server.user("foo"); function check_clients_count(expectedCount) { let coll = user.collection("clients"); // Treat a non-existent collection as empty. equal(expectedCount, coll ? coll.count() : 0); } function check_client_deleted(id) { let coll = user.collection("clients"); let wbo = coll.wbo(id); return !wbo || !wbo.payload; } async function uploadNewKeys() { await generateNewKeys(Service.collectionKeys); let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); await serverKeys.encrypt(Service.identity.syncKeyBundle); ok((await serverKeys.upload(Service.resource(Service.cryptoKeysURL))).success); } try { await configureIdentity({username: "foo"}, server); await Service.login(); await generateNewKeys(Service.collectionKeys); _("First sync, client record is uploaded"); equal(engine.lastRecordUpload, 0); ok(engine.isFirstSync); check_clients_count(0); await syncClientsEngine(server); check_clients_count(1); ok(engine.lastRecordUpload > 0); ok(!engine.isFirstSync); // Our uploaded record has a version. await check_record_version(user, engine.localID); // Initial setup can wipe the server, so clean up. deletedCollections = []; deletedItems = []; _("Change our keys and our client ID, reupload keys."); let oldLocalID = engine.localID; // Preserve to test for deletion! engine.localID = Utils.makeGUID(); await engine.resetClient(); await generateNewKeys(Service.collectionKeys); let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); await serverKeys.encrypt(Service.identity.syncKeyBundle); ok((await serverKeys.upload(Service.resource(Service.cryptoKeysURL))).success); _("Sync."); await syncClientsEngine(server); _("Old record " + oldLocalID + " was deleted, new one uploaded."); check_clients_count(1); check_client_deleted(oldLocalID); _("Now change our keys but don't upload them. " + "That means we get an HMAC error but redownload keys."); Service.lastHMACEvent = 0; engine.localID = Utils.makeGUID(); await engine.resetClient(); await generateNewKeys(Service.collectionKeys); deletedCollections = []; deletedItems = []; check_clients_count(1); await syncClientsEngine(server); _("Old record was not deleted, new one uploaded."); equal(deletedCollections.length, 0); equal(deletedItems.length, 0); check_clients_count(2); _("Now try the scenario where our keys are wrong *and* there's a bad record."); // Clean up and start fresh. user.collection("clients")._wbos = {}; Service.lastHMACEvent = 0; engine.localID = Utils.makeGUID(); await engine.resetClient(); deletedCollections = []; deletedItems = []; check_clients_count(0); await uploadNewKeys(); // Sync once to upload a record. await syncClientsEngine(server); check_clients_count(1); // Generate and upload new keys, so the old client record is wrong. await uploadNewKeys(); // Create a new client record and new keys. Now our keys are wrong, as well // as the object on the server. We'll download the new keys and also delete // the bad client record. oldLocalID = engine.localID; // Preserve to test for deletion! engine.localID = Utils.makeGUID(); await engine.resetClient(); await generateNewKeys(Service.collectionKeys); let oldKey = Service.collectionKeys.keyForCollection(); equal(deletedCollections.length, 0); equal(deletedItems.length, 0); await syncClientsEngine(server); equal(deletedItems.length, 1); check_client_deleted(oldLocalID); check_clients_count(1); let newKey = Service.collectionKeys.keyForCollection(); ok(!oldKey.equals(newKey)); } finally { await cleanup(); await promiseStopServer(server); } }); add_task(async function test_properties() { _("Test lastRecordUpload property"); try { equal(Svc.Prefs.get("clients.lastRecordUpload"), undefined); equal(engine.lastRecordUpload, 0); let now = Date.now(); engine.lastRecordUpload = now / 1000; equal(engine.lastRecordUpload, Math.floor(now / 1000)); } finally { await cleanup(); } }); add_task(async function test_full_sync() { _("Ensure that Clients engine fetches all records for each sync."); let now = Date.now() / 1000; let server = await serverForFoo(engine); let user = server.user("foo"); await SyncTestingInfrastructure(server); await generateNewKeys(Service.collectionKeys); let activeID = Utils.makeGUID(); user.collection("clients").insertRecord({ id: activeID, name: "Active client", type: "desktop", commands: [], version: "48", protocols: ["1.5"], }, now - 10); let deletedID = Utils.makeGUID(); user.collection("clients").insertRecord({ id: deletedID, name: "Client to delete", type: "desktop", commands: [], version: "48", protocols: ["1.5"], }, now - 10); try { let store = engine._store; _("First sync. 2 records downloaded; our record uploaded."); strictEqual(engine.lastRecordUpload, 0); ok(engine.isFirstSync); await syncClientsEngine(server); ok(engine.lastRecordUpload > 0); ok(!engine.isFirstSync); deepEqual(user.collection("clients").keys().sort(), [activeID, deletedID, engine.localID].sort(), "Our record should be uploaded on first sync"); let ids = await store.getAllIDs(); deepEqual(Object.keys(ids).sort(), [activeID, deletedID, engine.localID].sort(), "Other clients should be downloaded on first sync"); _("Delete a record, then sync again"); let collection = server.getCollection("foo", "clients"); collection.remove(deletedID); // Simulate a timestamp update in info/collections. await syncClientsEngine(server); _("Record should be updated"); ids = await store.getAllIDs(); deepEqual(Object.keys(ids).sort(), [activeID, engine.localID].sort(), "Deleted client should be removed on next sync"); } finally { await cleanup(); try { server.deleteCollections("foo"); } finally { await promiseStopServer(server); } } }); add_task(async function test_sync() { _("Ensure that Clients engine uploads a new client record once a week."); let server = await serverForFoo(engine); let user = server.user("foo"); await SyncTestingInfrastructure(server); await generateNewKeys(Service.collectionKeys); function clientWBO() { return user.collection("clients").wbo(engine.localID); } try { _("First sync. Client record is uploaded."); equal(clientWBO(), undefined); equal(engine.lastRecordUpload, 0); ok(engine.isFirstSync); await syncClientsEngine(server); ok(!!clientWBO().payload); ok(engine.lastRecordUpload > 0); ok(!engine.isFirstSync); _("Let's time travel more than a week back, new record should've been uploaded."); engine.lastRecordUpload -= MORE_THAN_CLIENTS_TTL_REFRESH; let lastweek = engine.lastRecordUpload; clientWBO().payload = undefined; await syncClientsEngine(server); ok(!!clientWBO().payload); ok(engine.lastRecordUpload > lastweek); ok(!engine.isFirstSync); _("Remove client record."); await engine.removeClientData(); equal(clientWBO().payload, undefined); _("Time travel one day back, no record uploaded."); engine.lastRecordUpload -= LESS_THAN_CLIENTS_TTL_REFRESH; let yesterday = engine.lastRecordUpload; await syncClientsEngine(server); equal(clientWBO().payload, undefined); equal(engine.lastRecordUpload, yesterday); ok(!engine.isFirstSync); } finally { await cleanup(); await promiseStopServer(server); } }); add_task(async function test_client_name_change() { _("Ensure client name change incurs a client record update."); let tracker = engine._tracker; engine.localID; // Needed to increase the tracker changedIDs count. let initialName = engine.localName; tracker.start(); _("initial name: " + initialName); // Tracker already has data, so clear it. await tracker.clearChangedIDs(); let initialScore = tracker.score; let changedIDs = await tracker.getChangedIDs(); equal(Object.keys(changedIDs).length, 0); Svc.Prefs.set("client.name", "new name"); await tracker.asyncObserver.promiseObserversComplete(); _("new name: " + engine.localName); notEqual(initialName, engine.localName); changedIDs = await tracker.getChangedIDs(); equal(Object.keys(changedIDs).length, 1); ok(engine.localID in changedIDs); ok(tracker.score > initialScore); ok(tracker.score >= SCORE_INCREMENT_XLARGE); await tracker.stop(); await cleanup(); }); add_task(async function test_fxa_device_id_change() { _("Ensure an FxA device ID change incurs a client record update."); let tracker = engine._tracker; engine.localID; // Needed to increase the tracker changedIDs count. tracker.start(); // Tracker already has data, so clear it. await tracker.clearChangedIDs(); let initialScore = tracker.score; let changedIDs = await tracker.getChangedIDs(); equal(Object.keys(changedIDs).length, 0); Services.obs.notifyObservers(null, "fxaccounts:new_device_id"); await tracker.asyncObserver.promiseObserversComplete(); changedIDs = await tracker.getChangedIDs(); equal(Object.keys(changedIDs).length, 1); ok(engine.localID in changedIDs); ok(tracker.score > initialScore); ok(tracker.score >= SINGLE_USER_THRESHOLD); await tracker.stop(); await cleanup(); }); add_task(async function test_last_modified() { _("Ensure that remote records have a sane serverLastModified attribute."); let now = Date.now() / 1000; let server = await serverForFoo(engine); let user = server.user("foo"); await SyncTestingInfrastructure(server); await generateNewKeys(Service.collectionKeys); let activeID = Utils.makeGUID(); user.collection("clients").insertRecord({ id: activeID, name: "Active client", type: "desktop", commands: [], version: "48", protocols: ["1.5"], }, now - 10); try { let collection = user.collection("clients"); _("Sync to download the record"); await syncClientsEngine(server); equal(engine._store._remoteClients[activeID].serverLastModified, now - 10, "last modified in the local record is correctly the server last-modified"); _("Modify the record and re-upload it"); // set a new name to make sure we really did upload. engine._store._remoteClients[activeID].name = "New name"; engine._modified.set(activeID, 0); // The sync above also did a POST, so adjust our lastModified. engine.lastModified = server.getCollection("foo", "clients").timestamp; await engine._uploadOutgoing(); _("Local record should have updated timestamp"); ok(engine._store._remoteClients[activeID].serverLastModified >= now); _("Record on the server should have new name but not serverLastModified"); let payload = collection.cleartext(activeID); equal(payload.name, "New name"); equal(payload.serverLastModified, undefined); } finally { await cleanup(); server.deleteCollections("foo"); await promiseStopServer(server); } }); add_task(async function test_send_command() { _("Verifies _sendCommandToClient puts commands in the outbound queue."); let store = engine._store; let tracker = engine._tracker; let remoteId = Utils.makeGUID(); let rec = new ClientsRec("clients", remoteId); await store.create(rec); await store.createRecord(remoteId, "clients"); let action = "testCommand"; let args = ["foo", "bar"]; let extra = { flowID: "flowy" }; await engine._sendCommandToClient(action, args, remoteId, extra); let newRecord = store._remoteClients[remoteId]; let clientCommands = (await engine._readCommands())[remoteId]; notEqual(newRecord, undefined); equal(clientCommands.length, 1); let command = clientCommands[0]; equal(command.command, action); equal(command.args.length, 2); deepEqual(command.args, args); ok(command.flowID); const changes = await tracker.getChangedIDs(); notEqual(changes[remoteId], undefined); await cleanup(); }); // The browser UI might call _addClientCommand indirectly without awaiting on the returned promise. // We need to make sure this doesn't result on commands not being saved. add_task(async function test_add_client_command_race() { let promises = []; for (let i = 0; i < 100; i++) { promises.push(engine._addClientCommand(`client-${i}`, { command: "cmd", args: []})); } await Promise.all(promises); let localCommands = await engine._readCommands(); for (let i = 0; i < 100; i++) { equal(localCommands[`client-${i}`].length, 1); } }); add_task(async function test_command_validation() { _("Verifies that command validation works properly."); let store = engine._store; let testCommands = [ ["resetAll", [], true ], ["resetAll", ["foo"], false], ["resetEngine", ["tabs"], true ], ["resetEngine", [], false], ["wipeAll", [], true ], ["wipeAll", ["foo"], false], ["wipeEngine", ["tabs"], true ], ["wipeEngine", [], false], ["logout", [], true ], ["logout", ["foo"], false], ["__UNKNOWN__", [], false], ]; for (let [action, args, expectedResult] of testCommands) { let remoteId = Utils.makeGUID(); let rec = new ClientsRec("clients", remoteId); await store.create(rec); await store.createRecord(remoteId, "clients"); await engine.sendCommand(action, args, remoteId); let newRecord = store._remoteClients[remoteId]; notEqual(newRecord, undefined); let clientCommands = (await engine._readCommands())[remoteId]; if (expectedResult) { _("Ensuring command is sent: " + action); equal(clientCommands.length, 1); let command = clientCommands[0]; equal(command.command, action); deepEqual(command.args, args); notEqual(engine._tracker, undefined); const changes = await engine._tracker.getChangedIDs(); notEqual(changes[remoteId], undefined); } else { _("Ensuring command is scrubbed: " + action); equal(clientCommands, undefined); if (store._tracker) { equal(engine._tracker[remoteId], undefined); } } } await cleanup(); }); add_task(async function test_command_duplication() { _("Ensures duplicate commands are detected and not added"); let store = engine._store; let remoteId = Utils.makeGUID(); let rec = new ClientsRec("clients", remoteId); await store.create(rec); await store.createRecord(remoteId, "clients"); let action = "resetAll"; let args = []; await engine.sendCommand(action, args, remoteId); await engine.sendCommand(action, args, remoteId); let clientCommands = (await engine._readCommands())[remoteId]; equal(clientCommands.length, 1); _("Check variant args length"); await engine._saveCommands({}); action = "resetEngine"; await engine.sendCommand(action, [{ x: "foo" }], remoteId); await engine.sendCommand(action, [{ x: "bar" }], remoteId); _("Make sure we spot a real dupe argument."); await engine.sendCommand(action, [{ x: "bar" }], remoteId); clientCommands = (await engine._readCommands())[remoteId]; equal(clientCommands.length, 2); await cleanup(); }); add_task(async function test_command_invalid_client() { _("Ensures invalid client IDs are caught"); let id = Utils.makeGUID(); let error; try { await engine.sendCommand("wipeAll", [], id); } catch (ex) { error = ex; } equal(error.message.indexOf("Unknown remote client ID: "), 0); await cleanup(); }); add_task(async function test_process_incoming_commands() { _("Ensures local commands are executed"); engine.localCommands = [{ command: "logout", args: [] }]; let ev = "weave:service:logout:finish"; let logoutPromise = new Promise(resolve => { var handler = function() { Svc.Obs.remove(ev, handler); resolve(); }; Svc.Obs.add(ev, handler); }); // logout command causes processIncomingCommands to return explicit false. ok(!(await engine.processIncomingCommands())); await logoutPromise; await cleanup(); }); add_task(async function test_filter_duplicate_names() { _("Ensure that we exclude clients with identical names that haven't synced in a week."); let now = Date.now() / 1000; let server = await serverForFoo(engine); let user = server.user("foo"); await SyncTestingInfrastructure(server); await generateNewKeys(Service.collectionKeys); // Synced recently. let recentID = Utils.makeGUID(); user.collection("clients").insertRecord({ id: recentID, name: "My Phone", type: "mobile", commands: [], version: "48", protocols: ["1.5"], }, now - 10); // Dupe of our client, synced more than 1 week ago. let dupeID = Utils.makeGUID(); user.collection("clients").insertRecord({ id: dupeID, name: engine.localName, type: "desktop", commands: [], version: "48", protocols: ["1.5"], }, now - 604820); // Synced more than 1 week ago, but not a dupe. let oldID = Utils.makeGUID(); user.collection("clients").insertRecord({ id: oldID, name: "My old desktop", type: "desktop", commands: [], version: "48", protocols: ["1.5"], }, now - 604820); try { let store = engine._store; _("First sync"); strictEqual(engine.lastRecordUpload, 0); ok(engine.isFirstSync); await syncClientsEngine(server); ok(engine.lastRecordUpload > 0); ok(!engine.isFirstSync); deepEqual(user.collection("clients").keys().sort(), [recentID, dupeID, oldID, engine.localID].sort(), "Our record should be uploaded on first sync"); let ids = await store.getAllIDs(); deepEqual(Object.keys(ids).sort(), [recentID, dupeID, oldID, engine.localID].sort(), "Duplicate ID should remain in getAllIDs"); ok((await engine._store.itemExists(dupeID)), "Dupe ID should be considered as existing for Sync methods."); ok(!engine.remoteClientExists(dupeID), "Dupe ID should not be considered as existing for external methods."); // dupe desktop should not appear in .deviceTypes. equal(engine.deviceTypes.get("desktop"), 2); equal(engine.deviceTypes.get("mobile"), 1); // dupe desktop should not appear in stats deepEqual(engine.stats, { hasMobile: 1, names: [engine.localName, "My Phone", "My old desktop"], numClients: 3, }); ok(engine.remoteClientExists(oldID), "non-dupe ID should exist."); ok(!engine.remoteClientExists(dupeID), "dupe ID should not exist"); equal(engine.remoteClients.length, 2, "dupe should not be in remoteClients"); // Check that a subsequent Sync doesn't report anything as being processed. let counts; Svc.Obs.add("weave:engine:sync:applied", function observe(subject, data) { Svc.Obs.remove("weave:engine:sync:applied", observe); counts = subject; }); await syncClientsEngine(server); equal(counts.applied, 0); // We didn't report applying any records. equal(counts.reconciled, 4); // We reported reconcilliation for all records equal(counts.succeeded, 0); equal(counts.failed, 0); equal(counts.newFailed, 0); _("Broadcast logout to all clients"); await engine.sendCommand("logout", []); await syncClientsEngine(server); let collection = server.getCollection("foo", "clients"); let recentPayload = collection.cleartext(recentID); compareCommands(recentPayload.commands, [{ command: "logout", args: [] }], "Should send commands to the recent client"); let oldPayload = collection.cleartext(oldID); compareCommands(oldPayload.commands, [{ command: "logout", args: [] }], "Should send commands to the week-old client"); let dupePayload = collection.cleartext(dupeID); deepEqual(dupePayload.commands, [], "Should not send commands to the dupe client"); _("Update the dupe client's modified time"); collection.insertRecord({ id: dupeID, name: engine.localName, type: "desktop", commands: [], version: "48", protocols: ["1.5"], }, now - 10); _("Second sync."); await syncClientsEngine(server); ids = await store.getAllIDs(); deepEqual(Object.keys(ids).sort(), [recentID, oldID, dupeID, engine.localID].sort(), "Stale client synced, so it should no longer be marked as a dupe"); ok(engine.remoteClientExists(dupeID), "Dupe ID should appear as it synced."); // Recently synced dupe desktop should appear in .deviceTypes. equal(engine.deviceTypes.get("desktop"), 3); // Recently synced dupe desktop should now appear in stats deepEqual(engine.stats, { hasMobile: 1, names: [engine.localName, "My Phone", engine.localName, "My old desktop"], numClients: 4, }); ok(engine.remoteClientExists(dupeID), "recently synced dupe ID should now exist"); equal(engine.remoteClients.length, 3, "recently synced dupe should now be in remoteClients"); } finally { await cleanup(); try { server.deleteCollections("foo"); } finally { await promiseStopServer(server); } } }); add_task(async function test_command_sync() { _("Ensure that commands are synced across clients."); await engine._store.wipe(); await generateNewKeys(Service.collectionKeys); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let user = server.user("foo"); let remoteId = Utils.makeGUID(); function clientWBO(id) { return user.collection("clients").wbo(id); } _("Create remote client record"); user.collection("clients").insertRecord({ id: remoteId, name: "Remote client", type: "desktop", commands: [], version: "48", protocols: ["1.5"], }); try { _("Syncing."); await syncClientsEngine(server); _("Checking remote record was downloaded."); let clientRecord = engine._store._remoteClients[remoteId]; notEqual(clientRecord, undefined); equal(clientRecord.commands.length, 0); _("Send a command to the remote client."); await engine.sendCommand("wipeAll", []); let clientCommands = (await engine._readCommands())[remoteId]; equal(clientCommands.length, 1); await syncClientsEngine(server); _("Checking record was uploaded."); notEqual(clientWBO(engine.localID).payload, undefined); ok(engine.lastRecordUpload > 0); ok(!engine.isFirstSync); notEqual(clientWBO(remoteId).payload, undefined); Svc.Prefs.set("client.GUID", remoteId); await engine._resetClient(); equal(engine.localID, remoteId); _("Performing sync on resetted client."); await syncClientsEngine(server); notEqual(engine.localCommands, undefined); equal(engine.localCommands.length, 1); let command = engine.localCommands[0]; equal(command.command, "wipeAll"); equal(command.args.length, 0); } finally { await cleanup(); try { let collection = server.getCollection("foo", "clients"); collection.remove(remoteId); } finally { await promiseStopServer(server); } } }); add_task(async function test_clients_not_in_fxa_list() { _("Ensure that clients not in the FxA devices list are marked as stale."); await engine._store.wipe(); await generateNewKeys(Service.collectionKeys); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let remoteId = Utils.makeGUID(); let remoteId2 = Utils.makeGUID(); let collection = server.getCollection("foo", "clients"); _("Create remote client records"); collection.insertRecord({ id: remoteId, name: "Remote client", type: "desktop", commands: [], version: "48", fxaDeviceId: remoteId, protocols: ["1.5"], }); collection.insertRecord({ id: remoteId2, name: "Remote client 2", type: "desktop", commands: [], version: "48", fxaDeviceId: remoteId2, protocols: ["1.5"], }); let fxAccounts = engine.fxAccounts; engine.fxAccounts = { notifyDevices() { return Promise.resolve(true); }, getDeviceId() { return fxAccounts.getDeviceId(); }, getDeviceList() { return Promise.resolve([{ id: remoteId }]); }, }; try { _("Syncing."); await syncClientsEngine(server); ok(!engine._store._remoteClients[remoteId].stale); ok(engine._store._remoteClients[remoteId2].stale); } finally { engine.fxAccounts = fxAccounts; await cleanup(); try { collection.remove(remoteId); } finally { await promiseStopServer(server); } } }); add_task(async function test_dupe_device_ids() { _("Ensure that we mark devices with duplicate fxaDeviceIds but older lastModified as stale."); await engine._store.wipe(); await generateNewKeys(Service.collectionKeys); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let remoteId = Utils.makeGUID(); let remoteId2 = Utils.makeGUID(); let remoteDeviceId = Utils.makeGUID(); let collection = server.getCollection("foo", "clients"); _("Create remote client records"); collection.insertRecord({ id: remoteId, name: "Remote client", type: "desktop", commands: [], version: "48", fxaDeviceId: remoteDeviceId, protocols: ["1.5"], }, Date.now() / 1000 - 30000); collection.insertRecord({ id: remoteId2, name: "Remote client", type: "desktop", commands: [], version: "48", fxaDeviceId: remoteDeviceId, protocols: ["1.5"], }); let fxAccounts = engine.fxAccounts; engine.fxAccounts = { notifyDevices() { return Promise.resolve(true); }, getDeviceId() { return fxAccounts.getDeviceId(); }, getDeviceList() { return Promise.resolve([{ id: remoteDeviceId }]); }, }; try { _("Syncing."); await syncClientsEngine(server); ok(engine._store._remoteClients[remoteId].stale); ok(!engine._store._remoteClients[remoteId2].stale); } finally { engine.fxAccounts = fxAccounts; await cleanup(); try { collection.remove(remoteId); } finally { await promiseStopServer(server); } } }); add_task(async function test_send_uri_to_client_for_display() { _("Ensure sendURIToClientForDisplay() sends command properly."); let tracker = engine._tracker; let store = engine._store; let remoteId = Utils.makeGUID(); let rec = new ClientsRec("clients", remoteId); rec.name = "remote"; await store.create(rec); await store.createRecord(remoteId, "clients"); await tracker.clearChangedIDs(); let initialScore = tracker.score; let uri = "http://www.mozilla.org/"; let title = "Title of the Page"; await engine.sendURIToClientForDisplay(uri, remoteId, title); let newRecord = store._remoteClients[remoteId]; notEqual(newRecord, undefined); let clientCommands = (await engine._readCommands())[remoteId]; equal(clientCommands.length, 1); let command = clientCommands[0]; equal(command.command, "displayURI"); equal(command.args.length, 3); equal(command.args[0], uri); equal(command.args[1], engine.localID); equal(command.args[2], title); ok(tracker.score > initialScore); ok(tracker.score - initialScore >= SCORE_INCREMENT_XLARGE); _("Ensure unknown client IDs result in exception."); let unknownId = Utils.makeGUID(); let error; try { await engine.sendURIToClientForDisplay(uri, unknownId); } catch (ex) { error = ex; } equal(error.message.indexOf("Unknown remote client ID: "), 0); await cleanup(); }); add_task(async function test_receive_display_uri() { _("Ensure processing of received 'displayURI' commands works."); // We don't set up WBOs and perform syncing because other tests verify // the command API works as advertised. This saves us a little work. let uri = "http://www.mozilla.org/"; let remoteId = Utils.makeGUID(); let title = "Page Title!"; let command = { command: "displayURI", args: [uri, remoteId, title], }; engine.localCommands = [command]; // Received 'displayURI' command should result in the topic defined below // being called. let ev = "weave:engine:clients:display-uris"; let promiseDisplayURI = new Promise(resolve => { let handler = function(subject, data) { Svc.Obs.remove(ev, handler); resolve({ subject, data }); }; Svc.Obs.add(ev, handler); }); ok((await engine.processIncomingCommands())); let { subject, data } = await promiseDisplayURI; equal(subject[0].uri, uri); equal(subject[0].clientId, remoteId); equal(subject[0].title, title); equal(data, null); await cleanup(); }); add_task(async function test_optional_client_fields() { _("Ensure that we produce records with the fields added in Bug 1097222."); const SUPPORTED_PROTOCOL_VERSIONS = ["1.5"]; let local = await engine._store.createRecord(engine.localID, "clients"); equal(local.name, engine.localName); equal(local.type, engine.localType); equal(local.version, Services.appinfo.version); deepEqual(local.protocols, SUPPORTED_PROTOCOL_VERSIONS); // Optional fields. // Make sure they're what they ought to be... equal(local.os, Services.appinfo.OS); equal(local.appPackage, Services.appinfo.ID); // ... and also that they're non-empty. ok(!!local.os); ok(!!local.appPackage); ok(!!local.application); // We don't currently populate device or formfactor. // See Bug 1100722, Bug 1100723. await cleanup(); }); add_task(async function test_merge_commands() { _("Verifies local commands for remote clients are merged with the server's"); let now = Date.now() / 1000; let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); await generateNewKeys(Service.collectionKeys); let collection = server.getCollection("foo", "clients"); let desktopID = Utils.makeGUID(); collection.insertRecord({ id: desktopID, name: "Desktop client", type: "desktop", commands: [{ command: "displayURI", args: ["https://example.com", engine.localID, "Yak Herders Anonymous"], flowID: Utils.makeGUID(), }], version: "48", protocols: ["1.5"], }, now - 10); let mobileID = Utils.makeGUID(); collection.insertRecord({ id: mobileID, name: "Mobile client", type: "mobile", commands: [{ command: "logout", args: [], flowID: Utils.makeGUID(), }], version: "48", protocols: ["1.5"], }, now - 10); try { _("First sync. 2 records downloaded."); strictEqual(engine.lastRecordUpload, 0); ok(engine.isFirstSync); await syncClientsEngine(server); _("Broadcast logout to all clients"); await engine.sendCommand("logout", []); await syncClientsEngine(server); let desktopPayload = collection.cleartext(desktopID); compareCommands(desktopPayload.commands, [{ command: "displayURI", args: ["https://example.com", engine.localID, "Yak Herders Anonymous"], }, { command: "logout", args: [], }], "Should send the logout command to the desktop client"); let mobilePayload = collection.cleartext(mobileID); compareCommands(mobilePayload.commands, [{ command: "logout", args: [] }], "Should not send a duplicate logout to the mobile client"); } finally { await cleanup(); try { server.deleteCollections("foo"); } finally { await promiseStopServer(server); } } }); add_task(async function test_duplicate_remote_commands() { _("Verifies local commands for remote clients are sent only once (bug 1289287)"); let now = Date.now() / 1000; let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); await generateNewKeys(Service.collectionKeys); let collection = server.getCollection("foo", "clients"); let desktopID = Utils.makeGUID(); collection.insertRecord({ id: desktopID, name: "Desktop client", type: "desktop", commands: [], version: "48", protocols: ["1.5"], }, now - 10); try { _("First sync. 1 record downloaded."); strictEqual(engine.lastRecordUpload, 0); ok(engine.isFirstSync); await syncClientsEngine(server); _("Send tab to client"); await engine.sendCommand("displayURI", ["https://example.com", engine.localID, "Yak Herders Anonymous"]); await syncClientsEngine(server); _("Simulate the desktop client consuming the command and syncing to the server"); collection.insertRecord({ id: desktopID, name: "Desktop client", type: "desktop", commands: [], version: "48", protocols: ["1.5"], }, now - 10); _("Send another tab to the desktop client"); await engine.sendCommand("displayURI", ["https://foobar.com", engine.localID, "Foo bar!"], desktopID); await syncClientsEngine(server); let desktopPayload = collection.cleartext(desktopID); compareCommands(desktopPayload.commands, [{ command: "displayURI", args: ["https://foobar.com", engine.localID, "Foo bar!"], }], "Should only send the second command to the desktop client"); } finally { await cleanup(); try { server.deleteCollections("foo"); } finally { await promiseStopServer(server); } } }); add_task(async function test_upload_after_reboot() { _("Multiple downloads, reboot, then upload (bug 1289287)"); let now = Date.now() / 1000; let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); await generateNewKeys(Service.collectionKeys); let collection = server.getCollection("foo", "clients"); let deviceBID = Utils.makeGUID(); let deviceCID = Utils.makeGUID(); collection.insertRecord({ id: deviceBID, name: "Device B", type: "desktop", commands: [{ command: "displayURI", args: ["https://deviceclink.com", deviceCID, "Device C link"], flowID: Utils.makeGUID(), }], version: "48", protocols: ["1.5"], }, now - 10); collection.insertRecord({ id: deviceCID, name: "Device C", type: "desktop", commands: [], version: "48", protocols: ["1.5"], }, now - 10); try { _("First sync. 2 records downloaded."); strictEqual(engine.lastRecordUpload, 0); ok(engine.isFirstSync); await syncClientsEngine(server); _("Send tab to client"); await engine.sendCommand("displayURI", ["https://example.com", engine.localID, "Yak Herders Anonymous"], deviceBID); const oldUploadOutgoing = SyncEngine.prototype._uploadOutgoing; SyncEngine.prototype._uploadOutgoing = async () => engine._onRecordsWritten([], [deviceBID]); await syncClientsEngine(server); let deviceBPayload = collection.cleartext(deviceBID); compareCommands(deviceBPayload.commands, [{ command: "displayURI", args: ["https://deviceclink.com", deviceCID, "Device C link"], }], "Should be the same because the upload failed"); _("Simulate the client B consuming the command and syncing to the server"); collection.insertRecord({ id: deviceBID, name: "Device B", type: "desktop", commands: [], version: "48", protocols: ["1.5"], }, now - 10); // Simulate reboot SyncEngine.prototype._uploadOutgoing = oldUploadOutgoing; engine = Service.clientsEngine = new ClientEngine(Service); await engine.initialize(); await syncClientsEngine(server); deviceBPayload = collection.cleartext(deviceBID); compareCommands(deviceBPayload.commands, [{ command: "displayURI", args: ["https://example.com", engine.localID, "Yak Herders Anonymous"], }], "Should only had written our outgoing command"); } finally { await cleanup(); try { server.deleteCollections("foo"); } finally { await promiseStopServer(server); } } }); add_task(async function test_keep_cleared_commands_after_reboot() { _("Download commands, fail upload, reboot, then apply new commands (bug 1289287)"); let now = Date.now() / 1000; let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); await generateNewKeys(Service.collectionKeys); let collection = server.getCollection("foo", "clients"); let deviceBID = Utils.makeGUID(); let deviceCID = Utils.makeGUID(); collection.insertRecord({ id: engine.localID, name: "Device A", type: "desktop", commands: [{ command: "displayURI", args: ["https://deviceblink.com", deviceBID, "Device B link"], flowID: Utils.makeGUID(), }, { command: "displayURI", args: ["https://deviceclink.com", deviceCID, "Device C link"], flowID: Utils.makeGUID(), }], version: "48", protocols: ["1.5"], }, now - 10); collection.insertRecord({ id: deviceBID, name: "Device B", type: "desktop", commands: [], version: "48", protocols: ["1.5"], }, now - 10); collection.insertRecord({ id: deviceCID, name: "Device C", type: "desktop", commands: [], version: "48", protocols: ["1.5"], }, now - 10); try { _("First sync. Download remote and our record."); strictEqual(engine.lastRecordUpload, 0); ok(engine.isFirstSync); const oldUploadOutgoing = SyncEngine.prototype._uploadOutgoing; SyncEngine.prototype._uploadOutgoing = async () => engine._onRecordsWritten([], [deviceBID]); let commandsProcessed = 0; engine._handleDisplayURIs = (uris) => { commandsProcessed = uris.length; }; await syncClientsEngine(server); await engine.processIncomingCommands(); // Not called by the engine.sync(), gotta call it ourselves equal(commandsProcessed, 2, "We processed 2 commands"); let localRemoteRecord = collection.cleartext(engine.localID); compareCommands(localRemoteRecord.commands, [{ command: "displayURI", args: ["https://deviceblink.com", deviceBID, "Device B link"], }, { command: "displayURI", args: ["https://deviceclink.com", deviceCID, "Device C link"], }], "Should be the same because the upload failed"); // Another client sends another link collection.insertRecord({ id: engine.localID, name: "Device A", type: "desktop", commands: [{ command: "displayURI", args: ["https://deviceblink.com", deviceBID, "Device B link"], flowID: Utils.makeGUID(), }, { command: "displayURI", args: ["https://deviceclink.com", deviceCID, "Device C link"], flowID: Utils.makeGUID(), }, { command: "displayURI", args: ["https://deviceclink2.com", deviceCID, "Device C link 2"], flowID: Utils.makeGUID(), }], version: "48", protocols: ["1.5"], }, now - 5); // Simulate reboot SyncEngine.prototype._uploadOutgoing = oldUploadOutgoing; engine = Service.clientsEngine = new ClientEngine(Service); await engine.initialize(); commandsProcessed = 0; engine._handleDisplayURIs = (uris) => { commandsProcessed = uris.length; }; await syncClientsEngine(server); await engine.processIncomingCommands(); equal(commandsProcessed, 1, "We processed one command (the other were cleared)"); localRemoteRecord = collection.cleartext(deviceBID); deepEqual(localRemoteRecord.commands, [], "Should be empty"); } finally { await cleanup(); // Reset service (remove mocks) engine = Service.clientsEngine = new ClientEngine(Service); await engine.initialize(); await engine._resetClient(); try { server.deleteCollections("foo"); } finally { await promiseStopServer(server); } } }); add_task(async function test_deleted_commands() { _("Verifies commands for a deleted client are discarded"); let now = Date.now() / 1000; let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); await generateNewKeys(Service.collectionKeys); let collection = server.getCollection("foo", "clients"); let activeID = Utils.makeGUID(); collection.insertRecord({ id: activeID, name: "Active client", type: "desktop", commands: [], version: "48", protocols: ["1.5"], }, now - 10); let deletedID = Utils.makeGUID(); collection.insertRecord({ id: deletedID, name: "Client to delete", type: "desktop", commands: [], version: "48", protocols: ["1.5"], }, now - 10); try { _("First sync. 2 records downloaded."); await syncClientsEngine(server); _("Delete a record on the server."); collection.remove(deletedID); _("Broadcast a command to all clients"); await engine.sendCommand("logout", []); await syncClientsEngine(server); deepEqual(collection.keys().sort(), [activeID, engine.localID].sort(), "Should not reupload deleted clients"); let activePayload = collection.cleartext(activeID); compareCommands(activePayload.commands, [{ command: "logout", args: [] }], "Should send the command to the active client"); } finally { await cleanup(); try { server.deleteCollections("foo"); } finally { await promiseStopServer(server); } } }); add_task(async function test_send_uri_ack() { _("Ensure a sent URI is deleted when the client syncs"); let now = Date.now() / 1000; let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); await generateNewKeys(Service.collectionKeys); try { let fakeSenderID = Utils.makeGUID(); _("Initial sync for empty clients collection"); await syncClientsEngine(server); let collection = server.getCollection("foo", "clients"); collection.updateRecord(engine.localID, payload => { _("Send a URL to the device on the server"); payload.commands = [{ command: "displayURI", args: ["https://example.com", fakeSenderID, "Yak Herders Anonymous"], flowID: Utils.makeGUID(), }]; }, now - 10); _("Sync again"); await syncClientsEngine(server); compareCommands(engine.localCommands, [{ command: "displayURI", args: ["https://example.com", fakeSenderID, "Yak Herders Anonymous"], }], "Should receive incoming URI"); ok((await engine.processIncomingCommands()), "Should process incoming commands"); const clearedCommands = (await engine._readCommands())[engine.localID]; compareCommands(clearedCommands, [{ command: "displayURI", args: ["https://example.com", fakeSenderID, "Yak Herders Anonymous"], }], "Should mark the commands as cleared after processing"); _("Check that the command was removed on the server"); await syncClientsEngine(server); let ourPayload = collection.cleartext(engine.localID); ok(ourPayload, "Should upload the synced client record"); deepEqual(ourPayload.commands, [], "Should not reupload cleared commands"); } finally { await cleanup(); try { server.deleteCollections("foo"); } finally { await promiseStopServer(server); } } }); add_task(async function test_command_sync() { _("Notify other clients when writing their record."); await engine._store.wipe(); await generateNewKeys(Service.collectionKeys); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let collection = server.getCollection("foo", "clients"); let remoteId = Utils.makeGUID(); let remoteId2 = Utils.makeGUID(); _("Create remote client record 1"); collection.insertRecord({ id: remoteId, name: "Remote client", type: "desktop", commands: [], version: "48", protocols: ["1.5"], }); _("Create remote client record 2"); collection.insertRecord({ id: remoteId2, name: "Remote client 2", type: "mobile", commands: [], version: "48", protocols: ["1.5"], }); try { equal(collection.count(), 2, "2 remote records written"); await syncClientsEngine(server); equal(collection.count(), 3, "3 remote records written (+1 for the synced local record)"); await engine.sendCommand("wipeAll", []); await engine._tracker.addChangedID(engine.localID); const getClientFxaDeviceId = sinon.stub(engine, "getClientFxaDeviceId", (id) => "fxa-" + id); const engineMock = sinon.mock(engine); let _notifyCollectionChanged = engineMock.expects("_notifyCollectionChanged") .withArgs(["fxa-" + remoteId, "fxa-" + remoteId2]); _("Syncing."); await syncClientsEngine(server); _notifyCollectionChanged.verify(); engineMock.restore(); getClientFxaDeviceId.restore(); } finally { await cleanup(); await engine._tracker.clearChangedIDs(); try { server.deleteCollections("foo"); } finally { await promiseStopServer(server); } } }); add_task(async function ensureSameFlowIDs() { let events = []; let origRecordTelemetryEvent = Service.recordTelemetryEvent; Service.recordTelemetryEvent = (object, method, value, extra) => { events.push({ object, method, value, extra }); }; let server = await serverForFoo(engine); try { // Setup 2 clients, send them a command, and ensure we get to events // written, both with the same flowID. await SyncTestingInfrastructure(server); let collection = server.getCollection("foo", "clients"); let remoteId = Utils.makeGUID(); let remoteId2 = Utils.makeGUID(); _("Create remote client record 1"); collection.insertRecord({ id: remoteId, name: "Remote client", type: "desktop", commands: [], version: "48", protocols: ["1.5"], }); _("Create remote client record 2"); collection.insertRecord({ id: remoteId2, name: "Remote client 2", type: "mobile", commands: [], version: "48", protocols: ["1.5"], }); await syncClientsEngine(server); await engine.sendCommand("wipeAll", []); await syncClientsEngine(server); equal(events.length, 2); // we don't know what the flowID is, but do know it should be the same. equal(events[0].extra.flowID, events[1].extra.flowID); // Wipe remote clients to ensure deduping doesn't prevent us from adding the command. for (let client of Object.values(engine._store._remoteClients)) { client.commands = []; } // check it's correctly used when we specify a flow ID events.length = 0; let flowID = Utils.makeGUID(); await engine.sendCommand("wipeAll", [], null, { flowID }); await syncClientsEngine(server); equal(events.length, 2); equal(events[0].extra.flowID, flowID); equal(events[1].extra.flowID, flowID); // Wipe remote clients to ensure deduping doesn't prevent us from adding the command. for (let client of Object.values(engine._store._remoteClients)) { client.commands = []; } // and that it works when something else is in "extra" events.length = 0; await engine.sendCommand("wipeAll", [], null, { reason: "testing" }); await syncClientsEngine(server); equal(events.length, 2); equal(events[0].extra.flowID, events[1].extra.flowID); equal(events[0].extra.reason, "testing"); equal(events[1].extra.reason, "testing"); // Wipe remote clients to ensure deduping doesn't prevent us from adding the command. for (let client of Object.values(engine._store._remoteClients)) { client.commands = []; } // and when both are specified. events.length = 0; await engine.sendCommand("wipeAll", [], null, { reason: "testing", flowID }); await syncClientsEngine(server); equal(events.length, 2); equal(events[0].extra.flowID, flowID); equal(events[1].extra.flowID, flowID); equal(events[0].extra.reason, "testing"); equal(events[1].extra.reason, "testing"); // Wipe remote clients to ensure deduping doesn't prevent us from adding the command. for (let client of Object.values(engine._store._remoteClients)) { client.commands = []; } } finally { Service.recordTelemetryEvent = origRecordTelemetryEvent; cleanup(); await promiseStopServer(server); } }); add_task(async function test_duplicate_commands_telemetry() { let events = []; let origRecordTelemetryEvent = Service.recordTelemetryEvent; Service.recordTelemetryEvent = (object, method, value, extra) => { events.push({ object, method, value, extra }); }; let server = await serverForFoo(engine); try { await SyncTestingInfrastructure(server); let collection = server.getCollection("foo", "clients"); let remoteId = Utils.makeGUID(); let remoteId2 = Utils.makeGUID(); _("Create remote client record 1"); collection.insertRecord({ id: remoteId, name: "Remote client", type: "desktop", commands: [], version: "48", protocols: ["1.5"], }); _("Create remote client record 2"); collection.insertRecord({ id: remoteId2, name: "Remote client 2", type: "mobile", commands: [], version: "48", protocols: ["1.5"], }); await syncClientsEngine(server); // Make sure deduping works before syncing await engine.sendURIToClientForDisplay("https://example.com", remoteId, "Example"); await engine.sendURIToClientForDisplay("https://example.com", remoteId, "Example"); equal(events.length, 1); await syncClientsEngine(server); // And after syncing. await engine.sendURIToClientForDisplay("https://example.com", remoteId, "Example"); equal(events.length, 1); // Ensure we aren't deduping commands to different clients await engine.sendURIToClientForDisplay("https://example.com", remoteId2, "Example"); equal(events.length, 2); } finally { Service.recordTelemetryEvent = origRecordTelemetryEvent; cleanup(); await promiseStopServer(server); } }); add_task(async function test_other_clients_notified_on_first_sync() { _("Ensure that other clients are notified when we upload our client record for the first time."); await engine.resetLastSync(); await engine._store.wipe(); await generateNewKeys(Service.collectionKeys); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); const fxAccounts = engine.fxAccounts; let calls = 0; engine.fxAccounts = { getDeviceId() { return fxAccounts.getDeviceId(); }, notifyDevices() { calls++; return Promise.resolve(true); }, }; try { engine.lastRecordUpload = 0; _("First sync, should notify other clients"); await syncClientsEngine(server); equal(calls, 1); _("Second sync, should not notify other clients"); await syncClientsEngine(server); equal(calls, 1); } finally { engine.fxAccounts = fxAccounts; cleanup(); await promiseStopServer(server); } }); add_task(async function device_disconnected_notification_updates_known_stale_clients() { const spyUpdate = sinon.spy(engine, "updateKnownStaleClients"); Services.obs.notifyObservers(null, "fxaccounts:device_disconnected", JSON.stringify({ isLocalDevice: false })); ok(spyUpdate.calledOnce, "updateKnownStaleClients should be called"); spyUpdate.reset(); Services.obs.notifyObservers(null, "fxaccounts:device_disconnected", JSON.stringify({ isLocalDevice: true })); ok(spyUpdate.notCalled, "updateKnownStaleClients should not be called"); spyUpdate.restore(); }); add_task(async function update_known_stale_clients() { const makeFakeClient = (id) => ({ id, fxaDeviceId: `fxa-${id}` }); const clients = [makeFakeClient("one"), makeFakeClient("two"), makeFakeClient("three")]; const stubRemoteClients = sinon.stub(engine._store, "_remoteClients").get(() => { return clients; }); - const stubRefresh = sinon.stub(engine, "_refreshKnownStaleClients", () => { + const stubFetchFxADevices = sinon.stub(engine, "_fetchFxADevices", () => { engine._knownStaleFxADeviceIds = ["fxa-one", "fxa-two"]; }); engine._knownStaleFxADeviceIds = null; await engine.updateKnownStaleClients(); ok(clients[0].stale); ok(clients[1].stale); ok(!clients[2].stale); stubRemoteClients.restore(); - stubRefresh.restore(); -}); - -add_task(async function process_incoming_refreshes_known_stale_clients() { - const stubProcessIncoming = sinon.stub(SyncEngine.prototype, "_processIncoming"); - const stubRefresh = sinon.stub(engine, "_refreshKnownStaleClients", () => { - engine._knownStaleFxADeviceIds = ["one", "two"]; - }); - - engine._knownStaleFxADeviceIds = null; - await engine._processIncoming(); - ok(stubRefresh.calledOnce, "Should refresh the known stale clients"); - stubRefresh.reset(); - - await engine._processIncoming(); - ok(stubRefresh.notCalled, "Should not refresh the known stale clients since it's already populated"); - - stubProcessIncoming.restore(); - stubRefresh.restore(); -}); - -add_task(async function process_incoming_refreshes_known_stale_clients() { - Services.prefs.clearUserPref("services.sync.clients.lastModifiedOnProcessCommands"); - engine._localClientLastModified = Math.round(Date.now() / 1000); - - const stubRemoveLocalCommand = sinon.stub(engine, "removeLocalCommand"); - const tabProcessedSpy = sinon.spy(engine, "_handleDisplayURIs"); - engine.localCommands = [{ command: "displayURI", args: ["https://foo.bar", "fxaid1", "foo"] }]; - - await engine.processIncomingCommands(); - ok(tabProcessedSpy.calledOnce); - // Let's say we failed to upload and we end up calling processIncomingCommands again - await engine.processIncomingCommands(); - ok(tabProcessedSpy.calledOnce); - - tabProcessedSpy.restore(); - stubRemoveLocalCommand.restore(); + stubFetchFxADevices.restore(); }); add_task(async function test_create_record_command_limit() { await engine._store.wipe(); await generateNewKeys(Service.collectionKeys); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); const fakeLimit = 4 * 1024; let maxSizeStub = sinon.stub(Service, "getMemcacheMaxRecordPayloadSize", () => fakeLimit); let user = server.user("foo"); let remoteId = Utils.makeGUID(); _("Create remote client record"); user.collection("clients").insertRecord({ id: remoteId, name: "Remote client", type: "desktop", commands: [], version: "57", protocols: ["1.5"], }); try { _("Initial sync."); await syncClientsEngine(server); _("Send a fairly sane number of commands."); for (let i = 0; i < 5; ++i) { await engine.sendURIToClientForDisplay( `https://www.example.com/1/${i}`, remoteId, `Page 1.${i}`); } await syncClientsEngine(server); _("Make sure they all fit and weren't dropped."); let parsedServerRecord = user.collection("clients").cleartext(remoteId); equal(parsedServerRecord.commands.length, 5); await engine.sendCommand("wipeEngine", ["history"], remoteId); _("Send a not-sane number of commands."); // Much higher than the maximum number of commands we could actually fit. for (let i = 0; i < 500; ++i) { await engine.sendURIToClientForDisplay( `https://www.example.com/2/${i}`, remoteId, `Page 2.${i}`); } await syncClientsEngine(server); _("Ensure we didn't overflow the server limit."); let wbo = user.collection("clients").wbo(remoteId); less(wbo.payload.length, fakeLimit); _("And that the data we uploaded is both sane json and containing some commands."); let remoteCommands = wbo.getCleartext().commands; greater(remoteCommands.length, 2); let firstCommand = remoteCommands[0]; _("The first command should still be present, since it had a high priority"); equal(firstCommand.command, "wipeEngine"); _("And the last command in the list should be the last command we sent."); let lastCommand = remoteCommands[remoteCommands.length - 1]; equal(lastCommand.command, "displayURI"); deepEqual(lastCommand.args, ["https://www.example.com/2/499", engine.localID, "Page 2.499"]); } finally { maxSizeStub.restore(); await cleanup(); try { let collection = server.getCollection("foo", "clients"); collection.remove(remoteId); } finally { await promiseStopServer(server); } } });