summaryrefslogtreecommitdiffstats
path: root/toolkit/components/thumbnails/PageThumbs.sys.mjs
blob: 4d103c8bd856da3d2e1853913b38c847a9a25d65 (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
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
/* 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 PREF_STORAGE_VERSION = "browser.pagethumbnails.storage_version";
const LATEST_STORAGE_VERSION = 3;

const EXPIRATION_MIN_CHUNK_SIZE = 50;
const EXPIRATION_INTERVAL_SECS = 3600;

// If a request for a thumbnail comes in and we find one that is "stale"
// (or don't find one at all) we automatically queue a request to generate a
// new one.
const MAX_THUMBNAIL_AGE_SECS = 172800; // 2 days == 60*60*24*2 == 172800 secs.

/**
 * Name of the directory in the profile that contains the thumbnails.
 */
const THUMBNAIL_DIRECTORY = "thumbnails";

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

import { BasePromiseWorker } from "resource://gre/modules/PromiseWorker.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  PageThumbUtils: "resource://gre/modules/PageThumbUtils.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
});

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "gUpdateTimerManager",
  "@mozilla.org/updates/timer-manager;1",
  "nsIUpdateTimerManager"
);

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "PageThumbsStorageService",
  "@mozilla.org/thumbnails/pagethumbs-service;1",
  "nsIPageThumbsStorageService"
);

/**
 * Utilities for dealing with promises.
 */
const TaskUtils = {
  /**
   * Read the bytes from a blob, asynchronously.
   *
   * @return {Promise}
   * @resolve {ArrayBuffer} In case of success, the bytes contained in the blob.
   * @reject {DOMException} In case of error, the underlying DOMException.
   */
  readBlob: function readBlob(blob) {
    return new Promise((resolve, reject) => {
      let reader = new FileReader();
      reader.onloadend = function onloadend() {
        if (reader.readyState != FileReader.DONE) {
          reject(reader.error);
        } else {
          resolve(reader.result);
        }
      };
      reader.readAsArrayBuffer(blob);
    });
  },
};

/**
 * Singleton providing functionality for capturing web page thumbnails and for
 * accessing them if already cached.
 */
