summaryrefslogtreecommitdiffstats
path: root/services/sync/modules/engines/tabs.sys.mjs
blob: 45242b71a7e0b795958563b9d736428e11d7e228 (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
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
/* 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/. */

const STORAGE_VERSION = 1; // This needs to be kept in-sync with the rust storage version

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { SyncEngine, Tracker } from "resource://services-sync/engines.sys.mjs";
import { Svc, Utils } from "resource://services-sync/util.sys.mjs";
import { Log } from "resource://gre/modules/Log.sys.mjs";
import {
  SCORE_INCREMENT_SMALL,
  STATUS_OK,
  URI_LENGTH_MAX,
} from "resource://services-sync/constants.sys.mjs";
import { CommonUtils } from "resource://services-common/utils.sys.mjs";
import { Async } from "resource://services-common/async.sys.mjs";
import {
  SyncRecord,
  SyncTelemetry,
} from "resource://services-sync/telemetry.sys.mjs";
import { BridgedEngine } from "resource://services-sync/bridged_engine.sys.mjs";

const FAR_FUTURE = 4102405200000; // 2100/01/01

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
  ReaderMode: "resource://gre/modules/ReaderMode.sys.mjs",
  TabsStore: "resource://gre/modules/RustTabs.sys.mjs",
});

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "TABS_FILTERED_SCHEMES",
  "services.sync.engine.tabs.filteredSchemes",
  "",
  null,
  val => {
    return new Set(val.split("|"));
  }
);

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "SYNC_AFTER_DELAY_MS",
  "services.sync.syncedTabs.syncDelayAfterTabChange",
  0
);

// A "bridged engine" to our tabs component.
export function TabEngine(service) {
  BridgedEngine.call(this, "Tabs", service);
}

