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"use strict";
6
7var EXPORTED_SYMBOLS = ["SyncedTabs"];
8
9const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
10const { XPCOMUtils } = ChromeUtils.import(
11  "resource://gre/modules/XPCOMUtils.jsm"
12);
13const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm");
14const { Weave } = ChromeUtils.import("resource://services-sync/main.js");
15const { Preferences } = ChromeUtils.import(
16  "resource://gre/modules/Preferences.jsm"
17);
18
19// The Sync XPCOM service
20XPCOMUtils.defineLazyGetter(this, "weaveXPCService", function() {
21  return Cc["@mozilla.org/weave/service;1"].getService(
22    Ci.nsISupports
23  ).wrappedJSObject;
24});
25
26// from MDN...
27function escapeRegExp(string) {
28  return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29}
30
31// A topic we fire whenever we have new tabs available. This might be due
32// to a request made by this module to refresh the tab list, or as the result
33// of a regularly scheduled sync. The intent is that consumers just listen
34// for this notification and update their UI in response.
35const TOPIC_TABS_CHANGED = "services.sync.tabs.changed";
36
37// The interval, in seconds, before which we consider the existing list
38// of tabs "fresh enough" and don't force a new sync.
39const TABS_FRESH_ENOUGH_INTERVAL = 30;
40
41XPCOMUtils.defineLazyGetter(this, "log", function() {
42  let log = Log.repository.getLogger("Sync.RemoteTabs");
43  log.manageLevelFromPref("services.sync.log.logger.tabs");
44  return log;
45});
46
47// A private singleton that does the work.
48let SyncedTabsInternal = {
49  /* Make a "tab" record. Returns a promise */
50  async _makeTab(client, tab, url, showRemoteIcons) {
51    let icon;
52    if (showRemoteIcons) {
53      icon = tab.icon;
54    }
55    if (!icon) {
56      // By not specifying a size the favicon service will pick the default,
57      // that is usually set through setDefaultIconURIPreferredSize by the
58      // first browser window. Commonly it's 16px at current dpi.
59      icon = "page-icon:" + url;
60    }
61    return {
62      type: "tab",
63      title: tab.title || url,
64      url,
65      icon,
66      client: client.id,
67      lastUsed: tab.lastUsed,
68    };
69  },
70
71  /* Make a "client" record. Returns a promise for consistency with _makeTab */
72  async _makeClient(client) {
73    return {
74      id: client.id,
75      type: "client",
76      name: Weave.Service.clientsEngine.getClientName(client.id),
77      clientType: Weave.Service.clientsEngine.getClientType(client.id),
78      lastModified: client.lastModified * 1000, // sec to ms
79      tabs: [],
80    };
81  },
82
83  _tabMatchesFilter(tab, filter) {
84    let reFilter = new RegExp(escapeRegExp(filter), "i");
85    return reFilter.test(tab.url) || reFilter.test(tab.title);
86  },
87
88  async getTabClients(filter) {
89    log.info("Generating tab list with filter", filter);
90    let result = [];
91
92    // If Sync isn't ready, don't try and get anything.
93    if (!weaveXPCService.ready) {
94      log.debug("Sync isn't yet ready, so returning an empty tab list");
95      return result;
96    }
97
98    // A boolean that controls whether we should show the icon from the remote tab.
99    const showRemoteIcons = Preferences.get(
100      "services.sync.syncedTabs.showRemoteIcons",
101      true
102    );
103
104    let engine = Weave.Service.engineManager.get("tabs");
105
106    let ntabs = 0;
107
108    for (let client of Object.values(engine.getAllClients())) {
109      if (!Weave.Service.clientsEngine.remoteClientExists(client.id)) {
110        continue;
111      }
112      let clientRepr = await this._makeClient(client);
113      log.debug("Processing client", clientRepr);
114
115      for (let tab of client.tabs) {
116        let url = tab.urlHistory[0];
117        log.trace("remote tab", url);
118
119        if (!url) {
120          continue;
121        }
122        let tabRepr = await this._makeTab(client, tab, url, showRemoteIcons);
123        if (filter && !this._tabMatchesFilter(tabRepr, filter)) {
124          continue;
125        }
126        clientRepr.tabs.push(tabRepr);
127      }
128      // We return all clients, even those without tabs - the consumer should
129      // filter it if they care.
130      ntabs += clientRepr.tabs.length;
131      result.push(clientRepr);
132    }
133    log.info(`Final tab list has ${result.length} clients with ${ntabs} tabs.`);
134    return result;
135  },
136
137  async syncTabs(force) {
138    if (!force) {
139      // Don't bother refetching tabs if we already did so recently
140      let lastFetch = Preferences.get("services.sync.lastTabFetch", 0);
141      let now = Math.floor(Date.now() / 1000);
142      if (now - lastFetch < TABS_FRESH_ENOUGH_INTERVAL) {
143        log.info("_refetchTabs was done recently, do not doing it again");
144        return false;
145      }
146    }
147
148    // If Sync isn't configured don't try and sync, else we will get reports
149    // of a login failure.
150    if (Weave.Status.checkSetup() == Weave.CLIENT_NOT_CONFIGURED) {
151      log.info("Sync client is not configured, so not attempting a tab sync");
152      return false;
153    }
154    // Ask Sync to just do the tabs engine if it can.
155    try {
156      log.info("Doing a tab sync.");
157      await Weave.Service.sync({ why: "tabs", engines: ["tabs"] });
158      return true;
159    } catch (ex) {
160      log.error("Sync failed", ex);
161      throw ex;
162    }
163  },
164
165  observe(subject, topic, data) {
166    log.trace(`observed topic=${topic}, data=${data}, subject=${subject}`);
167    switch (topic) {
168      case "weave:engine:sync:finish":
169        if (data != "tabs") {
170          return;
171        }
172        // The tabs engine just finished syncing
173        // Set our lastTabFetch pref here so it tracks both explicit sync calls
174        // and normally scheduled ones.
175        Preferences.set(
176          "services.sync.lastTabFetch",
177          Math.floor(Date.now() / 1000)
178        );
179        Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED);
180        break;
181      case "weave:service:start-over":
182        // start-over needs to notify so consumers find no tabs.
183        Preferences.reset("services.sync.lastTabFetch");
184        Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED);
185        break;
186      case "nsPref:changed":
187        Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED);
188        break;
189      default:
190        break;
191    }
192  },
193
194  // Returns true if Sync is configured to Sync tabs, false otherwise
195  get isConfiguredToSyncTabs() {
196    if (!weaveXPCService.ready) {
197      log.debug("Sync isn't yet ready; assuming tab engine is enabled");
198      return true;
199    }
200
201    let engine = Weave.Service.engineManager.get("tabs");
202    return engine && engine.enabled;
203  },
204
205  get hasSyncedThisSession() {
206    let engine = Weave.Service.engineManager.get("tabs");
207    return engine && engine.hasSyncedThisSession;
208  },
209};
210
211Services.obs.addObserver(SyncedTabsInternal, "weave:engine:sync:finish");
212Services.obs.addObserver(SyncedTabsInternal, "weave:service:start-over");
213// Observe the pref the indicates the state of the tabs engine has changed.
214// This will force consumers to re-evaluate the state of sync and update
215// accordingly.
216Services.prefs.addObserver("services.sync.engine.tabs", SyncedTabsInternal);
217
218// The public interface.
219var SyncedTabs = {
220  // A mock-point for tests.
221  _internal: SyncedTabsInternal,
222
223  // We make the topic for the observer notification public.
224  TOPIC_TABS_CHANGED,
225
226  // Returns true if Sync is configured to Sync tabs, false otherwise
227  get isConfiguredToSyncTabs() {
228    return this._internal.isConfiguredToSyncTabs;
229  },
230
231  // Returns true if a tab sync has completed once this session. If this
232  // returns false, then getting back no clients/tabs possibly just means we
233  // are waiting for that first sync to complete.
234  get hasSyncedThisSession() {
235    return this._internal.hasSyncedThisSession;
236  },
237
238  // Return a promise that resolves with an array of client records, each with
239  // a .tabs array. Note that part of the contract for this module is that the
240  // returned objects are not shared between invocations, so callers are free
241  // to mutate the returned objects (eg, sort, truncate) however they see fit.
242  getTabClients(query) {
243    return this._internal.getTabClients(query);
244  },
245
246  // Starts a background request to start syncing tabs. Returns a promise that
247  // resolves when the sync is complete, but there's no resolved value -
248  // callers should be listening for TOPIC_TABS_CHANGED.
249  // If |force| is true we always sync. If false, we only sync if the most
250  // recent sync wasn't "recently".
251  syncTabs(force) {
252    return this._internal.syncTabs(force);
253  },
254
255  sortTabClientsByLastUsed(clients) {
256    // First sort the list of tabs for each client. Note that
257    // this module promises that the objects it returns are never
258    // shared, so we are free to mutate those objects directly.
259    for (let client of clients) {
260      let tabs = client.tabs;
261      tabs.sort((a, b) => b.lastUsed - a.lastUsed);
262    }
263    // Now sort the clients - the clients are sorted in the order of the
264    // most recent tab for that client (ie, it is important the tabs for
265    // each client are already sorted.)
266    clients.sort((a, b) => {
267      if (a.tabs.length == 0) {
268        return 1; // b comes first.
269      }
270      if (b.tabs.length == 0) {
271        return -1; // a comes first.
272      }
273      return b.tabs[0].lastUsed - a.tabs[0].lastUsed;
274    });
275  },
276};
277