export var PageThumbs = {
  _initialized: false,

  /**
   * The calculated width and height of the thumbnails.
   */
  _thumbnailWidth: 0,
  _thumbnailHeight: 0,

  /**
   * The scheme to use for thumbnail urls.
   */
  get scheme() {
    return "moz-page-thumb";
  },

  /**
   * The static host to use for thumbnail urls.
   */
  get staticHost() {
    return "thumbnails";
  },

  /**
   * The thumbnails' image type.
   */
  get contentType() {
    return "image/png";
  },

  init: function PageThumbs_init() {
    if (!this._initialized) {
      this._initialized = true;

      this._placesObserver = new PlacesWeakCallbackWrapper(
        this.handlePlacesEvents.bind(this)
      );
      PlacesObservers.addListener(
        ["history-cleared", "page-removed"],
        this._placesObserver
      );

      // Migrate the underlying storage, if needed.
      PageThumbsStorageMigrator.migrate();
      PageThumbsExpiration.init();
    }
  },

  handlePlacesEvents(events) {
    for (const event of events) {
      switch (event.type) {
        case "history-cleared": {
          PageThumbsStorage.wipe();
          break;
        }
        case "page-removed": {
          if (event.isRemovedFromStore) {
            PageThumbsStorage.remove(event.url);
          }
          break;
        }
      }
    }
  },

  uninit: function PageThumbs_uninit() {
    if (this._initialized) {
      this._initialized = false;
    }
  },

  /**
   * Gets the thumbnail image's url for a given web page's url.
   * @param aUrl The web page's url that is depicted in the thumbnail.
   * @return The thumbnail image's url.
   */
  getThumbnailURL: function PageThumbs_getThumbnailURL(aUrl) {
    return (
      this.scheme +
      "://" +
      this.staticHost +
      "/?url=" +
      encodeURIComponent(aUrl) +
      "&revision=" +
      PageThumbsStorage.getRevision(aUrl)
    );
  },

  /**
   * Gets the path of the thumbnail file for a given web page's
   * url. This file may or may not exist depending on whether the
   * thumbnail has been captured or not.
   *
   * @param aUrl The web page's url.
   * @return The path of the thumbnail file.
   */
  getThumbnailPath: function PageThumbs_getThumbnailPath(aUrl) {
    return lazy.PageThumbsStorageService.getFilePathForURL(aUrl);
  },

  /**
   * Asynchronously returns a thumbnail as a blob for the given
   * window.
   *
   * @param aBrowser The <browser> to capture a thumbnail from.
   * @param aArgs See captureToCanvas for accepted arguments.
   * @return {Promise}
   * @resolve {Blob} The thumbnail, as a Blob.
   */
  captureToBlob: function PageThumbs_captureToBlob(aBrowser, aArgs) {
    if (!this._prefEnabled()) {
      return null;
    }

    return new Promise(resolve => {
      let canvas = this.createCanvas(aBrowser.ownerGlobal);
      this.captureToCanvas(aBrowser, canvas, aArgs)
        .then(() => {
          canvas.toBlob(blob => {
            resolve(blob, this.contentType);
          });
        })
        .catch(e => console.error(e));
    });
  },

  /**
   * Captures a thumbnail from a given window and draws it to the given canvas.
   * Note, when dealing with remote content, this api draws into the passed
   * canvas asynchronously. Pass aCallback to receive an async callback after
   * canvas painting has completed.
   * @param aBrowser The browser to capture a thumbnail from.
   * @param aCanvas The canvas to draw to. The thumbnail will be scaled to match
   *   the dimensions of this canvas. If callers pass a 0x0 canvas, the canvas
   *   will be resized to default thumbnail dimensions just prior to painting.
   * @param aArgs (optional) Additional named parameters:
   *   fullScale - request that a non-downscaled image be returned.
   *   isImage - indicate that this should be treated as an image url.
   *   backgroundColor - background color to draw behind images.
   *   targetWidth - desired width for images.
   *   isBackgroundThumb - true if request is from the background thumb service.
   *   fullViewport - request that a screenshot for the viewport be
   *     captured. This makes it possible to get a screenshot that reflects
   *     the current scroll position of aBrowser.
   * @param aSkipTelemetry skip recording telemetry
   */
  async captureToCanvas(aBrowser, aCanvas, aArgs, aSkipTelemetry = false) {
    let telemetryCaptureTime = new Date();
    let args = {
      fullScale: aArgs ? aArgs.fullScale : false,
      isImage: aArgs ? aArgs.isImage : false,
      backgroundColor:
        aArgs?.backgroundColor ?? lazy.PageThumbUtils.THUMBNAIL_BG_COLOR,
      targetWidth:
        aArgs?.targetWidth ?? lazy.PageThumbUtils.THUMBNAIL_DEFAULT_SIZE,
      isBackgroundThumb: aArgs ? aArgs.isBackgroundThumb : false,
      fullViewport: aArgs?.fullViewport ?? false,
    };

    return this._captureToCanvas(aBrowser, aCanvas, args).then(() => {
      if (!aSkipTelemetry) {
        Services.telemetry
          .getHistogramById("FX_THUMBNAILS_CAPTURE_TIME_MS")
          .add(new Date() - telemetryCaptureTime);
      }
      return aCanvas;
    });
  },

  /**
   * Asynchronously check the state of aBrowser to see if it passes a set of
   * predefined security checks. Consumers should refrain from storing
   * thumbnails if these checks fail. Note the final result of this call is
   * transitory as it is based on current navigation state and the type of
   * content being displayed.
   *
   * @param aBrowser The target browser
   */
  async shouldStoreThumbnail(aBrowser) {
    // Don't capture in private browsing mode.
    if (lazy.PrivateBrowsingUtils.isBrowserPrivate(aBrowser)) {
      return false;
    }
    if (aBrowser.isRemoteBrowser) {
      if (aBrowser.browsingContext.currentWindowGlobal) {
        let thumbnailsActor =
          aBrowser.browsingContext.currentWindowGlobal.getActor("Thumbnails");
        return thumbnailsActor
          .sendQuery("Browser:Thumbnail:CheckState")
          .catch(err => {
            return false;
          });
      }
      return false;
    }
    return lazy.PageThumbUtils.shouldStoreContentThumbnail(
      aBrowser.contentDocument,
      aBrowser.docShell
    );
  },

  // The background thumbnail service captures to canvas but doesn't want to
  // participate in this service's telemetry, which is why this method exists.
  async _captureToCanvas(aBrowser, aCanvas, aArgs) {
    if (aBrowser.isRemoteBrowser) {
      let thumbnail = await this._captureRemoteThumbnail(
        aBrowser,
        aCanvas.width,
        aCanvas.height,
        aArgs
      );

      // 'thumbnail' can be null if the browser has navigated away after starting
      // the thumbnail request, so we check it here.
      if (thumbnail) {
        let ctx = thumbnail.getContext("2d");
        let imgData = ctx.getImageData(0, 0, thumbnail.width, thumbnail.height);
        aCanvas.width = thumbnail.width;
        aCanvas.height = thumbnail.height;
        aCanvas.getContext("2d").putImageData(imgData, 0, 0);
      }

      return aCanvas;
    }
    // The content is a local page, grab a thumbnail sync.
    await lazy.PageThumbUtils.createSnapshotThumbnail(aBrowser, aCanvas, aArgs);
    return aCanvas;
  },

  /**
   * Asynchrnously render an appropriately scaled thumbnail to canvas.
   *
   * @param aBrowser The browser to capture a thumbnail from.
   * @param aWidth The desired canvas width.
   * @param aHeight The desired canvas height.
   * @param aArgs (optional) Additional named parameters:
   *   fullScale - request that a non-downscaled image be returned.
   *   isImage - indicate that this should be treated as an image url.
   *   backgroundColor - background color to draw behind images.
   *   targetWidth - desired width for images.
   *   isBackgroundThumb - true if request is from the background thumb service.
   *   fullViewport - request that a screenshot for the viewport be
   *     captured. This makes it possible to get a screenshot that reflects
   *     the current scroll position of aBrowser.
   * @return a promise
   */
  async _captureRemoteThumbnail(aBrowser, aWidth, aHeight, aArgs) {
    if (!aBrowser.browsingContext || !aBrowser.isConnected) {
      return null;
    }

    let thumbnailsActor = aBrowser.browsingContext.currentWindowGlobal.getActor(
      aArgs.isBackgroundThumb ? "BackgroundThumbnails" : "Thumbnails"
    );
    let contentInfo = await thumbnailsActor.sendQuery(
      "Browser:Thumbnail:ContentInfo",
      {
        isImage: aArgs.isImage,
        targetWidth: aArgs.targetWidth,
        backgroundColor: aArgs.backgroundColor,
      }
    );

    let contentWidth = contentInfo.width;
    let contentHeight = contentInfo.height;
    if (contentWidth == 0 || contentHeight == 0) {
      throw new Error("IMAGE_ZERO_DIMENSION");
    }

    if (!aBrowser.isConnected) {
      return null;
    }
    let doc = aBrowser.ownerDocument;
    let thumbnail = doc.createElementNS(
      lazy.PageThumbUtils.HTML_NAMESPACE,
      "canvas"
    );

    let image;
    if (contentInfo.imageData) {
      thumbnail.width = contentWidth;
      thumbnail.height = contentHeight;

      image = new aBrowser.ownerGlobal.Image();
      await new Promise(resolve => {
        image.onload = resolve;
        image.src = contentInfo.imageData;
      });
    } else {
      let fullScale = aArgs ? aArgs.fullScale : false;
      let scale = fullScale
        ? 1
        : Math.min(Math.max(aWidth / contentWidth, aHeight / contentHeight), 1);

      image = await aBrowser.drawSnapshot(
        0,
        0,
        contentWidth,
        contentHeight,
        scale,
        aArgs.backgroundColor,
        aArgs.fullViewport
      );
      if (!image) {
        return null;
      }

      thumbnail.width = fullScale ? contentWidth : aWidth;
      thumbnail.height = fullScale ? contentHeight : aHeight;
    }

    thumbnail.getContext("2d").drawImage(image, 0, 0);

    return thumbnail;
  },

  /**
   * Captures a thumbnail for the given browser and stores it to the cache.
   * @param aBrowser The browser to capture a thumbnail for.
   */
  captureAndStore: async function PageThumbs_captureAndStore(aBrowser) {
    if (!this._prefEnabled()) {
      return;
    }

    let url = aBrowser.currentURI.spec;
    let originalURL;
    let channelError = false;

    if (!aBrowser.isRemoteBrowser) {
      let channel = aBrowser.docShell.currentDocumentChannel;
      originalURL = channel.originalURI.spec;
      // see if this was an error response.
      channelError = lazy.PageThumbUtils.isChannelErrorResponse(channel);
    } else {
      let thumbnailsActor =
        aBrowser.browsingContext.currentWindowGlobal.getActor("Thumbnails");
      let resp = await thumbnailsActor.sendQuery(
        "Browser:Thumbnail:GetOriginalURL"
      );

      originalURL = resp.originalURL || url;
      channelError = resp.channelError;
    }

    try {
      let blob = await this.captureToBlob(aBrowser);
      let buffer = await TaskUtils.readBlob(blob);
      await this._store(originalURL, url, buffer, channelError);
    } catch (ex) {
      console.error("Exception thrown during thumbnail capture:", ex);
    }
  },

  /**
   * Checks if an existing thumbnail for the specified URL is either missing
   * or stale, and if so, captures and stores it.  Once the thumbnail is stored,
   * an observer service notification will be sent, so consumers should observe
   * such notifications if they want to be notified of an updated thumbnail.
   *
   * @param aBrowser The content window of this browser will be captured.
   */
  captureAndStoreIfStale: async function PageThumbs_captureAndStoreIfStale(
    aBrowser
  ) {
    if (!aBrowser.currentURI) {
      return false;
    }
    let url = aBrowser.currentURI.spec;
    let recent;
    try {
      recent = await PageThumbsStorage.isFileRecentForURL(url);
    } catch {
      return false;
    }
    if (
      !recent &&
      // Careful, the call to PageThumbsStorage is async, so the browser may
      // have navigated away from the URL or even closed.
      aBrowser.currentURI &&
      aBrowser.currentURI.spec == url
    ) {
      await this.captureAndStore(aBrowser);
    }
    return true;
  },

  /**
   * Stores data to disk for the given URLs.
   *
   * NB: The background thumbnail service calls this, too.
   *
   * @param aOriginalURL The URL with which the capture was initiated.
   * @param aFinalURL The URL to which aOriginalURL ultimately resolved.
   * @param aData An ArrayBuffer containing the image data.
   * @param aNoOverwrite If true and files for the URLs already exist, the files
   *                     will not be overwritten.
   */
  _store: async function PageThumbs__store(
    aOriginalURL,
    aFinalURL,
    aData,
    aNoOverwrite
  ) {
    let telemetryStoreTime = new Date();
    await PageThumbsStorage.writeData(aFinalURL, aData, aNoOverwrite);
    Services.telemetry
      .getHistogramById("FX_THUMBNAILS_STORE_TIME_MS")
      .add(new Date() - telemetryStoreTime);

    Services.obs.notifyObservers(null, "page-thumbnail:create", aFinalURL);
    // We've been redirected. Create a copy of the current thumbnail for
    // the redirect source. We need to do this because:
    //
    // 1) Users can drag any kind of links onto the newtab page. If those
    //    links redirect to a different URL then we want to be able to
    //    provide thumbnails for both of them.
    //
    // 2) The newtab page should actually display redirect targets, only.
    //    Because of bug 559175 this information can get lost when using
    //    Sync and therefore also redirect sources appear on the newtab
    //    page. We also want thumbnails for those.
    if (aFinalURL != aOriginalURL) {
      await PageThumbsStorage.copy(aFinalURL, aOriginalURL, aNoOverwrite);
      Services.obs.notifyObservers(null, "page-thumbnail:create", aOriginalURL);
    }
  },

  /**
   * Register an expiration filter.
   *
   * When thumbnails are going to expire, each registered filter is asked for a
   * list of thumbnails to keep.
   *
   * The filter (if it is a callable) or its filterForThumbnailExpiration method
   * (if the filter is an object) is called with a single argument.  The
   * argument is a callback function.  The filter must call the callback
   * function and pass it an array of zero or more URLs.  (It may do so
   * asynchronously.)  Thumbnails for those URLs will be except from expiration.
   *
   * @param aFilter callable, or object with filterForThumbnailExpiration method
   */
  addExpirationFilter: function PageThumbs_addExpirationFilter(aFilter) {
    PageThumbsExpiration.addFilter(aFilter);
  },

  /**
   * Unregister an expiration filter.
   * @param aFilter A filter that was previously passed to addExpirationFilter.
   */
  removeExpirationFilter: function PageThumbs_removeExpirationFilter(aFilter) {
    PageThumbsExpiration.removeFilter(aFilter);
  },

  /**
   * Creates a new hidden canvas element.
   * @param aWindow The document of this window will be used to create the
   *                canvas.  If not given, the hidden window will be used.
   * @return The newly created canvas.
   */
  createCanvas: function PageThumbs_createCanvas(aWindow) {
    return lazy.PageThumbUtils.createCanvas(aWindow);
  },

  _prefEnabled: function PageThumbs_prefEnabled() {
    try {
      return !Services.prefs.getBoolPref(
        "browser.pagethumbnails.capturing_disabled"
      );
    } catch (e) {
      return true;
    }
  },
};