TabEngine.prototype = {
  _trackerObj: TabTracker,
  syncPriority: 3,

  async prepareTheBridge(isQuickWrite) {
    let clientsEngine = this.service.clientsEngine;
    // Tell the bridged engine about clients.
    // This is the same shape as ClientData in app-services.
    // schema: https://github.com/mozilla/application-services/blob/a1168751231ed4e88c44d85f6dccc09c3b412bd2/components/sync15/src/client_types.rs#L14
    let clientData = {
      local_client_id: clientsEngine.localID,
      recent_clients: {},
    };

    // We shouldn't upload tabs past what the server will accept
    let tabs = await this.getTabsWithinPayloadSize();
    await this._rustStore.setLocalTabs(
      tabs.map(tab => {
        // rust wants lastUsed in MS but the provider gives it in seconds
        tab.lastUsed = tab.lastUsed * 1000;
        return tab;
      })
    );

    for (let remoteClient of clientsEngine.remoteClients) {
      let id = remoteClient.id;
      if (!id) {
        throw new Error("Remote client somehow did not have an id");
      }
      let client = {
        fxa_device_id: remoteClient.fxaDeviceId,
        // device_name and device_type are soft-deprecated - every client
        // prefers what's in the FxA record. But fill them correctly anyway.
        device_name: clientsEngine.getClientName(id) ?? "",
        device_type: clientsEngine.getClientType(id),
      };
      clientData.recent_clients[id] = client;
    }

    // put ourself in there too so we record the correct device info in our sync record.
    clientData.recent_clients[clientsEngine.localID] = {
      fxa_device_id: await clientsEngine.fxAccounts.device.getLocalId(),
      device_name: clientsEngine.localName,
      device_type: clientsEngine.localType,
    };

    // Quick write needs to adjust the lastSync so we can POST to the server
    // see quickWrite() for details
    if (isQuickWrite) {
      await this.setLastSync(FAR_FUTURE);
      await this._bridge.prepareForSync(JSON.stringify(clientData));
      return;
    }

    // Just incase we crashed while the lastSync timestamp was FAR_FUTURE, we
    // reset it to zero
    if ((await this.getLastSync()) === FAR_FUTURE) {
      await this._bridge.setLastSync(0);
    }
    await this._bridge.prepareForSync(JSON.stringify(clientData));
  },

  async _syncStartup() {
    await super._syncStartup();
    await this.prepareTheBridge();
  },

  async initialize() {
    await SyncEngine.prototype.initialize.call(this);

    let path = PathUtils.join(PathUtils.profileDir, "synced-tabs.db");
    this._rustStore = await lazy.TabsStore.init(path);
    this._bridge = await this._rustStore.bridgedEngine();

    // Uniffi doesn't currently only support async methods, so we'll need to hardcode
    // these values for now (which is fine for now as these hardly ever change)
    this._bridge.storageVersion = STORAGE_VERSION;
    this._bridge.allowSkippedRecord = true;

    this._log.info("Got a bridged engine!");
    this._tracker.modified = true;
  },

  async getChangedIDs() {
    // No need for a proper timestamp (no conflict resolution needed).
    let changedIDs = {};
    if (this._tracker.modified) {
      changedIDs[this.service.clientsEngine.localID] = 0;
    }
    return changedIDs;
  },

  // API for use by Sync UI code to give user choices of tabs to open.
  async getAllClients() {
    let remoteTabs = await this._rustStore.getAll();
    let remoteClientTabs = [];
    for (let remoteClient of this.service.clientsEngine.remoteClients) {
      // We get the some client info from the rust tabs engine and some from
      // the clients engine.
      let rustClient = remoteTabs.find(
        x => x.clientId === remoteClient.fxaDeviceId
      );
      if (!rustClient) {
        continue;
      }
      let client = {
        // rust gives us ms but js uses seconds, so fix them up.
        tabs: rustClient.remoteTabs.map(tab => {
          tab.lastUsed = tab.lastUsed / 1000;
          return tab;
        }),
        lastModified: rustClient.lastModified / 1000,
        ...remoteClient,
      };
      remoteClientTabs.push(client);
    }
    return remoteClientTabs;
  },

  async removeClientData() {
    let url = this.engineURL + "/" + this.service.clientsEngine.localID;
    await this.service.resource(url).delete();
  },

  async trackRemainingChanges() {
    if (this._modified.count() > 0) {
      this._tracker.modified = true;
    }
  },

  async getTabsWithinPayloadSize() {
    const maxPayloadSize = this.service.getMaxRecordPayloadSize();
    // See bug 535326 comment 8 for an explanation of the estimation
    const maxSerializedSize = (maxPayloadSize / 4) * 3 - 1500;
    return TabProvider.getAllTabsWithEstimatedMax(true, maxSerializedSize);
  },

  // Support for "quick writes"
  _engineLock: Utils.lock,
  _engineLocked: false,

  // Tabs has a special lock to help support its "quick write"
  get locked() {
    return this._engineLocked;
  },
  lock() {
    if (this._engineLocked) {
      return false;
    }
    this._engineLocked = true;
    return true;
  },
  unlock() {
    this._engineLocked = false;
  },

  // Quickly do a POST of our current tabs if possible.
  // This does things that would be dangerous for other engines - eg, posting
  // without checking what's on the server could cause data-loss for other
  // engines, but because each device exclusively owns exactly 1 tabs record
  // with a known ID, it's safe here.
  // Returns true if we successfully synced, false otherwise (either on error
  // or because we declined to sync for any reason.) The return value is
  // primarily for tests.
  async quickWrite() {
    if (!this.enabled) {
      // this should be very rare, and only if tabs are disabled after the
      // timer is created.
      this._log.info("Can't do a quick-sync as tabs is disabled");
      return false;
    }
    // This quick-sync doesn't drive the login state correctly, so just
    // decline to sync if out status is bad
    if (this.service.status.checkSetup() != STATUS_OK) {
      this._log.info(
        "Can't do a quick-sync due to the service status",
        this.service.status.toString()
      );
      return false;
    }
    if (!this.service.serverConfiguration) {
      this._log.info("Can't do a quick sync before the first full sync");
      return false;
    }
    try {
      return await this._engineLock("tabs.js: quickWrite", async () => {
        // We want to restore the lastSync timestamp when complete so next sync
        // takes tabs written by other devices since our last real sync.
        // And for this POST we don't want the protections offered by
        // X-If-Unmodified-Since - we want the POST to work even if the remote
        // has moved on and we will catch back up next full sync.
        const origLastSync = await this.getLastSync();
        try {
          return this._doQuickWrite();
        } finally {
          // set the lastSync to it's original value for regular sync
          await this.setLastSync(origLastSync);
        }
      })();
    } catch (ex) {
      if (!Utils.isLockException(ex)) {
        throw ex;
      }
      this._log.info(
        "Can't do a quick-write as another tab sync is in progress"
      );
      return false;
    }
  },

  // The guts of the quick-write sync, after we've taken the lock, checked
  // the service status etc.
  async _doQuickWrite() {
    // We need to track telemetry for these syncs too!
    const name = "tabs";
    let telemetryRecord = new SyncRecord(
      SyncTelemetry.allowedEngines,
      "quick-write"
    );
    telemetryRecord.onEngineStart(name);
    try {
      Async.checkAppReady();
      // We need to prep the bridge before we try to POST since it grabs
      // the most recent local client id and properly sets a lastSync
      // which is needed for a proper POST request
      await this.prepareTheBridge(true);
      this._tracker.clearChangedIDs();
      this._tracker.resetScore();

      Async.checkAppReady();
      // now just the "upload" part of a sync,
      // which for a rust engine is  not obvious.
      // We need to do is ask the rust engine for the changes. Although
      // this is kinda abusing the bridged-engine interface, we know the tabs
      // implementation of it works ok
      let outgoing = await this._bridge.apply();
      // We know we always have exactly 1 record.
      let mine = outgoing[0];
      this._log.trace("outgoing bso", mine);
      // `this._recordObj` is a `BridgedRecord`, which isn't exported.
      let record = this._recordObj.fromOutgoingBso(this.name, JSON.parse(mine));
      let changeset = {};
      changeset[record.id] = { synced: false, record };
      this._modified.replace(changeset);

      Async.checkAppReady();
      await this._uploadOutgoing();
      telemetryRecord.onEngineStop(name, null);
      return true;
    } catch (ex) {
      this._log.warn("quicksync sync failed", ex);
      telemetryRecord.onEngineStop(name, ex);
      return false;
    } finally {
      // The top-level sync is never considered to fail here, just the engine
      telemetryRecord.finished(null);
      SyncTelemetry.takeTelemetryRecord(telemetryRecord);
    }
  },

  async _sync() {
    try {
      await this._engineLock("tabs.js: fullSync", async () => {
        await super._sync();
      })();
    } catch (ex) {
      if (!Utils.isLockException(ex)) {
        throw ex;
      }
      this._log.info(
        "Can't do full tabs sync as a quick-write is currently running"
      );
    }
  },
};
Object.setPrototypeOf(TabEngine.prototype, BridgedEngine.prototype);

