summaryrefslogtreecommitdiffstats
path: root/services/sync/modules/SyncedTabs.sys.mjs
blob: 058525995b489a9b27c1de577cddfe61fe55719b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
/* 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",
});

// The Sync XPCOM service
ChromeUtils.defineLazyGetter(lazy, "weaveXPCService", function () {
  return Cc["@mozilla.org/weave/service;1"].getService(
    Ci.nsISupports
  ).wrappedJSObject;
});

// 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";

// 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);
  },

  _createRecentTabsList(
    clients,
    maxCount,
    extraParams = { removeAllDupes: true, removeDeviceDupes: false }
  ) {
    let tabs = [];

    for (let client of clients) {
      if (extraParams.removeDeviceDupes) {
        client.tabs = this._filterRecentTabsDupes(client.tabs);
      }
      for (let tab of client.tabs) {
        tab.device = client.name;
        tab.deviceType = client.clientType;
      }
      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;
  },

  _filterRecentTabsDupes(tabs) {
    // Filter out any tabs with duplicate URLs preserving
    // the duplicate with the most recent lastUsed value
    return tabs.filter(tab => {
      return !tabs.some(t => {
        return t.url === tab.url && tab.lastUsed < t.lastUsed;
      });
    });
  },

  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);
      }
      // 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) {
    Services.telemetry.setEventRecordingEnabled("synced_tabs", true);
    Services.telemetry.recordEvent(
      "synced_tabs",
      tabEvent,
      object,
      null,
      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);
  },
};