export var PageThumbsStorage = {
  ensurePath: function Storage_ensurePath() {
    // Create the directory (ignore any error if the directory
    // already exists). As all writes are done from the PageThumbsWorker
    // thread, which serializes its operations, this ensures that
    // future operations can proceed without having to check whether
    // the directory exists.
    return PageThumbsWorker.post("makeDir", [
      lazy.PageThumbsStorageService.path,
      { ignoreExisting: true },
    ]).catch(function onError(aReason) {
      console.error("Could not create thumbnails directory", aReason);
    });
  },

  _revisionTable: {},

  // Generate an arbitrary revision tag, i.e. one that can't be used to
  // infer URL frecency.
  updateRevision(aURL) {
    // Initialize with a random value and increment on each update. Wrap around
    // modulo _revisionRange, so that even small values carry no meaning.
    let rev = this._revisionTable[aURL];
    if (rev == null) {
      rev = Math.floor(Math.random() * this._revisionRange);
    }
    this._revisionTable[aURL] = (rev + 1) % this._revisionRange;
  },

  // If two thumbnails with the same URL and revision are in cache at the
  // same time, the image loader may pick the stale thumbnail in some cases.
  // Therefore _revisionRange must be large enough to prevent this, e.g.
  // in the pathological case image.cache.size (5MB by default) could fill
  // with (abnormally small) 10KB thumbnail images if the browser session
  // runs long enough (though this is unlikely as thumbnails are usually
  // only updated every MAX_THUMBNAIL_AGE_SECS).
  _revisionRange: 8192,

  /**
   * Return a revision tag for the thumbnail stored for a given URL.
   *
   * @param aURL The URL spec string
   * @return A revision tag for the corresponding thumbnail. Returns a changed
   * value whenever the stored thumbnail changes.
   */
  getRevision(aURL) {
    let rev = this._revisionTable[aURL];
    if (rev == null) {
      this.updateRevision(aURL);
      rev = this._revisionTable[aURL];
    }
    return rev;
  },

  /**
   * Write the contents of a thumbnail, off the main thread.
   *
   * @param {string} aURL The url for which to store a thumbnail.
   * @param {ArrayBuffer} aData The data to store in the thumbnail, as
   * an ArrayBuffer. This array buffer will be detached and cannot be
   * reused after the copy.
   * @param {boolean} aNoOverwrite If true and the thumbnail's file already
   * exists, the file will not be overwritten.
   *
   * @return {Promise}
   */
  writeData: function Storage_writeData(aURL, aData, aNoOverwrite) {
    let path = lazy.PageThumbsStorageService.getFilePathForURL(aURL);
    this.ensurePath();
    aData = new Uint8Array(aData);
    let msg = [
      path,
      aData,
      {
        tmpPath: path + ".tmp",
        mode: aNoOverwrite ? "create" : "overwrite",
      },
    ];
    return PageThumbsWorker.post(
      "writeAtomic",
      msg,
      msg /* we don't want that message garbage-collected,
           as OS.Shared.Type.void_t.in_ptr.toMsg uses C-level
           memory tricks to enforce zero-copy*/
    ).then(
      () => this.updateRevision(aURL),
      this._eatNoOverwriteError(aNoOverwrite)
    );
  },

  /**
   * Copy a thumbnail, off the main thread.
   *
   * @param {string} aSourceURL The url of the thumbnail to copy.
   * @param {string} aTargetURL The url of the target thumbnail.
   * @param {boolean} aNoOverwrite If true and the target file already exists,
   * the file will not be overwritten.
   *
   * @return {Promise}
   */
  copy: function Storage_copy(aSourceURL, aTargetURL, aNoOverwrite) {
    this.ensurePath();
    let sourceFile =
      lazy.PageThumbsStorageService.getFilePathForURL(aSourceURL);
    let targetFile =
      lazy.PageThumbsStorageService.getFilePathForURL(aTargetURL);
    let options = { noOverwrite: aNoOverwrite };
    return PageThumbsWorker.post("copy", [
      sourceFile,
      targetFile,
      options,
    ]).then(
      () => this.updateRevision(aTargetURL),
      this._eatNoOverwriteError(aNoOverwrite)
    );
  },

  /**
   * Remove a single thumbnail, off the main thread.
   *
   * @return {Promise}
   */
  remove: function Storage_remove(aURL) {
    return PageThumbsWorker.post("remove", [
      lazy.PageThumbsStorageService.getFilePathForURL(aURL),
    ]);
  },

  /**
   * Remove all thumbnails, off the main thread.
   *
   * @return {Promise}
   */
  wipe: async function Storage_wipe() {
    //
    // This operation may be launched during shutdown, so we need to
    // take a few precautions to ensure that:
    //
    // 1. it is not interrupted by shutdown, in which case we
    //    could be leaving privacy-sensitive files on disk;
    // 2. it is not launched too late during shutdown, in which
    //    case this could cause shutdown freezes (see bug 1005487,
    //    which will eventually be fixed by bug 965309)
    //

    let blocker = () => undefined;

    // The following operation will rise an error if we have already
    // reached profileBeforeChange, in which case it is too late
    // to clear the thumbnail wipe.
    IOUtils.profileBeforeChange.addBlocker(
      "PageThumbs: removing all thumbnails",
      blocker
    );

    // Start the work only now that `profileBeforeChange` has had
    // a chance to throw an error.

    let promise = PageThumbsWorker.post("wipe", [
      lazy.PageThumbsStorageService.path,
    ]);
    try {
      await promise;
    } finally {
      // Generally, we will be done much before profileBeforeChange,
      // so let's not hoard blockers.
      IOUtils.profileBeforeChange.removeBlocker(blocker);
    }
  },

  fileExistsForURL: function Storage_fileExistsForURL(aURL) {
    return PageThumbsWorker.post("exists", [
      lazy.PageThumbsStorageService.getFilePathForURL(aURL),
    ]);
  },

  isFileRecentForURL: function Storage_isFileRecentForURL(aURL) {
    return PageThumbsWorker.post("isFileRecent", [
      lazy.PageThumbsStorageService.getFilePathForURL(aURL),
      MAX_THUMBNAIL_AGE_SECS,
    ]);
  },

  /**
   * For functions that take a noOverwrite option, IOUtils throws an error if
   * the target file exists and noOverwrite is true.  We don't consider that an
   * error, and we don't want such errors propagated.
   *
   * @param {aNoOverwrite} The noOverwrite option used in the IOUtils operation.
   *
   * @return {function} A function that should be passed as the second argument
   * to then() (the `onError` argument).
   */
  _eatNoOverwriteError: function Storage__eatNoOverwriteError(aNoOverwrite) {
    return function onError(err) {
      if (
        !aNoOverwrite ||
        !DOMException.isInstance(err) ||
        err.name !== "TypeMismatchError"
      ) {
        throw err;
      }
    };
  },
};

