SyncedTabs.sys.mjs (15541B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 CLIENT_NOT_CONFIGURED: "resource://services-sync/constants.sys.mjs", 11 Weave: "resource://services-sync/main.sys.mjs", 12 getRemoteCommandStore: "resource://services-sync/TabsStore.sys.mjs", 13 RemoteCommand: "resource://services-sync/TabsStore.sys.mjs", 14 FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs", 15 }); 16 17 // The Sync XPCOM service 18 ChromeUtils.defineLazyGetter(lazy, "weaveXPCService", function () { 19 return Cc["@mozilla.org/weave/service;1"].getService(Ci.nsISupports) 20 .wrappedJSObject; 21 }); 22 23 ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { 24 return ChromeUtils.importESModule( 25 "resource://gre/modules/FxAccounts.sys.mjs" 26 ).getFxAccountsSingleton(); 27 }); 28 29 // from MDN... 30 function escapeRegExp(string) { 31 return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 32 } 33 34 // A topic we fire whenever we have new tabs available. This might be due 35 // to a request made by this module to refresh the tab list, or as the result 36 // of a regularly scheduled sync. The intent is that consumers just listen 37 // for this notification and update their UI in response. 38 const TOPIC_TABS_CHANGED = "services.sync.tabs.changed"; 39 40 // A topic we fire whenever we have queued a new remote tabs command. 41 const TOPIC_TABS_COMMAND_QUEUED = "services.sync.tabs.command-queued"; 42 43 // The interval, in seconds, before which we consider the existing list 44 // of tabs "fresh enough" and don't force a new sync. 45 const TABS_FRESH_ENOUGH_INTERVAL_SECONDS = 30; 46 47 ChromeUtils.defineLazyGetter(lazy, "log", () => { 48 const { Log } = ChromeUtils.importESModule( 49 "resource://gre/modules/Log.sys.mjs" 50 ); 51 let log = Log.repository.getLogger("Sync.RemoteTabs"); 52 log.manageLevelFromPref("services.sync.log.logger.tabs"); 53 return log; 54 }); 55 56 // We allow some test preferences to simulate many and inactive tabs. 57 XPCOMUtils.defineLazyPreferenceGetter( 58 lazy, 59 "NUM_FAKE_INACTIVE_TABS", 60 "services.sync.syncedTabs.numFakeInactiveTabs", 61 0 62 ); 63 64 XPCOMUtils.defineLazyPreferenceGetter( 65 lazy, 66 "NUM_FAKE_ACTIVE_TABS", 67 "services.sync.syncedTabs.numFakeActiveTabs", 68 0 69 ); 70 71 // A private singleton that does the work. 72 let SyncedTabsInternal = { 73 /* Make a "tab" record. Returns a promise */ 74 async _makeTab(client, tab, url, showRemoteIcons) { 75 let icon; 76 if (showRemoteIcons) { 77 icon = tab.icon; 78 } 79 if (!icon) { 80 // By not specifying a size the favicon service will pick the default, 81 // that is usually set through setDefaultIconURIPreferredSize by the 82 // first browser window. Commonly it's 16px at current dpi. 83 icon = "page-icon:" + url; 84 } 85 return { 86 type: "tab", 87 title: tab.title || url, 88 url, 89 icon, 90 client: client.id, 91 lastUsed: tab.lastUsed, 92 inactive: tab.inactive, 93 }; 94 }, 95 96 /* Make a "client" record. Returns a promise for consistency with _makeTab */ 97 async _makeClient(client) { 98 return { 99 id: client.id, 100 type: "client", 101 name: lazy.Weave.Service.clientsEngine.getClientName(client.id), 102 clientType: lazy.Weave.Service.clientsEngine.getClientType(client.id), 103 lastModified: client.lastModified * 1000, // sec to ms 104 tabs: [], 105 }; 106 }, 107 108 _tabMatchesFilter(tab, filter) { 109 let reFilter = new RegExp(escapeRegExp(filter), "i"); 110 return reFilter.test(tab.url) || reFilter.test(tab.title); 111 }, 112 113 // A wrapper for grabbing the fxaDeviceId, to make it easier for stubbing 114 // for tests 115 _getClientFxaDeviceId(clientId) { 116 return lazy.Weave.Service.clientsEngine.getClientFxaDeviceId(clientId); 117 }, 118 119 _createRecentTabsList( 120 clients, 121 maxCount, 122 extraParams = { removeAllDupes: true, removeDeviceDupes: false } 123 ) { 124 let tabs = []; 125 126 for (let client of clients) { 127 if (extraParams.removeDeviceDupes) { 128 client.tabs = this._filterRecentTabsDupes(client.tabs); 129 } 130 131 // We have the client obj but we need the FxA device obj so we use the clients 132 // engine to get us the FxA device 133 let device = 134 lazy.fxAccounts.device.recentDeviceList && 135 lazy.fxAccounts.device.recentDeviceList.find( 136 d => d.id === this._getClientFxaDeviceId(client.id) 137 ); 138 139 for (let tab of client.tabs) { 140 tab.device = client.name; 141 tab.deviceType = client.clientType; 142 // Surface broadcasted commmands for things like close remote tab 143 tab.fxaDeviceId = device.id; 144 tab.availableCommands = device.availableCommands; 145 } 146 tabs = [...tabs, ...client.tabs.reverse()]; 147 } 148 if (extraParams.removeAllDupes) { 149 tabs = this._filterRecentTabsDupes(tabs); 150 } 151 tabs = tabs.sort((a, b) => b.lastUsed - a.lastUsed).slice(0, maxCount); 152 return tabs; 153 }, 154 155 // Filter out any tabs with duplicate URLs preserving 156 // the duplicate with the most recent lastUsed value 157 _filterRecentTabsDupes(tabs) { 158 const tabMap = new Map(); 159 for (const tab of tabs) { 160 const existingTab = tabMap.get(tab.url); 161 if (!existingTab || tab.lastUsed > existingTab.lastUsed) { 162 tabMap.set(tab.url, tab); 163 } 164 } 165 return Array.from(tabMap.values()); 166 }, 167 168 async getTabClients(filter) { 169 lazy.log.info("Generating tab list with filter", filter); 170 let result = []; 171 172 // If Sync isn't ready, don't try and get anything. 173 if (!lazy.weaveXPCService.ready) { 174 lazy.log.debug("Sync isn't yet ready, so returning an empty tab list"); 175 return result; 176 } 177 178 // A boolean that controls whether we should show the icon from the remote tab. 179 const showRemoteIcons = Services.prefs.getBoolPref( 180 "services.sync.syncedTabs.showRemoteIcons", 181 true 182 ); 183 184 let engine = lazy.Weave.Service.engineManager.get("tabs"); 185 186 let ntabs = 0; 187 let clientTabList = await engine.getAllClients(); 188 for (let client of clientTabList) { 189 if (!lazy.Weave.Service.clientsEngine.remoteClientExists(client.id)) { 190 continue; 191 } 192 let clientRepr = await this._makeClient(client); 193 lazy.log.debug("Processing client", clientRepr); 194 195 let tabs = Array.from(client.tabs); // avoid modifying in-place. 196 // For QA, UX, etc, we allow "fake tabs" to be added to each device. 197 for (let i = 0; i < lazy.NUM_FAKE_INACTIVE_TABS; i++) { 198 tabs.push({ 199 icon: null, 200 lastUsed: 1000, 201 title: `Fake inactive tab ${i}`, 202 urlHistory: [`https://example.com/inactive/${i}`], 203 inactive: true, 204 }); 205 } 206 for (let i = 0; i < lazy.NUM_FAKE_ACTIVE_TABS; i++) { 207 tabs.push({ 208 icon: null, 209 lastUsed: Date.now() - 1000 + i, 210 title: `Fake tab ${i}`, 211 urlHistory: [`https://example.com/${i}`], 212 }); 213 } 214 215 for (let tab of tabs) { 216 let url = tab.urlHistory[0]; 217 lazy.log.trace("remote tab", url); 218 219 if (!url) { 220 continue; 221 } 222 let tabRepr = await this._makeTab(client, tab, url, showRemoteIcons); 223 if (filter && !this._tabMatchesFilter(tabRepr, filter)) { 224 continue; 225 } 226 clientRepr.tabs.push(tabRepr); 227 } 228 229 // Filter out duplicate tabs based on URL 230 clientRepr.tabs = this._filterRecentTabsDupes(clientRepr.tabs); 231 232 // We return all clients, even those without tabs - the consumer should 233 // filter it if they care. 234 ntabs += clientRepr.tabs.length; 235 result.push(clientRepr); 236 } 237 lazy.log.info( 238 `Final tab list has ${result.length} clients with ${ntabs} tabs.` 239 ); 240 return result; 241 }, 242 243 async syncTabs(force) { 244 if (!force) { 245 // Don't bother refetching tabs if we already did so recently 246 let lastFetch = Services.prefs.getIntPref( 247 "services.sync.lastTabFetch", 248 0 249 ); 250 let now = Math.floor(Date.now() / 1000); 251 if (now - lastFetch < TABS_FRESH_ENOUGH_INTERVAL_SECONDS) { 252 lazy.log.info("_refetchTabs was done recently, do not doing it again"); 253 return false; 254 } 255 } 256 257 // If Sync isn't configured don't try and sync, else we will get reports 258 // of a login failure. 259 if (lazy.Weave.Status.checkSetup() === lazy.CLIENT_NOT_CONFIGURED) { 260 lazy.log.info( 261 "Sync client is not configured, so not attempting a tab sync" 262 ); 263 return false; 264 } 265 // If the primary pass is locked, we should not try to sync 266 if (lazy.Weave.Utils.mpLocked()) { 267 lazy.log.info( 268 "Can't sync tabs due to the primary password being locked", 269 lazy.Weave.Status.login 270 ); 271 return false; 272 } 273 // Ask Sync to just do the tabs engine if it can. 274 try { 275 lazy.log.info("Doing a tab sync."); 276 await lazy.Weave.Service.sync({ why: "tabs", engines: ["tabs"] }); 277 return true; 278 } catch (ex) { 279 lazy.log.error("Sync failed", ex); 280 throw ex; 281 } 282 }, 283 284 observe(subject, topic, data) { 285 lazy.log.trace(`observed topic=${topic}, data=${data}, subject=${subject}`); 286 switch (topic) { 287 case "weave:engine:sync:finish": 288 if (data != "tabs") { 289 return; 290 } 291 // The tabs engine just finished syncing 292 // Set our lastTabFetch pref here so it tracks both explicit sync calls 293 // and normally scheduled ones. 294 Services.prefs.setIntPref( 295 "services.sync.lastTabFetch", 296 Math.floor(Date.now() / 1000) 297 ); 298 Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED); 299 break; 300 case "weave:service:start-over": 301 // start-over needs to notify so consumers find no tabs. 302 Services.prefs.clearUserPref("services.sync.lastTabFetch"); 303 Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED); 304 break; 305 case "nsPref:changed": 306 Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED); 307 break; 308 default: 309 break; 310 } 311 }, 312 313 // Returns true if Sync is configured to Sync tabs, false otherwise 314 get isConfiguredToSyncTabs() { 315 if (!lazy.weaveXPCService.ready) { 316 lazy.log.debug("Sync isn't yet ready; assuming tab engine is enabled"); 317 return true; 318 } 319 320 let engine = lazy.Weave.Service.engineManager.get("tabs"); 321 return engine && engine.enabled; 322 }, 323 324 get hasSyncedThisSession() { 325 let engine = lazy.Weave.Service.engineManager.get("tabs"); 326 return engine && engine.hasSyncedThisSession; 327 }, 328 }; 329 330 Services.obs.addObserver(SyncedTabsInternal, "weave:engine:sync:finish"); 331 Services.obs.addObserver(SyncedTabsInternal, "weave:service:start-over"); 332 // Observe the pref the indicates the state of the tabs engine has changed. 333 // This will force consumers to re-evaluate the state of sync and update 334 // accordingly. 335 Services.prefs.addObserver("services.sync.engine.tabs", SyncedTabsInternal); 336 337 // The public interface. 338 export var SyncedTabs = { 339 // A mock-point for tests. 340 _internal: SyncedTabsInternal, 341 342 // We make the topic for the observer notification public. 343 TOPIC_TABS_CHANGED, 344 345 // Expose the interval used to determine if synced tabs data needs a new sync 346 TABS_FRESH_ENOUGH_INTERVAL_SECONDS, 347 348 // Returns true if Sync is configured to Sync tabs, false otherwise 349 get isConfiguredToSyncTabs() { 350 return this._internal.isConfiguredToSyncTabs; 351 }, 352 353 // Returns true if a tab sync has completed once this session. If this 354 // returns false, then getting back no clients/tabs possibly just means we 355 // are waiting for that first sync to complete. 356 get hasSyncedThisSession() { 357 return this._internal.hasSyncedThisSession; 358 }, 359 360 // Return a promise that resolves with an array of client records, each with 361 // a .tabs array. Note that part of the contract for this module is that the 362 // returned objects are not shared between invocations, so callers are free 363 // to mutate the returned objects (eg, sort, truncate) however they see fit. 364 getTabClients(query) { 365 return this._internal.getTabClients(query); 366 }, 367 368 // Starts a background request to start syncing tabs. Returns a promise that 369 // resolves when the sync is complete, but there's no resolved value - 370 // callers should be listening for TOPIC_TABS_CHANGED. 371 // If |force| is true we always sync. If false, we only sync if the most 372 // recent sync wasn't "recently". 373 syncTabs(force) { 374 return this._internal.syncTabs(force); 375 }, 376 377 createRecentTabsList(clients, maxCount, extraParams) { 378 return this._internal._createRecentTabsList(clients, maxCount, extraParams); 379 }, 380 381 sortTabClientsByLastUsed(clients) { 382 // First sort the list of tabs for each client. Note that 383 // this module promises that the objects it returns are never 384 // shared, so we are free to mutate those objects directly. 385 for (let client of clients) { 386 let tabs = client.tabs; 387 tabs.sort((a, b) => b.lastUsed - a.lastUsed); 388 } 389 // Now sort the clients - the clients are sorted in the order of the 390 // most recent tab for that client (ie, it is important the tabs for 391 // each client are already sorted.) 392 clients.sort((a, b) => { 393 if (!a.tabs.length) { 394 return 1; // b comes first. 395 } 396 if (!b.tabs.length) { 397 return -1; // a comes first. 398 } 399 return b.tabs[0].lastUsed - a.tabs[0].lastUsed; 400 }); 401 }, 402 403 recordSyncedTabsTelemetry(object, tabEvent, extraOptions) { 404 if ( 405 !["fxa_avatar_menu", "fxa_app_menu", "synced_tabs_sidebar"].includes( 406 object 407 ) 408 ) { 409 return; 410 } 411 object = object 412 .split("_") 413 .map(word => word[0].toUpperCase() + word.slice(1)) 414 .join(""); 415 Glean.syncedTabs[tabEvent + object].record(extraOptions); 416 }, 417 418 // Get list of synced tabs across all devices/clients 419 // truncated by value of maxCount param, sorted by 420 // lastUsed value, and filtered for duplicate URLs 421 async getRecentTabs(maxCount, extraParams) { 422 let clients = await this.getTabClients(); 423 return this._internal._createRecentTabsList(clients, maxCount, extraParams); 424 }, 425 }; 426 427 // Remote tab management public interface. 428 export var SyncedTabsManagement = { 429 // A mock-point for tests. 430 async _getStore() { 431 return await lazy.getRemoteCommandStore(); 432 }, 433 434 /// Enqueue a tab to close on a remote device. 435 async enqueueTabToClose(deviceId, url) { 436 let store = await this._getStore(); 437 let command = new lazy.RemoteCommand.CloseTab({ url }); 438 if (!store.addRemoteCommand(deviceId, command)) { 439 lazy.log.warn( 440 "Could not queue a remote tab close - it was already queued" 441 ); 442 } else { 443 lazy.log.info("Queued remote tab close command."); 444 } 445 // fxAccounts commands infrastructure is lazily initialized, at which point 446 // it registers observers etc - make sure it's initialized; 447 lazy.FxAccounts.commands; 448 Services.obs.notifyObservers(null, TOPIC_TABS_COMMAND_QUEUED); 449 }, 450 451 /// Remove a tab from the queue of commands for a remote device. 452 async removePendingTabToClose(deviceId, url) { 453 let store = await this._getStore(); 454 let command = new lazy.RemoteCommand.CloseTab({ url }); 455 if (!store.removeRemoteCommand(deviceId, command)) { 456 lazy.log.warn("Could not remove a remote tab close - it was not queued"); 457 } else { 458 lazy.log.info("Removed queued remote tab close command."); 459 } 460 }, 461 };