summaryrefslogtreecommitdiff
path: root/services/sync/modules/SyncedTabs.jsm
blob: 1a69e356440e56f69d2f9fdd89ba6c63e8c00d97 (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
/* 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/. */

"use strict";

this.EXPORTED_SYMBOLS = ["SyncedTabs"];


const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;

Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://gre/modules/PlacesUtils.jsm", this);
Cu.import("resource://services-sync/main.js");
Cu.import("resource://gre/modules/Preferences.jsm");

// The Sync XPCOM service
XPCOMUtils.defineLazyGetter(this, "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 = 30;

let log = Log.repository.getLogger("Sync.RemoteTabs");
// A new scope to do the logging thang...
(function() {
  let level = Preferences.get("services.sync.log.logger.tabs");
  if (level) {
    let appender = new Log.DumpAppender();
    log.level = appender.level = Log.Level[level] || Log.Level.Debug;
    log.addAppender(appender);
  }
})();


// A private singleton that does the work.
let SyncedTabsInternal = {
  /* Make a "tab" record. Returns a promise */
  _makeTab: Task.async(function* (client, tab, url, showRemoteIcons) {
    let icon;
    if (showRemoteIcons) {
      icon = tab.icon;
    }
    if (!icon) {
      try {
        icon = (yield PlacesUtils.promiseFaviconLinkUrl(url)).spec;
      } catch (ex) { /* no favicon avaiable */ }
    }
    if (!icon) {
      icon = "";
    }
    return {
      type:  "tab",
      title: tab.title || url,
      url,
      icon,
      client: client.id,
      lastUsed: tab.lastUsed,
    };
  }),

  /* Make a "client" record. Returns a promise for consistency with _makeTab */
  _makeClient: Task.async(function* (client) {
    return {
      id: client.id,
      type: "client",
      name: Weave.Service.clientsEngine.getClientName(client.id),
      isMobile: Weave.Service.clientsEngine.isMobile(client.id),
      lastModified: client.lastModified * 1000, // sec to ms
      tabs: []
    };
  }),

  _tabMatchesFilter(tab, filter) {
    let reFilter = new RegExp(escapeRegExp(filter), "i");
    return tab.url.match(reFilter) || tab.title.match(reFilter);
  },

  getTabClients: Task.async(function* (filter) {
    log.info("Generating tab list with filter", filter);
    let result = [];

    // If Sync isn't ready, don't try and get anything.
    if (!weaveXPCService.ready) {
      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 = Preferences.get("services.sync.syncedTabs.showRemoteIcons", true);

    let engine = Weave.Service.engineManager.get("tabs");

    let seenURLs = new Set();
    let parentIndex = 0;
    let ntabs = 0;

    for (let [guid, client] of Object.entries(engine.getAllClients())) {
      if (!Weave.Service.clientsEngine.remoteClientExists(client.id)) {
        continue;
      }
      let clientRepr = yield this._makeClient(client);
      log.debug("Processing client", clientRepr);

      for (let tab of client.tabs) {
        let url = tab.urlHistory[0];
        log.debug("remote tab", url);
        // Note there are some issues with tracking "seen" tabs, including:
        // * We really can't return the entire urlHistory record as we are
        //   only checking the first entry - others might be different.
        // * We don't update the |lastUsed| timestamp to reflect the
        //   most-recently-seen time.
        // In a followup we should consider simply dropping this |seenUrls|
        // check and return duplicate records - it seems the user will be more
        // confused by tabs not showing up on a device (because it was detected
        // as a dupe so it only appears on a different device) than being
        // confused by seeing the same tab on different clients.
        if (!url || seenURLs.has(url)) {
          continue;
        }
        let tabRepr = yield this._makeTab(client, tab, url, showRemoteIcons);
        if (filter && !this._tabMatchesFilter(tabRepr, filter)) {
          continue;
        }
        seenURLs.add(url);
        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);
    }
    log.info(`Final tab list has ${result.length} clients with ${ntabs} tabs.`);
    return result;
  }),

  syncTabs(force) {
    if (!force) {
      // Don't bother refetching tabs if we already did so recently
      let lastFetch = Preferences.get("services.sync.lastTabFetch", 0);
      let now = Math.floor(Date.now() / 1000);
      if (now - lastFetch < TABS_FRESH_ENOUGH_INTERVAL) {
        log.info("_refetchTabs was done recently, do not doing it again");
        return Promise.resolve(false);
      }
    }

    // If Sync isn't configured don't try and sync, else we will get reports
    // of a login failure.
    if (Weave.Status.checkSetup() == Weave.CLIENT_NOT_CONFIGURED) {
      log.info("Sync client is not configured, so not attempting a tab sync");
      return Promise.resolve(false);
    }
    // Ask Sync to just do the tabs engine if it can.
    // Sync is currently synchronous, so do it after an event-loop spin to help
    // keep the UI responsive.
    return new Promise((resolve, reject) => {
      Services.tm.currentThread.dispatch(() => {
        try {
          log.info("Doing a tab sync.");
          Weave.Service.sync(["tabs"]);
          resolve(true);
        } catch (ex) {
          log.error("Sync failed", ex);
          reject(ex);
        };
      }, Ci.nsIThread.DISPATCH_NORMAL);
    });
  },

  observe(subject, topic, data) {
    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.
        Preferences.set("services.sync.lastTabFetch", Math.floor(Date.now() / 1000));
        Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED, null);
        break;
      case "weave:service:start-over":
        // start-over needs to notify so consumers find no tabs.
        Preferences.reset("services.sync.lastTabFetch");
        Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED, null);
        break;
      case "nsPref:changed":
        Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED, null);
        break;
      default:
        break;
    }
  },

  // Returns true if Sync is configured to Sync tabs, false otherwise
  get isConfiguredToSyncTabs() {
    if (!weaveXPCService.ready) {
      log.debug("Sync isn't yet ready; assuming tab engine is enabled");
      return true;
    }

    let engine = Weave.Service.engineManager.get("tabs");
    return engine && engine.enabled;
  },

  get hasSyncedThisSession() {
    let engine = Weave.Service.engineManager.get("tabs");
    return engine && engine.hasSyncedThisSession;
  },
};

Services.obs.addObserver(SyncedTabsInternal, "weave:engine:sync:finish", false);
Services.obs.addObserver(SyncedTabsInternal, "weave:service:start-over", false);
// 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, false);

// The public interface.
this.SyncedTabs = {
  // A mock-point for tests.
  _internal: SyncedTabsInternal,

  // We make the topic for the observer notification public.
  TOPIC_TABS_CHANGED,

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

  sortTabClientsByLastUsed(clients, maxTabs = Infinity) {
    // First sort and filter 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);
      if (Number.isFinite(maxTabs)) {
        client.tabs = tabs.slice(0, maxTabs);
      }
    }
    // 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 == 0) {
        return 1; // b comes first.
      }
      if (b.tabs.length == 0) {
        return -1; // a comes first.
      }
      return b.tabs[0].lastUsed - a.tabs[0].lastUsed;
    });
  },
};