var PageThumbsStorageMigrator = {
  get currentVersion() {
    try {
      return Services.prefs.getIntPref(PREF_STORAGE_VERSION);
    } catch (e) {
      // The pref doesn't exist, yet. Return version 0.
      return 0;
    }
  },

  set currentVersion(aVersion) {
    Services.prefs.setIntPref(PREF_STORAGE_VERSION, aVersion);
  },

  migrate: function Migrator_migrate() {
    let version = this.currentVersion;

    // Storage version 1 never made it to beta.
    // At the time of writing only Windows had (ProfD != ProfLD) and we
    // needed to move thumbnails from the roaming profile to the locale
    // one so that they're not needlessly included in backups and/or
    // written via SMB.

    // Storage version 2 also never made it to beta.
    // The thumbnail folder structure has been changed and old thumbnails
    // were not migrated. Instead, we just renamed the current folder to
    // "<name>-old" and will remove it later.

    if (version < 3) {
      this.migrateToVersion3();
    }

    this.currentVersion = LATEST_STORAGE_VERSION;
  },

  /**
   * Bug 239254 added support for having the disk cache and thumbnail
   * directories on a local path (i.e. ~/.cache/) under Linux. We'll first
   * try to move the old thumbnails to their new location. If that's not
   * possible (because ProfD might be on a different file system than
   * ProfLD) we'll just discard them.
   *
   * @param {string*} local The path to the local profile directory.
   * Used for testing. Default argument is good for all non-testing uses.
   * @param {string*} roaming The path to the roaming profile directory.
   * Used for testing. Default argument is good for all non-testing uses.
   */
  migrateToVersion3: function Migrator_migrateToVersion3(
    local = Services.dirsvc.get("ProfLD", Ci.nsIFile).path,
    roaming = Services.dirsvc.get("ProfD", Ci.nsIFile).path
  ) {
    PageThumbsWorker.post("moveOrDeleteAllThumbnails", [
      PathUtils.join(roaming, THUMBNAIL_DIRECTORY),
      PathUtils.join(local, THUMBNAIL_DIRECTORY),
    ]);
  },
};