export const TabProvider = {
  getWindowEnumerator() {
    return Services.wm.getEnumerator("navigator:browser");
  },

  shouldSkipWindow(win) {
    return win.closed || lazy.PrivateBrowsingUtils.isWindowPrivate(win);
  },

  getAllBrowserTabs() {
    let tabs = [];
    for (let win of this.getWindowEnumerator()) {
      if (this.shouldSkipWindow(win)) {
        continue;
      }
      // Get all the tabs from the browser
      for (let tab of win.gBrowser.tabs) {
        tabs.push(tab);
      }
    }

    return tabs.sort(function (a, b) {
      return b.lastAccessed - a.lastAccessed;
    });
  },

  // This function creates tabs records up to a specified amount of bytes
  // It is an "estimation" since we don't accurately calculate how much the
  // favicon and JSON overhead is and give a rough estimate (for optimization purposes)
  async getAllTabsWithEstimatedMax(filter, bytesMax) {
    let log = Log.repository.getLogger(`Sync.Engine.Tabs.Provider`);
    let tabRecords = [];
    let iconPromises = [];
    let runningByteLength = 0;
    let encoder = new TextEncoder();

    // Fetch all the tabs the user has open
    let winTabs = this.getAllBrowserTabs();

    for (let tab of winTabs) {
      // We don't want to process any more tabs than we can sync
      if (runningByteLength >= bytesMax) {
        log.warn(
          `Can't fit all tabs in sync payload: have ${winTabs.length},
             but can only fit ${tabRecords.length}.`
        );
        break;
      }

      // Note that we used to sync "tab history" (ie, the "back button") state,
      // but in practice this hasn't been used - only the current URI is of
      // interest to clients.
      // We stopped recording this in bug 1783991.
      if (!tab?.linkedBrowser) {
        continue;
      }
      let acceptable = !filter
        ? url => url
        : url =>
            url &&
            !lazy.TABS_FILTERED_SCHEMES.has(Services.io.extractScheme(url));

      let url = tab.linkedBrowser.currentURI?.spec;
      // Special case for reader mode.
      if (url && url.startsWith("about:reader?")) {
        url = lazy.ReaderMode.getOriginalUrl(url);
      }
      // We ignore the tab completely if the current entry url is
      // not acceptable (we need something accurate to open).
      if (!acceptable(url)) {
        continue;
      }

      if (url.length > URI_LENGTH_MAX) {
        log.trace("Skipping over-long URL.");
        continue;
      }

      let thisTab = {
        title: tab.linkedBrowser.contentTitle || "",
        urlHistory: [url],
        icon: "",
        lastUsed: Math.floor((tab.lastAccessed || 0) / 1000),
      };
      tabRecords.push(thisTab);

      // we don't want to wait for each favicon to resolve to get the bytes
      // so we estimate a conservative 100 chars for the favicon and json overhead
      // Rust will further optimize and trim if we happened to be wildly off
      runningByteLength +=
        encoder.encode(thisTab.title + thisTab.lastUsed + url).byteLength + 100;

      // Use the favicon service for the icon url - we can wait for the promises at the end.
      let iconPromise = lazy.PlacesUtils.promiseFaviconData(url)
        .then(iconData => {
          thisTab.icon = iconData.uri.spec;
        })
        .catch(ex => {
          log.trace(
            `Failed to fetch favicon for ${url}`,
            thisTab.urlHistory[0]
          );
        });
      iconPromises.push(iconPromise);
    }

    await Promise.allSettled(iconPromises);
    return tabRecords;
  },
};

