461 lines
15 KiB
JavaScript
461 lines
15 KiB
JavaScript
/* 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/. */
|
|
|
|
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
CLIENT_NOT_CONFIGURED: "resource://services-sync/constants.sys.mjs",
|
|
Weave: "resource://services-sync/main.sys.mjs",
|
|
getRemoteCommandStore: "resource://services-sync/TabsStore.sys.mjs",
|
|
RemoteCommand: "resource://services-sync/TabsStore.sys.mjs",
|
|
FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs",
|
|
});
|
|
|
|
// The Sync XPCOM service
|
|
ChromeUtils.defineLazyGetter(lazy, "weaveXPCService", function () {
|
|
return Cc["@mozilla.org/weave/service;1"].getService(Ci.nsISupports)
|
|
.wrappedJSObject;
|
|
});
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
|
|
return ChromeUtils.importESModule(
|
|
"resource://gre/modules/FxAccounts.sys.mjs"
|
|
).getFxAccountsSingleton();
|
|
});
|
|
|
|
// from MDN...
|
|
function escapeRegExp(string) {
|
|
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
// A topic we fire whenever we have new tabs available. This might be due
|
|
// to a request made by this module to refresh the tab list, or as the result
|
|
// of a regularly scheduled sync. The intent is that consumers just listen
|
|
// for this notification and update their UI in response.
|
|
const TOPIC_TABS_CHANGED = "services.sync.tabs.changed";
|
|
|
|
// A topic we fire whenever we have queued a new remote tabs command.
|
|
const TOPIC_TABS_COMMAND_QUEUED = "services.sync.tabs.command-queued";
|
|
|
|
// The interval, in seconds, before which we consider the existing list
|
|
// of tabs "fresh enough" and don't force a new sync.
|
|
const TABS_FRESH_ENOUGH_INTERVAL_SECONDS = 30;
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "log", () => {
|
|
const { Log } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/Log.sys.mjs"
|
|
);
|
|
let log = Log.repository.getLogger("Sync.RemoteTabs");
|
|
log.manageLevelFromPref("services.sync.log.logger.tabs");
|
|
return log;
|
|
});
|
|
|
|
// We allow some test preferences to simulate many and inactive tabs.
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"NUM_FAKE_INACTIVE_TABS",
|
|
"services.sync.syncedTabs.numFakeInactiveTabs",
|
|
0
|
|
);
|
|
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"NUM_FAKE_ACTIVE_TABS",
|
|
"services.sync.syncedTabs.numFakeActiveTabs",
|
|
0
|
|
);
|
|
|
|
// A private singleton that does the work.
|
|
let SyncedTabsInternal = {
|
|
/* Make a "tab" record. Returns a promise */
|
|
async _makeTab(client, tab, url, showRemoteIcons) {
|
|
let icon;
|
|
if (showRemoteIcons) {
|
|
icon = tab.icon;
|
|
}
|
|
if (!icon) {
|
|
// By not specifying a size the favicon service will pick the default,
|
|
// that is usually set through setDefaultIconURIPreferredSize by the
|
|
// first browser window. Commonly it's 16px at current dpi.
|
|
icon = "page-icon:" + url;
|
|
}
|
|
return {
|
|
type: "tab",
|
|
title: tab.title || url,
|
|
url,
|
|
icon,
|
|
client: client.id,
|
|
lastUsed: tab.lastUsed,
|
|
inactive: tab.inactive,
|
|
};
|
|
},
|
|
|
|
/* Make a "client" record. Returns a promise for consistency with _makeTab */
|
|
async _makeClient(client) {
|
|
return {
|
|
id: client.id,
|
|
type: "client",
|
|
name: lazy.Weave.Service.clientsEngine.getClientName(client.id),
|
|
clientType: lazy.Weave.Service.clientsEngine.getClientType(client.id),
|
|
lastModified: client.lastModified * 1000, // sec to ms
|
|
tabs: [],
|
|
};
|
|
},
|
|
|
|
_tabMatchesFilter(tab, filter) {
|
|
let reFilter = new RegExp(escapeRegExp(filter), "i");
|
|
return reFilter.test(tab.url) || reFilter.test(tab.title);
|
|
},
|
|
|
|
// A wrapper for grabbing the fxaDeviceId, to make it easier for stubbing
|
|
// for tests
|
|
_getClientFxaDeviceId(clientId) {
|
|
return lazy.Weave.Service.clientsEngine.getClientFxaDeviceId(clientId);
|
|
},
|
|
|
|
_createRecentTabsList(
|
|
clients,
|
|
maxCount,
|
|
extraParams = { removeAllDupes: true, removeDeviceDupes: false }
|
|
) {
|
|
let tabs = [];
|
|
|
|
for (let client of clients) {
|
|
if (extraParams.removeDeviceDupes) {
|
|
client.tabs = this._filterRecentTabsDupes(client.tabs);
|
|
}
|
|
|
|
// We have the client obj but we need the FxA device obj so we use the clients
|
|
// engine to get us the FxA device
|
|
let device =
|
|
lazy.fxAccounts.device.recentDeviceList &&
|
|
lazy.fxAccounts.device.recentDeviceList.find(
|
|
d => d.id === this._getClientFxaDeviceId(client.id)
|
|
);
|
|
|
|
for (let tab of client.tabs) {
|
|
tab.device = client.name;
|
|
tab.deviceType = client.clientType;
|
|
// Surface broadcasted commmands for things like close remote tab
|
|
tab.fxaDeviceId = device.id;
|
|
tab.availableCommands = device.availableCommands;
|
|
}
|
|
tabs = [...tabs, ...client.tabs.reverse()];
|
|
}
|
|
if (extraParams.removeAllDupes) {
|
|
tabs = this._filterRecentTabsDupes(tabs);
|
|
}
|
|
tabs = tabs.sort((a, b) => b.lastUsed - a.lastUsed).slice(0, maxCount);
|
|
return tabs;
|
|
},
|
|
|
|
// Filter out any tabs with duplicate URLs preserving
|
|
// the duplicate with the most recent lastUsed value
|
|
_filterRecentTabsDupes(tabs) {
|
|
const tabMap = new Map();
|
|
for (const tab of tabs) {
|
|
const existingTab = tabMap.get(tab.url);
|
|
if (!existingTab || tab.lastUsed > existingTab.lastUsed) {
|
|
tabMap.set(tab.url, tab);
|
|
}
|
|
}
|
|
return Array.from(tabMap.values());
|
|
},
|
|
|
|
async getTabClients(filter) {
|
|
lazy.log.info("Generating tab list with filter", filter);
|
|
let result = [];
|
|
|
|
// If Sync isn't ready, don't try and get anything.
|
|
if (!lazy.weaveXPCService.ready) {
|
|
lazy.log.debug("Sync isn't yet ready, so returning an empty tab list");
|
|
return result;
|
|
}
|
|
|
|
// A boolean that controls whether we should show the icon from the remote tab.
|
|
const showRemoteIcons = Services.prefs.getBoolPref(
|
|
"services.sync.syncedTabs.showRemoteIcons",
|
|
true
|
|
);
|
|
|
|
let engine = lazy.Weave.Service.engineManager.get("tabs");
|
|
|
|
let ntabs = 0;
|
|
let clientTabList = await engine.getAllClients();
|
|
for (let client of clientTabList) {
|
|
if (!lazy.Weave.Service.clientsEngine.remoteClientExists(client.id)) {
|
|
continue;
|
|
}
|
|
let clientRepr = await this._makeClient(client);
|
|
lazy.log.debug("Processing client", clientRepr);
|
|
|
|
let tabs = Array.from(client.tabs); // avoid modifying in-place.
|
|
// For QA, UX, etc, we allow "fake tabs" to be added to each device.
|
|
for (let i = 0; i < lazy.NUM_FAKE_INACTIVE_TABS; i++) {
|
|
tabs.push({
|
|
icon: null,
|
|
lastUsed: 1000,
|
|
title: `Fake inactive tab ${i}`,
|
|
urlHistory: [`https://example.com/inactive/${i}`],
|
|
inactive: true,
|
|
});
|
|
}
|
|
for (let i = 0; i < lazy.NUM_FAKE_ACTIVE_TABS; i++) {
|
|
tabs.push({
|
|
icon: null,
|
|
lastUsed: Date.now() - 1000 + i,
|
|
title: `Fake tab ${i}`,
|
|
urlHistory: [`https://example.com/${i}`],
|
|
});
|
|
}
|
|
|
|
for (let tab of tabs) {
|
|
let url = tab.urlHistory[0];
|
|
lazy.log.trace("remote tab", url);
|
|
|
|
if (!url) {
|
|
continue;
|
|
}
|
|
let tabRepr = await this._makeTab(client, tab, url, showRemoteIcons);
|
|
if (filter && !this._tabMatchesFilter(tabRepr, filter)) {
|
|
continue;
|
|
}
|
|
clientRepr.tabs.push(tabRepr);
|
|
}
|
|
|
|
// Filter out duplicate tabs based on URL
|
|
clientRepr.tabs = this._filterRecentTabsDupes(clientRepr.tabs);
|
|
|
|
// We return all clients, even those without tabs - the consumer should
|
|
// filter it if they care.
|
|
ntabs += clientRepr.tabs.length;
|
|
result.push(clientRepr);
|
|
}
|
|
lazy.log.info(
|
|
`Final tab list has ${result.length} clients with ${ntabs} tabs.`
|
|
);
|
|
return result;
|
|
},
|
|
|
|
async syncTabs(force) {
|
|
if (!force) {
|
|
// Don't bother refetching tabs if we already did so recently
|
|
let lastFetch = Services.prefs.getIntPref(
|
|
"services.sync.lastTabFetch",
|
|
0
|
|
);
|
|
let now = Math.floor(Date.now() / 1000);
|
|
if (now - lastFetch < TABS_FRESH_ENOUGH_INTERVAL_SECONDS) {
|
|
lazy.log.info("_refetchTabs was done recently, do not doing it again");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// If Sync isn't configured don't try and sync, else we will get reports
|
|
// of a login failure.
|
|
if (lazy.Weave.Status.checkSetup() === lazy.CLIENT_NOT_CONFIGURED) {
|
|
lazy.log.info(
|
|
"Sync client is not configured, so not attempting a tab sync"
|
|
);
|
|
return false;
|
|
}
|
|
// If the primary pass is locked, we should not try to sync
|
|
if (lazy.Weave.Utils.mpLocked()) {
|
|
lazy.log.info(
|
|
"Can't sync tabs due to the primary password being locked",
|
|
lazy.Weave.Status.login
|
|
);
|
|
return false;
|
|
}
|
|
// Ask Sync to just do the tabs engine if it can.
|
|
try {
|
|
lazy.log.info("Doing a tab sync.");
|
|
await lazy.Weave.Service.sync({ why: "tabs", engines: ["tabs"] });
|
|
return true;
|
|
} catch (ex) {
|
|
lazy.log.error("Sync failed", ex);
|
|
throw ex;
|
|
}
|
|
},
|
|
|
|
observe(subject, topic, data) {
|
|
lazy.log.trace(`observed topic=${topic}, data=${data}, subject=${subject}`);
|
|
switch (topic) {
|
|
case "weave:engine:sync:finish":
|
|
if (data != "tabs") {
|
|
return;
|
|
}
|
|
// The tabs engine just finished syncing
|
|
// Set our lastTabFetch pref here so it tracks both explicit sync calls
|
|
// and normally scheduled ones.
|
|
Services.prefs.setIntPref(
|
|
"services.sync.lastTabFetch",
|
|
Math.floor(Date.now() / 1000)
|
|
);
|
|
Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED);
|
|
break;
|
|
case "weave:service:start-over":
|
|
// start-over needs to notify so consumers find no tabs.
|
|
Services.prefs.clearUserPref("services.sync.lastTabFetch");
|
|
Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED);
|
|
break;
|
|
case "nsPref:changed":
|
|
Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
},
|
|
|
|
// Returns true if Sync is configured to Sync tabs, false otherwise
|
|
get isConfiguredToSyncTabs() {
|
|
if (!lazy.weaveXPCService.ready) {
|
|
lazy.log.debug("Sync isn't yet ready; assuming tab engine is enabled");
|
|
return true;
|
|
}
|
|
|
|
let engine = lazy.Weave.Service.engineManager.get("tabs");
|
|
return engine && engine.enabled;
|
|
},
|
|
|
|
get hasSyncedThisSession() {
|
|
let engine = lazy.Weave.Service.engineManager.get("tabs");
|
|
return engine && engine.hasSyncedThisSession;
|
|
},
|
|
};
|
|
|
|
Services.obs.addObserver(SyncedTabsInternal, "weave:engine:sync:finish");
|
|
Services.obs.addObserver(SyncedTabsInternal, "weave:service:start-over");
|
|
// Observe the pref the indicates the state of the tabs engine has changed.
|
|
// This will force consumers to re-evaluate the state of sync and update
|
|
// accordingly.
|
|
Services.prefs.addObserver("services.sync.engine.tabs", SyncedTabsInternal);
|
|
|
|
// The public interface.
|
|
export var SyncedTabs = {
|
|
// A mock-point for tests.
|
|
_internal: SyncedTabsInternal,
|
|
|
|
// We make the topic for the observer notification public.
|
|
TOPIC_TABS_CHANGED,
|
|
|
|
// Expose the interval used to determine if synced tabs data needs a new sync
|
|
TABS_FRESH_ENOUGH_INTERVAL_SECONDS,
|
|
|
|
// Returns true if Sync is configured to Sync tabs, false otherwise
|
|
get isConfiguredToSyncTabs() {
|
|
return this._internal.isConfiguredToSyncTabs;
|
|
},
|
|
|
|
// Returns true if a tab sync has completed once this session. If this
|
|
// returns false, then getting back no clients/tabs possibly just means we
|
|
// are waiting for that first sync to complete.
|
|
get hasSyncedThisSession() {
|
|
return this._internal.hasSyncedThisSession;
|
|
},
|
|
|
|
// Return a promise that resolves with an array of client records, each with
|
|
// a .tabs array. Note that part of the contract for this module is that the
|
|
// returned objects are not shared between invocations, so callers are free
|
|
// to mutate the returned objects (eg, sort, truncate) however they see fit.
|
|
getTabClients(query) {
|
|
return this._internal.getTabClients(query);
|
|
},
|
|
|
|
// Starts a background request to start syncing tabs. Returns a promise that
|
|
// resolves when the sync is complete, but there's no resolved value -
|
|
// callers should be listening for TOPIC_TABS_CHANGED.
|
|
// If |force| is true we always sync. If false, we only sync if the most
|
|
// recent sync wasn't "recently".
|
|
syncTabs(force) {
|
|
return this._internal.syncTabs(force);
|
|
},
|
|
|
|
createRecentTabsList(clients, maxCount, extraParams) {
|
|
return this._internal._createRecentTabsList(clients, maxCount, extraParams);
|
|
},
|
|
|
|
sortTabClientsByLastUsed(clients) {
|
|
// First sort the list of tabs for each client. Note that
|
|
// this module promises that the objects it returns are never
|
|
// shared, so we are free to mutate those objects directly.
|
|
for (let client of clients) {
|
|
let tabs = client.tabs;
|
|
tabs.sort((a, b) => b.lastUsed - a.lastUsed);
|
|
}
|
|
// Now sort the clients - the clients are sorted in the order of the
|
|
// most recent tab for that client (ie, it is important the tabs for
|
|
// each client are already sorted.)
|
|
clients.sort((a, b) => {
|
|
if (!a.tabs.length) {
|
|
return 1; // b comes first.
|
|
}
|
|
if (!b.tabs.length) {
|
|
return -1; // a comes first.
|
|
}
|
|
return b.tabs[0].lastUsed - a.tabs[0].lastUsed;
|
|
});
|
|
},
|
|
|
|
recordSyncedTabsTelemetry(object, tabEvent, extraOptions) {
|
|
if (
|
|
!["fxa_avatar_menu", "fxa_app_menu", "synced_tabs_sidebar"].includes(
|
|
object
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
object = object
|
|
.split("_")
|
|
.map(word => word[0].toUpperCase() + word.slice(1))
|
|
.join("");
|
|
Glean.syncedTabs[tabEvent + object].record(extraOptions);
|
|
},
|
|
|
|
// Get list of synced tabs across all devices/clients
|
|
// truncated by value of maxCount param, sorted by
|
|
// lastUsed value, and filtered for duplicate URLs
|
|
async getRecentTabs(maxCount, extraParams) {
|
|
let clients = await this.getTabClients();
|
|
return this._internal._createRecentTabsList(clients, maxCount, extraParams);
|
|
},
|
|
};
|
|
|
|
// Remote tab management public interface.
|
|
export var SyncedTabsManagement = {
|
|
// A mock-point for tests.
|
|
async _getStore() {
|
|
return await lazy.getRemoteCommandStore();
|
|
},
|
|
|
|
/// Enqueue a tab to close on a remote device.
|
|
async enqueueTabToClose(deviceId, url) {
|
|
let store = await this._getStore();
|
|
let command = new lazy.RemoteCommand.CloseTab(url);
|
|
if (!store.addRemoteCommand(deviceId, command)) {
|
|
lazy.log.warn(
|
|
"Could not queue a remote tab close - it was already queued"
|
|
);
|
|
} else {
|
|
lazy.log.info("Queued remote tab close command.");
|
|
}
|
|
// fxAccounts commands infrastructure is lazily initialized, at which point
|
|
// it registers observers etc - make sure it's initialized;
|
|
lazy.FxAccounts.commands;
|
|
Services.obs.notifyObservers(null, TOPIC_TABS_COMMAND_QUEUED);
|
|
},
|
|
|
|
/// Remove a tab from the queue of commands for a remote device.
|
|
async removePendingTabToClose(deviceId, url) {
|
|
let store = await this._getStore();
|
|
let command = new lazy.RemoteCommand.CloseTab(url);
|
|
if (!store.removeRemoteCommand(deviceId, command)) {
|
|
lazy.log.warn("Could not remove a remote tab close - it was not queued");
|
|
} else {
|
|
lazy.log.info("Removed queued remote tab close command.");
|
|
}
|
|
},
|
|
};
|