// Export required for testing
export var PageThumbsExpiration = {
  _filters: [],

  init: function Expiration_init() {
    lazy.gUpdateTimerManager.registerTimer(
      "browser-cleanup-thumbnails",
      this,
      EXPIRATION_INTERVAL_SECS
    );
  },

  addFilter: function Expiration_addFilter(aFilter) {
    this._filters.push(aFilter);
  },

  removeFilter: function Expiration_removeFilter(aFilter) {
    let index = this._filters.indexOf(aFilter);
    if (index > -1) {
      this._filters.splice(index, 1);
    }
  },

  notify: function Expiration_notify(aTimer) {
    let urls = [];
    let filtersToWaitFor = this._filters.length;

    let expire = () => {
      this.expireThumbnails(urls);
    };

    // No registered filters.
    if (!filtersToWaitFor) {
      expire();
      return;
    }

    function filterCallback(aURLs) {
      urls = urls.concat(aURLs);
      if (--filtersToWaitFor == 0) {
        expire();
      }
    }

    for (let filter of this._filters) {
      if (typeof filter == "function") {
        filter(filterCallback);
      } else {
        filter.filterForThumbnailExpiration(filterCallback);
      }
    }
  },

  expireThumbnails: function Expiration_expireThumbnails(aURLsToKeep) {
    let keep = aURLsToKeep.map(url =>
      lazy.PageThumbsStorageService.getLeafNameForURL(url)
    );
    let msg = [
      lazy.PageThumbsStorageService.path,
      keep,
      EXPIRATION_MIN_CHUNK_SIZE,
    ];

    return PageThumbsWorker.post("expireFilesInDirectory", msg);
  },
};

/**
 * Interface to a dedicated thread handling I/O
 */
var PageThumbsWorker = new BasePromiseWorker(
  "resource://gre/modules/PageThumbs.worker.js"
);