function TabTracker(name, engine) {
  Tracker.call(this, name, engine);

  // Make sure "this" pointer is always set correctly for event listeners.
  this.onTab = Utils.bind2(this, this.onTab);
  this._unregisterListeners = Utils.bind2(this, this._unregisterListeners);
}
TabTracker.prototype = {
  QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),

  clearChangedIDs() {
    this.modified = false;
  },

  // We do not track TabSelect because that almost always triggers
  // the web progress listeners (onLocationChange), which we already track
  _topics: ["TabOpen", "TabClose"],

  _registerListenersForWindow(window) {
    this._log.trace("Registering tab listeners in window");
    for (let topic of this._topics) {
      window.addEventListener(topic, this.onTab);
    }
    window.addEventListener("unload", this._unregisterListeners);
    // If it's got a tab browser we can listen for things like navigation.
    if (window.gBrowser) {
      window.gBrowser.addProgressListener(this);
    }
  },

  _unregisterListeners(event) {
    this._unregisterListenersForWindow(event.target);
  },

  _unregisterListenersForWindow(window) {
    this._log.trace("Removing tab listeners in window");
    window.removeEventListener("unload", this._unregisterListeners);
    for (let topic of this._topics) {
      window.removeEventListener(topic, this.onTab);
    }
    if (window.gBrowser) {
      window.gBrowser.removeProgressListener(this);
    }
  },

  onStart() {
    Svc.Obs.add("domwindowopened", this.asyncObserver);
    for (let win of Services.wm.getEnumerator("navigator:browser")) {
      this._registerListenersForWindow(win);
    }
  },

  onStop() {
    Svc.Obs.remove("domwindowopened", this.asyncObserver);
    for (let win of Services.wm.getEnumerator("navigator:browser")) {
      this._unregisterListenersForWindow(win);
    }
  },

  async observe(subject, topic, data) {
    switch (topic) {
      case "domwindowopened":
        let onLoad = () => {
          subject.removeEventListener("load", onLoad);
          // Only register after the window is done loading to avoid unloads.
          this._registerListenersForWindow(subject);
        };

        // Add tab listeners now that a window has opened.
        subject.addEventListener("load", onLoad);
        break;
    }
  },

  onTab(event) {
    if (event.originalTarget.linkedBrowser) {
      let browser = event.originalTarget.linkedBrowser;
      if (
        lazy.PrivateBrowsingUtils.isBrowserPrivate(browser) &&
        !lazy.PrivateBrowsingUtils.permanentPrivateBrowsing
      ) {
        this._log.trace("Ignoring tab event from private browsing.");
        return;
      }
    }
    this._log.trace("onTab event: " + event.type);

    switch (event.type) {
      case "TabOpen":
        /* We do not have a reliable way of checking the URI on the TabOpen
         * so we will rely on the other methods (onLocationChange, getAllTabsWithEstimatedMax)
         * to filter these when going through sync
         */
        this.callScheduleSync(SCORE_INCREMENT_SMALL);
        break;
      case "TabClose":
        // If event target has `linkedBrowser`, the event target can be assumed <tab> element.
        // Else, event target is assumed <browser> element, use the target as it is.
        const tab = event.target.linkedBrowser || event.target;

        // TabClose means the tab has already loaded and we can check the URI
        // and ignore if it's a scheme we don't care about
        if (lazy.TABS_FILTERED_SCHEMES.has(tab.currentURI.scheme)) {
          return;
        }
        this.callScheduleSync(SCORE_INCREMENT_SMALL);
        break;
    }
  },

  // web progress listeners.
  onLocationChange(webProgress, request, locationURI, flags) {
    // We only care about top-level location changes. We do want location changes in the
    // same document because if a page uses the `pushState()` API, they *appear* as though
    // they are in the same document even if the URL changes. It also doesn't hurt to accurately
    // reflect the fragment changing - so we allow LOCATION_CHANGE_SAME_DOCUMENT
    if (
      flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_RELOAD ||
      !webProgress.isTopLevel ||
      !locationURI
    ) {
      return;
    }

    // We can't filter out tabs that we don't sync here, because we might be
    // navigating from a tab that we *did* sync to one we do not, and that
    // tab we *did* sync should no longer be synced.
    this.callScheduleSync();
  },

  callScheduleSync(scoreIncrement) {
    this.modified = true;
    let { scheduler } = this.engine.service;
    let delayInMs = lazy.SYNC_AFTER_DELAY_MS;

    // Schedule a sync once we detect a tab change
    // to ensure the server always has the most up to date tabs
    if (
      delayInMs > 0 &&
      scheduler.numClients > 1 // Only schedule quick syncs for multi client users
    ) {
      if (this.tabsQuickWriteTimer) {
        this._log.debug(
          "Detected a tab change, but a quick-write is already scheduled"
        );
        return;
      }
      this._log.debug(
        "Detected a tab change: scheduling a quick-write in " + delayInMs + "ms"
      );
      CommonUtils.namedTimer(
        () => {
          this._log.trace("tab quick-sync timer fired.");
          this.engine
            .quickWrite()
            .then(() => {
              this._log.trace("tab quick-sync done.");
            })
            .catch(ex => {
              this._log.error("tab quick-sync failed.", ex);
            });
        },
        delayInMs,
        this,
        "tabsQuickWriteTimer"
      );
    } else if (scoreIncrement) {
      this._log.debug(
        "Detected a tab change, but conditions aren't met for a quick write - bumping score"
      );
      this.score += scoreIncrement;
    } else {
      this._log.debug(
        "Detected a tab change, but conditions aren't met for a quick write or a score bump"
      );
    }
  },
};
Object.setPrototypeOf(TabTracker.prototype, Tracker.prototype);