summaryrefslogtreecommitdiffstats
path: root/toolkit/components/thumbnails
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
commit36d22d82aa202bb199967e9512281e9a53db42c9 (patch)
tree105e8c98ddea1c1e4784a60a5a6410fa416be2de /toolkit/components/thumbnails
parentInitial commit. (diff)
downloadfirefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz
firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/thumbnails')
-rw-r--r--toolkit/components/thumbnails/BackgroundPageThumbs.sys.mjs788
-rw-r--r--toolkit/components/thumbnails/PageThumbUtils.sys.mjs434
-rw-r--r--toolkit/components/thumbnails/PageThumbs.sys.mjs887
-rw-r--r--toolkit/components/thumbnails/PageThumbsStorageService.sys.mjs67
-rw-r--r--toolkit/components/thumbnails/PageThumbsWorker.js154
-rw-r--r--toolkit/components/thumbnails/components.conf14
-rw-r--r--toolkit/components/thumbnails/content/backgroundPageThumbs.xhtml15
-rw-r--r--toolkit/components/thumbnails/jar.mn6
-rw-r--r--toolkit/components/thumbnails/moz.build35
-rw-r--r--toolkit/components/thumbnails/nsIPageThumbsStorageService.idl30
-rw-r--r--toolkit/components/thumbnails/test/authenticate.sjs216
-rw-r--r--toolkit/components/thumbnails/test/background_red.html3
-rw-r--r--toolkit/components/thumbnails/test/background_red_redirect.sjs13
-rw-r--r--toolkit/components/thumbnails/test/background_red_scroll.html3
-rw-r--r--toolkit/components/thumbnails/test/browser.ini52
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bg_bad_url.js24
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bg_basic.js15
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bg_captureIfMissing.js37
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bg_crash_during_capture.js49
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bg_crash_while_idle.js29
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bg_destroy_browser.js48
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bg_image_capture.js95
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bg_no_alert.js16
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bg_no_auth_prompt.js26
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bg_no_cookies_sent.js49
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bg_no_cookies_stored.js39
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bg_no_duplicates.js24
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bg_queueing.js49
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bg_redirect.js32
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bg_timeout.js23
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bg_topsites.js76
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bug726727.js27
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bug727765.js38
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_bug818225.js42
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_capture.js37
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_capture_parent_process.js27
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_expiration.js78
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_fullViewport.js74
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_privacy.js72
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_redirect.js42
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_removed_tab.js13
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_storage.js97
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_storage_migrate3.js95
-rw-r--r--toolkit/components/thumbnails/test/browser_thumbnails_update.js211
-rw-r--r--toolkit/components/thumbnails/test/head.js235
-rw-r--r--toolkit/components/thumbnails/test/privacy_cache_control.sjs17
-rw-r--r--toolkit/components/thumbnails/test/sample_image_blue_300x600.jpgbin0 -> 3581 bytes
-rw-r--r--toolkit/components/thumbnails/test/sample_image_green_1024x1024.jpgbin0 -> 17077 bytes
-rw-r--r--toolkit/components/thumbnails/test/sample_image_red_1920x1080.jpgbin0 -> 57332 bytes
-rw-r--r--toolkit/components/thumbnails/test/test_thumbnails_interfaces.js52
-rw-r--r--toolkit/components/thumbnails/test/thumbnails_background.sjs91
-rw-r--r--toolkit/components/thumbnails/test/thumbnails_update.sjs57
-rw-r--r--toolkit/components/thumbnails/test/xpcshell.ini5
53 files changed, 4658 insertions, 0 deletions
diff --git a/toolkit/components/thumbnails/BackgroundPageThumbs.sys.mjs b/toolkit/components/thumbnails/BackgroundPageThumbs.sys.mjs
new file mode 100644
index 0000000000..c50d5ca1da
--- /dev/null
+++ b/toolkit/components/thumbnails/BackgroundPageThumbs.sys.mjs
@@ -0,0 +1,788 @@
+/* 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 DEFAULT_CAPTURE_TIMEOUT = 30000; // ms
+// For testing, the above timeout is excessive, and makes our tests overlong.
+const TESTING_CAPTURE_TIMEOUT = 5000; // ms
+
+const DESTROY_BROWSER_TIMEOUT = 60000; // ms
+
+// Let the page settle for this amount of milliseconds before capturing to allow
+// for any in-page changes or redirects.
+const SETTLE_WAIT_TIME = 2500;
+// For testing, the above timeout is excessive, and makes our tests overlong.
+const TESTING_SETTLE_WAIT_TIME = 0;
+
+const TELEMETRY_HISTOGRAM_ID_PREFIX = "FX_THUMBNAILS_BG_";
+
+const ABOUT_NEWTAB_SEGREGATION_PREF =
+ "privacy.usercontext.about_newtab_segregation.enabled";
+
+import {
+ PageThumbs,
+ PageThumbsStorage,
+} from "resource://gre/modules/PageThumbs.sys.mjs";
+
+// possible FX_THUMBNAILS_BG_CAPTURE_DONE_REASON_2 telemetry values
+const TEL_CAPTURE_DONE_OK = 0;
+const TEL_CAPTURE_DONE_TIMEOUT = 1;
+// 2 and 3 were used when we had special handling for private-browsing.
+const TEL_CAPTURE_DONE_CRASHED = 4;
+const TEL_CAPTURE_DONE_BAD_URI = 5;
+const TEL_CAPTURE_DONE_LOAD_FAILED = 6;
+const TEL_CAPTURE_DONE_IMAGE_ZERO_DIMENSION = 7;
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ContextualIdentityService:
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+});
+
+export const BackgroundPageThumbs = {
+ /**
+ * Asynchronously captures a thumbnail of the given URL.
+ *
+ * The page is loaded anonymously, and plug-ins are disabled.
+ *
+ * @param url The URL to capture.
+ * @param options An optional object that configures the capture. Its
+ * properties are the following, and all are optional:
+ * @opt onDone A function that will be asynchronously called when the
+ * capture is complete or times out. It's called as
+ * onDone(url),
+ * where `url` is the captured URL.
+ * @opt timeout The capture will time out after this many milliseconds have
+ * elapsed after the capture has progressed to the head of
+ * the queue and started. Defaults to 30000 (30 seconds).
+ * @opt isImage If true, backgroundPageThumbsContent will attempt to render
+ * the url directly to canvas. Note that images will mostly get
+ * detected and rendered as such anyway, but this will ensure it.
+ * @opt targetWidth The target width when capturing an image.
+ * @opt backgroundColor The background colour when capturing an image.
+ * @opt dontStore If set to true, the image blob won't be stored to disk, an
+ * object will instead be passed as third argument to onDone:
+ * {
+ * data: an ArrayBuffer containing the data
+ * contentType: the data content-type
+ * originalUrl: the originally requested url
+ * currentUrl: the final url after redirects
+ * }
+ * @opt contentType can be set to an image contentType for the capture,
+ * defaults to PageThumbs.contentType.
+ */
+ capture(url, options = {}) {
+ if (!PageThumbs._prefEnabled()) {
+ if (options.onDone) {
+ schedule(() => options.onDone(url));
+ }
+ return;
+ }
+ this._captureQueue = this._captureQueue || [];
+ this._capturesByURL = this._capturesByURL || new Map();
+
+ tel("QUEUE_SIZE_ON_CAPTURE", this._captureQueue.length);
+
+ // We want to avoid duplicate captures for the same URL. If there is an
+ // existing one, we just add the callback to that one and we are done.
+ let existing = this._capturesByURL.get(url);
+ if (existing) {
+ if (options.onDone) {
+ existing.doneCallbacks.push(options.onDone);
+ }
+ // The queue is already being processed, so nothing else to do...
+ return;
+ }
+ let cap = new Capture(url, this._onCaptureOrTimeout.bind(this), options);
+ this._captureQueue.push(cap);
+ this._capturesByURL.set(url, cap);
+ this._processCaptureQueue();
+ },
+
+ /**
+ * Asynchronously captures a thumbnail of the given URL if one does not
+ * already exist. Otherwise does nothing.
+ *
+ * @param url The URL to capture.
+ * @param options An optional object that configures the capture. See
+ * capture() for description.
+ * unloadingPromise This option is resolved when the calling context is
+ * unloading, so things can be cleaned up to avoid leak.
+ * @return {Promise} A Promise that resolves when this task completes
+ */
+ async captureIfMissing(url, options = {}) {
+ // Short circuit this function if pref is enabled, or else we leak observers.
+ // See Bug 1400562
+ if (!PageThumbs._prefEnabled()) {
+ if (options.onDone) {
+ options.onDone(url);
+ }
+ return url;
+ }
+ // The fileExistsForURL call is an optimization, potentially but unlikely
+ // incorrect, and no big deal when it is. After the capture is done, we
+ // atomically test whether the file exists before writing it.
+ let exists = await PageThumbsStorage.fileExistsForURL(url);
+ if (exists) {
+ if (options.onDone) {
+ options.onDone(url);
+ }
+ return url;
+ }
+ let thumbPromise = new Promise((resolve, reject) => {
+ let observe = (subject, topic, data) => {
+ if (data === url) {
+ switch (topic) {
+ case "page-thumbnail:create":
+ resolve();
+ break;
+ case "page-thumbnail:error":
+ reject(new Error("page-thumbnail:error"));
+ break;
+ }
+ cleanup();
+ }
+ };
+ Services.obs.addObserver(observe, "page-thumbnail:create");
+ Services.obs.addObserver(observe, "page-thumbnail:error");
+
+ // Make sure to clean up to avoid leaks by removing observers when
+ // observed or when our caller is unloading
+ function cleanup() {
+ if (observe) {
+ Services.obs.removeObserver(observe, "page-thumbnail:create");
+ Services.obs.removeObserver(observe, "page-thumbnail:error");
+ observe = null;
+ }
+ }
+ if (options.unloadingPromise) {
+ options.unloadingPromise.then(cleanup);
+ }
+ });
+ try {
+ this.capture(url, options);
+ await thumbPromise;
+ } catch (err) {
+ if (options.onDone) {
+ options.onDone(url);
+ }
+ throw err;
+ }
+ return url;
+ },
+
+ /**
+ * Tell the service that the thumbnail browser should be recreated at next
+ * call of _ensureBrowser().
+ */
+ renewThumbnailBrowser() {
+ this._renewThumbBrowser = true;
+ },
+
+ get useFissionBrowser() {
+ return Services.appinfo.fissionAutostart;
+ },
+
+ /**
+ * Ensures that initialization of the thumbnail browser's parent window has
+ * begun.
+ *
+ * @return True if the parent window is completely initialized and can be
+ * used, and false if initialization has started but not completed.
+ */
+ _ensureParentWindowReady() {
+ if (this._parentWin) {
+ // Already fully initialized.
+ return true;
+ }
+ if (this._startedParentWinInit) {
+ // Already started initializing.
+ return false;
+ }
+
+ this._startedParentWinInit = true;
+
+ // Create a windowless browser and load our hosting
+ // (privileged) document in it.
+ const flags = this.useFissionBrowser
+ ? Ci.nsIWebBrowserChrome.CHROME_REMOTE_WINDOW |
+ Ci.nsIWebBrowserChrome.CHROME_FISSION_WINDOW
+ : 0;
+ let wlBrowser = Services.appShell.createWindowlessBrowser(true, flags);
+ wlBrowser.QueryInterface(Ci.nsIInterfaceRequestor);
+ let webProgress = wlBrowser.getInterface(Ci.nsIWebProgress);
+ this._listener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsIWebProgressListener2",
+ "nsISupportsWeakReference",
+ ]),
+ };
+ this._listener.onStateChange = (wbp, request, stateFlags, status) => {
+ if (!request) {
+ return;
+ }
+ if (
+ stateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ stateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK
+ ) {
+ webProgress.removeProgressListener(this._listener);
+ delete this._listener;
+ // Get the window reference via the document.
+ this._parentWin = wlBrowser.document.defaultView;
+ this._processCaptureQueue();
+ }
+ };
+ webProgress.addProgressListener(
+ this._listener,
+ Ci.nsIWebProgress.NOTIFY_STATE_ALL
+ );
+ let loadURIOptions = {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ };
+ wlBrowser.loadURI(
+ Services.io.newURI("chrome://global/content/backgroundPageThumbs.xhtml"),
+ loadURIOptions
+ );
+ this._windowlessContainer = wlBrowser;
+
+ return false;
+ },
+
+ _init() {
+ Services.prefs.addObserver(ABOUT_NEWTAB_SEGREGATION_PREF, this);
+ Services.obs.addObserver(this, "profile-before-change");
+ },
+
+ observe(subject, topic, data) {
+ if (topic == "profile-before-change") {
+ this._destroy();
+ } else if (
+ topic == "nsPref:changed" &&
+ data == ABOUT_NEWTAB_SEGREGATION_PREF
+ ) {
+ BackgroundPageThumbs.renewThumbnailBrowser();
+ }
+ },
+
+ /**
+ * Destroys the service. Queued and pending captures will never complete, and
+ * their consumer callbacks will never be called.
+ */
+ _destroy() {
+ if (this._captureQueue) {
+ this._captureQueue.forEach(cap => cap.destroy());
+ }
+ this._destroyBrowser();
+ if (this._windowlessContainer) {
+ this._windowlessContainer.close();
+ }
+ delete this._captureQueue;
+ delete this._windowlessContainer;
+ delete this._startedParentWinInit;
+ delete this._parentWin;
+ delete this._listener;
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ Ci.nsIWebProgressListener,
+ Ci.nsIWebProgressListener2,
+ Ci.nsISupportsWeakReference,
+ ]),
+
+ onStateChange(wbp, request, stateFlags, status) {
+ if (!request || !wbp.isTopLevel) {
+ return;
+ }
+
+ if (
+ stateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ stateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK
+ ) {
+ // If about:blank is being loaded after a capture, move on
+ // to the next capture, otherwise ignore about:blank loads.
+ if (
+ request instanceof Ci.nsIChannel &&
+ request.URI.spec == "about:blank"
+ ) {
+ if (this._expectingAboutBlank) {
+ this._expectingAboutBlank = false;
+ if (this._captureQueue.length) {
+ this._processCaptureQueue();
+ }
+ }
+ return;
+ }
+
+ if (!this._captureQueue.length) {
+ return;
+ }
+
+ let currentCapture = this._captureQueue[0];
+ if (
+ Components.isSuccessCode(status) ||
+ status === Cr.NS_BINDING_ABORTED
+ ) {
+ this._thumbBrowser.ownerGlobal.requestIdleCallback(() => {
+ currentCapture.pageLoaded(this._thumbBrowser);
+ });
+ } else {
+ currentCapture._done(
+ this._thumbBrowser,
+ null,
+ currentCapture.timedOut
+ ? TEL_CAPTURE_DONE_TIMEOUT
+ : TEL_CAPTURE_DONE_LOAD_FAILED
+ );
+ }
+ }
+ },
+
+ /**
+ * Creates the thumbnail browser if it doesn't already exist.
+ */
+ _ensureBrowser() {
+ if (this._thumbBrowser && !this._renewThumbBrowser) {
+ return;
+ }
+
+ this._destroyBrowser();
+ this._renewThumbBrowser = false;
+
+ let browser = this._parentWin.document.createXULElement("browser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("remote", "true");
+ if (this.useFissionBrowser) {
+ browser.setAttribute("maychangeremoteness", "true");
+ }
+ browser.setAttribute("disableglobalhistory", "true");
+ browser.setAttribute("messagemanagergroup", "thumbnails");
+
+ if (Services.prefs.getBoolPref(ABOUT_NEWTAB_SEGREGATION_PREF)) {
+ // Use the private container for thumbnails.
+ let privateIdentity = lazy.ContextualIdentityService.getPrivateIdentity(
+ "userContextIdInternal.thumbnail"
+ );
+ browser.setAttribute("usercontextid", privateIdentity.userContextId);
+ }
+
+ // Size the browser. Make its aspect ratio the same as the canvases' that
+ // the thumbnails are drawn into; the canvases' aspect ratio is the same as
+ // the screen's, so use that. Aim for a size in the ballpark of 1024x768.
+ let [swidth, sheight] = [{}, {}];
+ Cc["@mozilla.org/gfx/screenmanager;1"]
+ .getService(Ci.nsIScreenManager)
+ .primaryScreen.GetRectDisplayPix({}, {}, swidth, sheight);
+ let bwidth = Math.min(1024, swidth.value);
+ // Setting the width and height attributes doesn't work -- the resulting
+ // thumbnails are blank and transparent -- but setting the style does.
+ browser.style.width = bwidth + "px";
+ browser.style.height = (bwidth * sheight.value) / swidth.value + "px";
+ browser.style.colorScheme = "env(-moz-content-preferred-color-scheme)";
+
+ this._parentWin.document.documentElement.appendChild(browser);
+
+ browser.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW);
+ browser.mute();
+
+ // an event that is sent if the remote process crashes - no need to remove
+ // it as we want it to be there as long as the browser itself lives.
+ browser.addEventListener("oop-browser-crashed", event => {
+ if (!event.isTopFrame) {
+ // It was a subframe that crashed. We'll ignore this.
+ return;
+ }
+
+ console.error("BackgroundThumbnails remote process crashed - recovering");
+ this._destroyBrowser();
+ let curCapture = this._captureQueue.length ? this._captureQueue[0] : null;
+ // we could retry the pending capture, but it's possible the crash
+ // was due directly to it, so trying again might just crash again.
+ // We could keep a flag to indicate if it previously crashed, but
+ // "resetting" the capture requires more work - so for now, we just
+ // discard it.
+ if (curCapture) {
+ // Continue queue processing by calling curCapture._done(). Do it after
+ // this crashed listener returns, though. A new browser will be created
+ // immediately (on the same stack as the _done call stack) if there are
+ // any more queued-up captures, and that seems to mess up the new
+ // browser's message manager if it happens on the same stack as the
+ // listener. Trying to send a message to the manager in that case
+ // throws NS_ERROR_NOT_INITIALIZED.
+ Services.tm.dispatchToMainThread(() => {
+ curCapture._done(browser, null, TEL_CAPTURE_DONE_CRASHED);
+ });
+ }
+ // else: we must have been idle and not currently doing a capture (eg,
+ // maybe a GC or similar crashed) - so there's no need to attempt a
+ // queue restart - the next capture request will set everything up.
+ });
+
+ this._thumbBrowser = browser;
+ browser.docShellIsActive = false;
+ },
+
+ _destroyBrowser() {
+ if (!this._thumbBrowser) {
+ return;
+ }
+ this._expectingAboutBlank = false;
+ this._thumbBrowser.remove();
+ delete this._thumbBrowser;
+ },
+
+ async _loadAboutBlank() {
+ if (this._expectingAboutBlank) {
+ return;
+ }
+
+ this._expectingAboutBlank = true;
+
+ let loadURIOptions = {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_STOP_CONTENT,
+ };
+ this._thumbBrowser.loadURI(
+ Services.io.newURI("about:blank"),
+ loadURIOptions
+ );
+ },
+
+ /**
+ * Starts the next capture if the queue is not empty and the service is fully
+ * initialized.
+ */
+ _processCaptureQueue() {
+ if (!this._captureQueue.length) {
+ if (this._thumbBrowser) {
+ BackgroundPageThumbs._loadAboutBlank();
+ }
+ return;
+ }
+
+ if (
+ this._captureQueue[0].pending ||
+ !this._ensureParentWindowReady() ||
+ this._expectingAboutBlank
+ ) {
+ return;
+ }
+
+ // Ready to start the first capture in the queue.
+ this._ensureBrowser();
+ this._captureQueue[0].start(this._thumbBrowser);
+ if (this._destroyBrowserTimer) {
+ this._destroyBrowserTimer.cancel();
+ delete this._destroyBrowserTimer;
+ }
+ },
+
+ /**
+ * Called when the current capture completes or fails (eg, times out, remote
+ * process crashes.)
+ */
+ _onCaptureOrTimeout(capture, reason) {
+ // Since timeouts start as an item is being processed, only the first
+ // item in the queue can be passed to this method.
+ if (capture !== this._captureQueue[0]) {
+ throw new Error("The capture should be at the head of the queue.");
+ }
+
+ this._captureQueue.shift();
+ this._capturesByURL.delete(capture.url);
+ if (reason != TEL_CAPTURE_DONE_OK) {
+ Services.obs.notifyObservers(null, "page-thumbnail:error", capture.url);
+ }
+
+ // Start the destroy-browser timer *before* processing the capture queue.
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.initWithCallback(
+ this._destroyBrowser.bind(this),
+ this._destroyBrowserTimeout,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ this._destroyBrowserTimer = timer;
+
+ this._processCaptureQueue();
+ },
+
+ _destroyBrowserTimeout: DESTROY_BROWSER_TIMEOUT,
+};
+
+BackgroundPageThumbs._init();
+
+/**
+ * Represents a single capture request in the capture queue.
+ *
+ * @param url The URL to capture.
+ * @param captureCallback A function you want called when the capture
+ * completes.
+ * @param options The capture options.
+ */
+function Capture(url, captureCallback, options) {
+ this.url = url;
+ this.captureCallback = captureCallback;
+ this.redirectTimer = null;
+ this.timedOut = false;
+ this.options = options;
+ this.id = Capture.nextID++;
+ this.creationDate = new Date();
+ this.doneCallbacks = [];
+ if (options.onDone) {
+ this.doneCallbacks.push(options.onDone);
+ }
+}
+
+Capture.prototype = {
+ get pending() {
+ return !!this._timeoutTimer;
+ },
+
+ /**
+ * Sends a message to the content script to start the capture.
+ *
+ * @param browser The thumbnail browser.
+ */
+ start(browser) {
+ this.startDate = new Date();
+ tel("CAPTURE_QUEUE_TIME_MS", this.startDate - this.creationDate);
+
+ let fallbackTimeout = Cu.isInAutomation
+ ? TESTING_CAPTURE_TIMEOUT
+ : DEFAULT_CAPTURE_TIMEOUT;
+
+ // timeout timer
+ let timeout =
+ typeof this.options.timeout == "number"
+ ? this.options.timeout
+ : fallbackTimeout;
+ this._timeoutTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ this._timeoutTimer.initWithCallback(
+ this,
+ timeout,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+
+ this._browser = browser;
+
+ if (!browser.browsingContext) {
+ return;
+ }
+
+ this._pageLoadStartTime = new Date();
+
+ BackgroundPageThumbs._expectingAboutBlank = false;
+
+ let thumbnailsActor = browser.browsingContext.currentWindowGlobal.getActor(
+ "BackgroundThumbnails"
+ );
+ thumbnailsActor
+ .sendQuery("Browser:Thumbnail:LoadURL", {
+ url: this.url,
+ })
+ .then(
+ success => {
+ // If it failed, then this was likely a bad url. If successful,
+ // BackgroundPageThumbs.onStateChange will call _done() after the
+ // load has completed.
+ if (!success) {
+ this._done(browser, null, TEL_CAPTURE_DONE_BAD_URI);
+ }
+ },
+ failure => {
+ // The query can fail when a crash occurs while loading. The error causes
+ // thumbnail crash tests to fail with an uninteresting error message.
+ }
+ );
+ },
+
+ 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);
+ });
+ },
+
+ async pageLoaded(aBrowser) {
+ if (this.timedOut) {
+ this._done(aBrowser, null, TEL_CAPTURE_DONE_TIMEOUT);
+ return;
+ }
+
+ let waitTime = Cu.isInAutomation
+ ? TESTING_SETTLE_WAIT_TIME
+ : SETTLE_WAIT_TIME;
+
+ // There was additional activity, so restart the wait timer
+ if (this.redirectTimer) {
+ this.redirectTimer.delay = waitTime;
+ return;
+ }
+
+ // The requested page has loaded or stopped/aborted, so capture the page
+ // soon but first let it settle in case of in-page redirects
+ await new Promise(resolve => {
+ this.redirectTimer = Cc["@mozilla.org/timer;1"].createInstance(
+ Ci.nsITimer
+ );
+ this.redirectTimer.init(resolve, waitTime, Ci.nsITimer.TYPE_ONE_SHOT);
+ });
+
+ this.redirectTimer = null;
+
+ let pageLoadTime = new Date() - this._pageLoadStartTime;
+ let canvasDrawStartTime = new Date();
+
+ let canvas = PageThumbs.createCanvas(aBrowser.ownerGlobal, 1, 1);
+ try {
+ await PageThumbs.captureToCanvas(
+ aBrowser,
+ canvas,
+ {
+ isBackgroundThumb: true,
+ isImage: this.options.isImage,
+ backgroundColor: this.options.backgroundColor,
+ },
+ true
+ );
+ } catch (ex) {
+ this._done(
+ aBrowser,
+ null,
+ ex == "IMAGE_ZERO_DIMENSION"
+ ? TEL_CAPTURE_DONE_IMAGE_ZERO_DIMENSION
+ : TEL_CAPTURE_DONE_LOAD_FAILED
+ );
+ return;
+ }
+
+ let canvasDrawTime = new Date() - canvasDrawStartTime;
+
+ let contentType =
+ (this.options.dontStore && this.options.contentType) ||
+ PageThumbs.contentType;
+ let imageData = await new Promise(resolve => {
+ canvas.toBlob(blob => {
+ resolve(blob, contentType);
+ }, contentType);
+ });
+
+ this._done(aBrowser, imageData, TEL_CAPTURE_DONE_OK, {
+ CAPTURE_PAGE_LOAD_TIME_MS: pageLoadTime,
+ CAPTURE_CANVAS_DRAW_TIME_MS: canvasDrawTime,
+ });
+ },
+
+ /**
+ * The only intended external use of this method is by the service when it's
+ * uninitializing and doing things like destroying the thumbnail browser. In
+ * that case the consumer's completion callback will never be called.
+ */
+ destroy() {
+ // This method may be called for captures that haven't started yet, so
+ // guard against not yet having _timeoutTimer, _msgMan etc properties...
+ if (this._timeoutTimer) {
+ this._timeoutTimer.cancel();
+ delete this._timeoutTimer;
+ }
+ delete this.captureCallback;
+ delete this.doneCallbacks;
+ delete this.options;
+ },
+
+ // Called when the timeout timer fires.
+ notify() {
+ this.timedOut = true;
+ this._browser.stop();
+ },
+
+ _done(browser, imageData, reason, telemetry) {
+ // Note that _done will be called only once, by either receiveMessage or
+ // notify, since it calls destroy here, which cancels the timeout timer and
+ // removes the didCapture message listener.
+ let { captureCallback, doneCallbacks, options } = this;
+ this.destroy();
+
+ if (typeof reason != "number") {
+ throw new Error("A done reason must be given.");
+ }
+
+ tel("CAPTURE_DONE_REASON_2", reason);
+
+ if (telemetry) {
+ // Telemetry is currently disabled in the content process (bug 680508).
+ for (let id in telemetry) {
+ tel(id, telemetry[id]);
+ }
+ }
+
+ let done = (info = null) => {
+ captureCallback(this, reason);
+ for (let callback of doneCallbacks) {
+ try {
+ callback.call(options, this.url, reason, info);
+ } catch (err) {
+ console.error(err);
+ }
+ }
+
+ if (Services.prefs.getBoolPref(ABOUT_NEWTAB_SEGREGATION_PREF)) {
+ // Clear the data in the private container for thumbnails.
+ let privateIdentity = lazy.ContextualIdentityService.getPrivateIdentity(
+ "userContextIdInternal.thumbnail"
+ );
+ if (privateIdentity) {
+ Services.clearData.deleteDataFromOriginAttributesPattern({
+ userContextId: privateIdentity.userContextId,
+ });
+ }
+ }
+ };
+
+ if (!imageData) {
+ done();
+ return;
+ }
+
+ this.readBlob(imageData).then(buffer => {
+ if (options.dontStore) {
+ done({
+ data: buffer,
+ originalUrl: this.url,
+ finalUrl: browser.currentURI.spec,
+ contentType: options.contentType || PageThumbs.contentType,
+ });
+ } else {
+ PageThumbs._store(this.url, browser.currentURI.spec, buffer, true).then(
+ done,
+ done
+ );
+ }
+ });
+ },
+};
+
+Capture.nextID = 0;
+
+/**
+ * Adds a value to one of this module's telemetry histograms.
+ *
+ * @param histogramID This is prefixed with this module's ID.
+ * @param value The value to add.
+ */
+function tel(histogramID, value) {
+ let id = TELEMETRY_HISTOGRAM_ID_PREFIX + histogramID;
+ Services.telemetry.getHistogramById(id).add(value);
+}
+
+function schedule(callback) {
+ Services.tm.dispatchToMainThread(callback);
+}
diff --git a/toolkit/components/thumbnails/PageThumbUtils.sys.mjs b/toolkit/components/thumbnails/PageThumbUtils.sys.mjs
new file mode 100644
index 0000000000..61b81518cd
--- /dev/null
+++ b/toolkit/components/thumbnails/PageThumbUtils.sys.mjs
@@ -0,0 +1,434 @@
+/* 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/. */
+
+/*
+ * Common thumbnailing routines used by various consumers, including
+ * PageThumbs and BackgroundPageThumbs.
+ */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+});
+
+export var PageThumbUtils = {
+ // The default thumbnail size for images
+ THUMBNAIL_DEFAULT_SIZE: 448,
+ // The default background color for page thumbnails.
+ THUMBNAIL_BG_COLOR: "#fff",
+ // The namespace for thumbnail canvas elements.
+ HTML_NAMESPACE: "http://www.w3.org/1999/xhtml",
+
+ /**
+ * Creates a new canvas element in the context of aWindow.
+ *
+ * @param aWindow The document of this window will be used to
+ * create the canvas.
+ * @param aWidth (optional) width of the canvas to create
+ * @param aHeight (optional) height of the canvas to create
+ * @return The newly created canvas.
+ */
+ createCanvas(aWindow, aWidth = 0, aHeight = 0) {
+ let doc = aWindow.document;
+ let canvas = doc.createElementNS(this.HTML_NAMESPACE, "canvas");
+ canvas.mozOpaque = true;
+ canvas.imageSmoothingEnabled = true;
+ let [thumbnailWidth, thumbnailHeight] = this.getThumbnailSize(aWindow);
+ canvas.width = aWidth ? aWidth : thumbnailWidth;
+ canvas.height = aHeight ? aHeight : thumbnailHeight;
+ return canvas;
+ },
+
+ /**
+ * Calculates a preferred initial thumbnail size based based on newtab.css
+ * sizes or a preference for other applications. The sizes should be the same
+ * as set for the tile sizes in newtab.
+ *
+ * @param aWindow (optional) aWindow that is used to calculate the scaling size.
+ * @return The calculated thumbnail size or a default if unable to calculate.
+ */
+ getThumbnailSize(aWindow = null) {
+ if (!this._thumbnailWidth || !this._thumbnailHeight) {
+ let screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService(
+ Ci.nsIScreenManager
+ );
+ let left = {},
+ top = {},
+ screenWidth = {},
+ screenHeight = {};
+ screenManager.primaryScreen.GetRectDisplayPix(
+ left,
+ top,
+ screenWidth,
+ screenHeight
+ );
+
+ /**
+ * The primary monitor default scale might be different than
+ * what is reported by the window on mixed-DPI systems.
+ * To get the best image quality, query both and take the highest one.
+ */
+ let primaryScale = screenManager.primaryScreen.defaultCSSScaleFactor;
+ let windowScale = aWindow ? aWindow.devicePixelRatio : primaryScale;
+ let scale = Math.max(primaryScale, windowScale);
+
+ /** *
+ * THESE VALUES ARE DEFINED IN newtab.css and hard coded.
+ * If you change these values from the prefs,
+ * ALSO CHANGE THEM IN newtab.css
+ */
+ let prefWidth = Services.prefs.getIntPref("toolkit.pageThumbs.minWidth");
+ let prefHeight = Services.prefs.getIntPref(
+ "toolkit.pageThumbs.minHeight"
+ );
+ let divisor = Services.prefs.getIntPref(
+ "toolkit.pageThumbs.screenSizeDivisor"
+ );
+
+ prefWidth *= scale;
+ prefHeight *= scale;
+
+ this._thumbnailWidth = Math.max(
+ Math.round(screenWidth.value / divisor),
+ prefWidth
+ );
+ this._thumbnailHeight = Math.max(
+ Math.round(screenHeight.value / divisor),
+ prefHeight
+ );
+ }
+
+ return [this._thumbnailWidth, this._thumbnailHeight];
+ },
+
+ /** *
+ * Given a browser window, return the size of the content
+ * minus the scroll bars.
+ */
+ getContentSize(aWindow) {
+ let utils = aWindow.windowUtils;
+ let sbWidth = {};
+ let sbHeight = {};
+
+ try {
+ utils.getScrollbarSize(false, sbWidth, sbHeight);
+ } catch (e) {
+ // This might fail if the window does not have a presShell.
+ console.error("Unable to get scrollbar size in determineCropSize.");
+ sbWidth.value = sbHeight.value = 0;
+ }
+
+ // Even in RTL mode, scrollbars are always on the right.
+ // So there's no need to determine a left offset.
+ let width = aWindow.innerWidth - sbWidth.value;
+ let height = aWindow.innerHeight - sbHeight.value;
+
+ return [width, height];
+ },
+
+ /**
+ * Renders an image onto a new canvas of a given width and proportional
+ * height. Uses an image that exists in the window and is loaded, or falls
+ * back to loading the url into a new image element.
+ */
+ async createImageThumbnailCanvas(
+ window,
+ url,
+ targetWidth = 448,
+ backgroundColor = this.THUMBNAIL_BG_COLOR
+ ) {
+ // 224px is the width of cards in ActivityStream; capture thumbnails at 2x
+ const doc = (window || Services.appShell.hiddenDOMWindow).document;
+
+ let image = doc.querySelector("img");
+ if (!image) {
+ image = doc.createElementNS(this.HTML_NAMESPACE, "img");
+ await new Promise((resolve, reject) => {
+ image.onload = () => resolve();
+ image.onerror = () => reject(new Error("LOAD_FAILED"));
+ image.src = url;
+ });
+ }
+
+ // <img src="*.svg"> has width/height but not naturalWidth/naturalHeight
+ const imageWidth = image.naturalWidth || image.width;
+ const imageHeight = image.naturalHeight || image.height;
+ if (imageWidth === 0 || imageHeight === 0) {
+ throw new Error("IMAGE_ZERO_DIMENSION");
+ }
+ const width = Math.min(targetWidth, imageWidth);
+ const height = (imageHeight * width) / imageWidth;
+
+ // As we're setting the width and maintaining the aspect ratio, if an image
+ // is very tall we might get a very large thumbnail. Restricting the canvas
+ // size to {width}x{width} solves this problem. Here we choose to clip the
+ // image at the bottom rather than centre it vertically, based on an
+ // estimate that the focus of a tall image is most likely to be near the top
+ // (e.g., the face of a person).
+ const canvasHeight = Math.min(height, width);
+ const canvas = this.createCanvas(window, width, canvasHeight);
+ const context = canvas.getContext("2d");
+ context.fillStyle = backgroundColor;
+ context.fillRect(0, 0, width, canvasHeight);
+ context.drawImage(image, 0, 0, width, height);
+
+ return {
+ width,
+ height: canvasHeight,
+ imageData: canvas.toDataURL(),
+ };
+ },
+
+ /**
+ * Given a browser, this creates a snapshot of the content
+ * and returns a canvas with the resulting snapshot of the content
+ * at the thumbnail size. It has to do this through a two step process:
+ *
+ * 1) Render the content at the window size to a canvas that is 2x the thumbnail size
+ * 2) Downscale the canvas from (1) down to the thumbnail size
+ *
+ * This is because the thumbnail size is too small to render at directly,
+ * causing pages to believe the browser is a small resolution. Also,
+ * at that resolution, graphical artifacts / text become very jagged.
+ * It's actually better to the eye to have small blurry text than sharp
+ * jagged pixels to represent text.
+ *
+ * @params aBrowser - the browser to create a snapshot of.
+ * @params aDestCanvas destination canvas to draw the final
+ * snapshot to. Can be null.
+ * @param aArgs (optional) Additional named parameters:
+ * fullScale - request that a non-downscaled image be returned.
+ * @return Canvas with a scaled thumbnail of the window.
+ */
+ async createSnapshotThumbnail(aBrowser, aDestCanvas, aArgs) {
+ const aWindow = aBrowser.contentWindow;
+ let backgroundColor = aArgs
+ ? aArgs.backgroundColor
+ : PageThumbUtils.THUMBNAIL_BG_COLOR;
+ let fullScale = aArgs ? aArgs.fullScale : false;
+ let [contentWidth, contentHeight] = this.getContentSize(aWindow);
+ let [thumbnailWidth, thumbnailHeight] = aDestCanvas
+ ? [aDestCanvas.width, aDestCanvas.height]
+ : this.getThumbnailSize(aWindow);
+
+ // If the caller wants a fullscale image, set the desired thumbnail dims
+ // to the dims of content and (if provided) size the incoming canvas to
+ // support our results.
+ if (fullScale) {
+ thumbnailWidth = contentWidth;
+ thumbnailHeight = contentHeight;
+ if (aDestCanvas) {
+ aDestCanvas.width = contentWidth;
+ aDestCanvas.height = contentHeight;
+ }
+ }
+
+ let intermediateWidth = thumbnailWidth * 2;
+ let intermediateHeight = thumbnailHeight * 2;
+ let skipDownscale = false;
+
+ // If the intermediate thumbnail is larger than content dims (hiDPI
+ // devices can experience this) or a full preview is requested render
+ // at the final thumbnail size.
+ if (
+ intermediateWidth >= contentWidth ||
+ intermediateHeight >= contentHeight ||
+ fullScale
+ ) {
+ intermediateWidth = thumbnailWidth;
+ intermediateHeight = thumbnailHeight;
+ skipDownscale = true;
+ }
+
+ // Create an intermediate surface
+ let snapshotCanvas = this.createCanvas(
+ aWindow,
+ intermediateWidth,
+ intermediateHeight
+ );
+
+ // Step 1: capture the image at the intermediate dims. For thumbnails
+ // this is twice the thumbnail size, for fullScale images this is at
+ // content dims.
+ // Also by default, canvas does not draw the scrollbars, so no need to
+ // remove the scrollbar sizes.
+ let scale = Math.min(
+ Math.max(
+ intermediateWidth / contentWidth,
+ intermediateHeight / contentHeight
+ ),
+ 1
+ );
+
+ let snapshotCtx = snapshotCanvas.getContext("2d");
+ snapshotCtx.save();
+ snapshotCtx.scale(scale, scale);
+ const image = await aBrowser.drawSnapshot(
+ 0,
+ 0,
+ contentWidth,
+ contentHeight,
+ scale,
+ backgroundColor
+ );
+ snapshotCtx.drawImage(image, 0, 0, contentWidth, contentHeight);
+ snapshotCtx.restore();
+
+ // Part 2: Downscale from our intermediate dims to the final thumbnail
+ // dims and copy the result to aDestCanvas. If the caller didn't
+ // provide a target canvas, create a new canvas and return it.
+ let finalCanvas =
+ aDestCanvas ||
+ this.createCanvas(aWindow, thumbnailWidth, thumbnailHeight);
+
+ let finalCtx = finalCanvas.getContext("2d");
+ finalCtx.save();
+ if (!skipDownscale) {
+ finalCtx.scale(0.5, 0.5);
+ }
+ finalCtx.drawImage(snapshotCanvas, 0, 0);
+ finalCtx.restore();
+
+ return finalCanvas;
+ },
+
+ /**
+ * Determine a good thumbnail crop size and scale for a given content
+ * window.
+ *
+ * @param aWindow The content window.
+ * @param aCanvas The target canvas.
+ * @return An array containing width, height and scale.
+ */
+ determineCropSize(aWindow, aCanvas) {
+ let utils = aWindow.windowUtils;
+ let sbWidth = {};
+ let sbHeight = {};
+
+ try {
+ utils.getScrollbarSize(false, sbWidth, sbHeight);
+ } catch (e) {
+ // This might fail if the window does not have a presShell.
+ console.error("Unable to get scrollbar size in determineCropSize.");
+ sbWidth.value = sbHeight.value = 0;
+ }
+
+ // Even in RTL mode, scrollbars are always on the right.
+ // So there's no need to determine a left offset.
+ let width = aWindow.innerWidth - sbWidth.value;
+ let height = aWindow.innerHeight - sbHeight.value;
+
+ let { width: thumbnailWidth, height: thumbnailHeight } = aCanvas;
+ let scale = Math.min(
+ Math.max(thumbnailWidth / width, thumbnailHeight / height),
+ 1
+ );
+ let scaledWidth = width * scale;
+ let scaledHeight = height * scale;
+
+ if (scaledHeight > thumbnailHeight) {
+ height -= Math.floor(Math.abs(scaledHeight - thumbnailHeight) * scale);
+ }
+
+ if (scaledWidth > thumbnailWidth) {
+ width -= Math.floor(Math.abs(scaledWidth - thumbnailWidth) * scale);
+ }
+
+ return [width, height, scale];
+ },
+
+ shouldStoreContentThumbnail(aDocument, aDocShell) {
+ if (lazy.BrowserUtils.isFindbarVisible(aDocShell)) {
+ return false;
+ }
+
+ // FIXME Bug 720575 - Don't capture thumbnails for SVG or XML documents as
+ // that currently regresses Talos SVG tests.
+ if (ChromeUtils.getClassName(aDocument) === "XMLDocument") {
+ return false;
+ }
+
+ let webNav = aDocShell.QueryInterface(Ci.nsIWebNavigation);
+
+ // Don't take screenshots of about: pages.
+ if (webNav.currentURI.schemeIs("about")) {
+ return false;
+ }
+
+ // There's no point in taking screenshot of loading pages.
+ if (aDocShell.busyFlags != Ci.nsIDocShell.BUSY_FLAGS_NONE) {
+ return false;
+ }
+
+ let channel = aDocShell.currentDocumentChannel;
+
+ // No valid document channel. We shouldn't take a screenshot.
+ if (!channel) {
+ return false;
+ }
+
+ // Don't take screenshots of internally redirecting about: pages.
+ // This includes error pages.
+ let uri = channel.originalURI;
+ if (uri.schemeIs("about")) {
+ return false;
+ }
+
+ let httpChannel;
+ try {
+ httpChannel = channel.QueryInterface(Ci.nsIHttpChannel);
+ } catch (e) {
+ /* Not an HTTP channel. */
+ }
+
+ if (httpChannel) {
+ // Continue only if we have a 2xx status code.
+ try {
+ if (Math.floor(httpChannel.responseStatus / 100) != 2) {
+ return false;
+ }
+ } catch (e) {
+ // Can't get response information from the httpChannel
+ // because mResponseHead is not available.
+ return false;
+ }
+
+ // Cache-Control: no-store.
+ if (httpChannel.isNoStoreResponse()) {
+ return false;
+ }
+
+ // Don't capture HTTPS pages unless the user explicitly enabled it.
+ if (
+ uri.schemeIs("https") &&
+ !Services.prefs.getBoolPref("browser.cache.disk_cache_ssl")
+ ) {
+ return false;
+ }
+ } // httpChannel
+ return true;
+ },
+
+ /**
+ * Given a channel, returns true if it should be considered an "error
+ * response", false otherwise.
+ */
+ isChannelErrorResponse(channel) {
+ // No valid document channel sounds like an error to me!
+ if (!channel) {
+ return true;
+ }
+ if (!(channel instanceof Ci.nsIHttpChannel)) {
+ // it might be FTP etc, so assume it's ok.
+ return false;
+ }
+ try {
+ return !channel.requestSucceeded;
+ } catch (_) {
+ // not being able to determine success is surely failure!
+ return true;
+ }
+ },
+};
diff --git a/toolkit/components/thumbnails/PageThumbs.sys.mjs b/toolkit/components/thumbnails/PageThumbs.sys.mjs
new file mode 100644
index 0000000000..95f1f5e62e
--- /dev/null
+++ b/toolkit/components/thumbnails/PageThumbs.sys.mjs
@@ -0,0 +1,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/PageThumbsWorker.js"
+);
diff --git a/toolkit/components/thumbnails/PageThumbsStorageService.sys.mjs b/toolkit/components/thumbnails/PageThumbsStorageService.sys.mjs
new file mode 100644
index 0000000000..c68390ac11
--- /dev/null
+++ b/toolkit/components/thumbnails/PageThumbsStorageService.sys.mjs
@@ -0,0 +1,67 @@
+/* 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 THUMBNAIL_DIRECTORY = "thumbnails";
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "gCryptoHash", function () {
+ return Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "gUnicodeConverter", function () {
+ let converter = Cc[
+ "@mozilla.org/intl/scriptableunicodeconverter"
+ ].createInstance(Ci.nsIScriptableUnicodeConverter);
+ converter.charset = "utf8";
+ return converter;
+});
+export function PageThumbsStorageService() {}
+
+PageThumbsStorageService.prototype = {
+ classID: Components.ID("{97943eec-0e48-49ef-b7b7-cf4aa0109bb6}"),
+ QueryInterface: ChromeUtils.generateQI(["nsIPageThumbsStorageService"]),
+ // The path for the storage
+ _path: null,
+ get path() {
+ if (!this._path) {
+ this._path = PathUtils.join(
+ PathUtils.localProfileDir,
+ THUMBNAIL_DIRECTORY
+ );
+ }
+ return this._path;
+ },
+
+ getLeafNameForURL(aURL) {
+ if (typeof aURL != "string") {
+ throw new TypeError("Expecting a string");
+ }
+ let hash = this._calculateMD5Hash(aURL);
+ return hash + ".png";
+ },
+
+ getFilePathForURL(aURL) {
+ return PathUtils.join(this.path, this.getLeafNameForURL(aURL));
+ },
+
+ _calculateMD5Hash(aValue) {
+ let hash = lazy.gCryptoHash;
+ let value = lazy.gUnicodeConverter.convertToByteArray(aValue);
+
+ hash.init(hash.MD5);
+ hash.update(value, value.length);
+ return this._convertToHexString(hash.finish(false));
+ },
+
+ _convertToHexString(aData) {
+ let hex = "";
+ for (let i = 0; i < aData.length; i++) {
+ hex += ("0" + aData.charCodeAt(i).toString(16)).slice(-2);
+ }
+ return hex;
+ },
+};
diff --git a/toolkit/components/thumbnails/PageThumbsWorker.js b/toolkit/components/thumbnails/PageThumbsWorker.js
new file mode 100644
index 0000000000..11b48bdcf9
--- /dev/null
+++ b/toolkit/components/thumbnails/PageThumbsWorker.js
@@ -0,0 +1,154 @@
+/* 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/. */
+
+/* eslint-env mozilla/chrome-worker */
+
+/**
+ * A worker dedicated for the I/O component of PageThumbs storage.
+ */
+
+"use strict";
+
+/* import-globals-from /toolkit/components/workerloader/require.js */
+importScripts("resource://gre/modules/workers/require.js");
+
+var PromiseWorker = require("resource://gre/modules/workers/PromiseWorker.js");
+
+var worker = new PromiseWorker.AbstractWorker();
+worker.dispatch = function (method, args = []) {
+ return Agent[method](...args);
+};
+worker.postMessage = function (message, ...transfers) {
+ self.postMessage(message, ...transfers);
+};
+worker.close = function () {
+ self.close();
+};
+
+self.addEventListener("message", msg => worker.handleMessage(msg));
+self.addEventListener("unhandledrejection", function (error) {
+ throw error.reason;
+});
+
+var Agent = {
+ // Checks if the specified file exists and has an age less than as
+ // specifed (in seconds).
+ async isFileRecent(path, maxAge) {
+ try {
+ let stat = await IOUtils.stat(path);
+ let maxDate = new Date();
+ maxDate.setSeconds(maxDate.getSeconds() - maxAge);
+ return stat.lastModified > maxDate;
+ } catch (ex) {
+ if (!(ex instanceof DOMException)) {
+ throw ex;
+ }
+ // file doesn't exist (or can't be stat'd) - must be stale.
+ return false;
+ }
+ },
+
+ async remove(path) {
+ try {
+ await IOUtils.remove(path);
+ return true;
+ } catch (e) {
+ return false;
+ }
+ },
+
+ async expireFilesInDirectory(path, filesToKeep, minChunkSize) {
+ let entries = await this.getFileEntriesInDirectory(path, filesToKeep);
+ let limit = Math.max(minChunkSize, Math.round(entries.length / 2));
+
+ for (let entry of entries) {
+ await this.remove(entry);
+
+ // Check if we reached the limit of files to remove.
+ if (--limit <= 0) {
+ break;
+ }
+ }
+
+ return true;
+ },
+
+ async getFileEntriesInDirectory(path, skipFiles) {
+ let children = await IOUtils.getChildren(path);
+ let skip = new Set(skipFiles);
+
+ let entries = [];
+ for (let entry of children) {
+ let stat = await IOUtils.stat(entry);
+ if (stat.type === "regular" && !skip.has(PathUtils.filename(entry))) {
+ entries.push(entry);
+ }
+ }
+ return entries;
+ },
+
+ async moveOrDeleteAllThumbnails(pathFrom, pathTo) {
+ await IOUtils.makeDirectory(pathTo);
+ if (pathFrom == pathTo) {
+ return true;
+ }
+ let children = await IOUtils.getChildren(pathFrom);
+ for (let entry of children) {
+ let stat = await IOUtils.stat(entry);
+ if (stat.type !== "regular") {
+ continue;
+ }
+
+ let fileName = PathUtils.filename(entry);
+ let from = PathUtils.join(pathFrom, fileName);
+ let to = PathUtils.join(pathTo, fileName);
+
+ try {
+ await IOUtils.move(from, to, { noOverwrite: true });
+ } catch (e) {
+ await IOUtils.remove(from);
+ }
+ }
+
+ try {
+ await IOUtils.remove(pathFrom, { recursive: true });
+ } catch (e) {
+ // This could fail if there's something in
+ // the folder we're not permitted to remove.
+ }
+
+ return true;
+ },
+
+ writeAtomic(path, buffer, options) {
+ return IOUtils.write(path, buffer, options);
+ },
+
+ makeDir(path, options) {
+ return IOUtils.makeDirectory(path, options);
+ },
+
+ copy(source, dest, options) {
+ return IOUtils.copy(source, dest, options);
+ },
+
+ async wipe(path) {
+ let children = await IOUtils.getChildren(path);
+ try {
+ await Promise.all(children.map(entry => IOUtils.remove(entry)));
+ } catch (ex) {
+ // If a file cannot be removed, we should still continue.
+ // This can happen at least for any of the following reasons:
+ // - access denied;
+ // - file has been removed recently during a previous wipe
+ // and the file system has not flushed that yet (yes, this
+ // can happen under Windows);
+ // - file has been removed by the user or another process.
+ }
+ },
+
+ exists(path) {
+ return IOUtils.exists(path);
+ },
+};
diff --git a/toolkit/components/thumbnails/components.conf b/toolkit/components/thumbnails/components.conf
new file mode 100644
index 0000000000..d472b23bed
--- /dev/null
+++ b/toolkit/components/thumbnails/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ 'cid': '{97943eec-0e48-49ef-b7b7-cf4aa0109bb6}',
+ 'contract_ids': ['@mozilla.org/thumbnails/pagethumbs-service;1'],
+ 'esModule': 'resource://gre/modules/PageThumbsStorageService.sys.mjs',
+ 'constructor': 'PageThumbsStorageService',
+ },
+]
diff --git a/toolkit/components/thumbnails/content/backgroundPageThumbs.xhtml b/toolkit/components/thumbnails/content/backgroundPageThumbs.xhtml
new file mode 100644
index 0000000000..dca2841130
--- /dev/null
+++ b/toolkit/components/thumbnails/content/backgroundPageThumbs.xhtml
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+
+<!-- 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/. -->
+
+<!-- This page is used to host a (remote) browser for background page
+ thumbnailing purposes. It's always loaded as chrome:// . -->
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta charset="utf-8" />
+ <title>backgroundPageThumbs.html</title>
+ </head>
+ <body></body>
+</html>
diff --git a/toolkit/components/thumbnails/jar.mn b/toolkit/components/thumbnails/jar.mn
new file mode 100644
index 0000000000..c2141dcb15
--- /dev/null
+++ b/toolkit/components/thumbnails/jar.mn
@@ -0,0 +1,6 @@
+# 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/.
+
+toolkit.jar:
+ content/global/backgroundPageThumbs.xhtml (content/backgroundPageThumbs.xhtml)
diff --git a/toolkit/components/thumbnails/moz.build b/toolkit/components/thumbnails/moz.build
new file mode 100644
index 0000000000..7469d39f30
--- /dev/null
+++ b/toolkit/components/thumbnails/moz.build
@@ -0,0 +1,35 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "New Tab Page")
+
+BROWSER_CHROME_MANIFESTS += ["test/browser.ini"]
+XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell.ini"]
+
+EXTRA_JS_MODULES += [
+ "BackgroundPageThumbs.sys.mjs",
+ "PageThumbs.sys.mjs",
+ "PageThumbsStorageService.sys.mjs",
+ "PageThumbsWorker.js",
+ "PageThumbUtils.sys.mjs",
+]
+
+XPIDL_SOURCES += [
+ "nsIPageThumbsStorageService.idl",
+]
+
+LOCAL_INCLUDES += ["/netwerk/base"]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+XPIDL_MODULE = "thumbnails"
+
+FINAL_LIBRARY = "xul"
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/toolkit/components/thumbnails/nsIPageThumbsStorageService.idl b/toolkit/components/thumbnails/nsIPageThumbsStorageService.idl
new file mode 100644
index 0000000000..bc49f5a751
--- /dev/null
+++ b/toolkit/components/thumbnails/nsIPageThumbsStorageService.idl
@@ -0,0 +1,30 @@
+/* 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/. */
+
+#include "nsISupports.idl"
+
+/**
+ * A service which returns information about file paths where the
+ * screenshots for URLs are stored. These screenshots are used by the
+ * moz-page-thumb protocol
+ */
+
+[scriptable, uuid(97943eec-0e48-49ef-b7b7-cf4aa0109bb6)]
+interface nsIPageThumbsStorageService : nsISupports
+{
+ /**
+ * Returns the leaf name of the file containing the screenshot for a given URL
+ */
+ AString getLeafNameForURL(in AString aURL);
+
+ /**
+ * Returns the path where the thumbnails are stored
+ */
+ readonly attribute AString path;
+
+ /**
+ * Returns the full file path containing the screenshot for a given URL
+ */
+ AString getFilePathForURL(in AString aURL);
+};
diff --git a/toolkit/components/thumbnails/test/authenticate.sjs b/toolkit/components/thumbnails/test/authenticate.sjs
new file mode 100644
index 0000000000..5bc811db74
--- /dev/null
+++ b/toolkit/components/thumbnails/test/authenticate.sjs
@@ -0,0 +1,216 @@
+function handleRequest(request, response) {
+ try {
+ reallyHandleRequest(request, response);
+ } catch (e) {
+ response.setStatusLine("1.0", 200, "AlmostOK");
+ response.write("Error handling request: " + e);
+ }
+}
+
+function reallyHandleRequest(request, response) {
+ var match;
+ var requestAuth = true,
+ requestProxyAuth = true;
+
+ // Allow the caller to drive how authentication is processed via the query.
+ // Eg, http://localhost:8888/authenticate.sjs?user=foo&realm=bar
+ // The extra ? allows the user/pass/realm checks to succeed if the name is
+ // at the beginning of the query string.
+ var query = "?" + request.queryString;
+
+ var expected_user = "",
+ expected_pass = "",
+ realm = "mochitest";
+ var proxy_expected_user = "",
+ proxy_expected_pass = "",
+ proxy_realm = "mochi-proxy";
+ var huge = false,
+ plugin = false,
+ anonymous = false;
+ var authHeaderCount = 1;
+ // user=xxx
+ match = /[^_]user=([^&]*)/.exec(query);
+ if (match) {
+ expected_user = match[1];
+ }
+
+ // pass=xxx
+ match = /[^_]pass=([^&]*)/.exec(query);
+ if (match) {
+ expected_pass = match[1];
+ }
+
+ // realm=xxx
+ match = /[^_]realm=([^&]*)/.exec(query);
+ if (match) {
+ realm = match[1];
+ }
+
+ // proxy_user=xxx
+ match = /proxy_user=([^&]*)/.exec(query);
+ if (match) {
+ proxy_expected_user = match[1];
+ }
+
+ // proxy_pass=xxx
+ match = /proxy_pass=([^&]*)/.exec(query);
+ if (match) {
+ proxy_expected_pass = match[1];
+ }
+
+ // proxy_realm=xxx
+ match = /proxy_realm=([^&]*)/.exec(query);
+ if (match) {
+ proxy_realm = match[1];
+ }
+
+ // huge=1
+ match = /huge=1/.exec(query);
+ if (match) {
+ huge = true;
+ }
+
+ // plugin=1
+ match = /plugin=1/.exec(query);
+ if (match) {
+ plugin = true;
+ }
+
+ // multiple=1
+ match = /multiple=([^&]*)/.exec(query);
+ if (match) {
+ authHeaderCount = match[1] + 0;
+ }
+
+ // anonymous=1
+ match = /anonymous=1/.exec(query);
+ if (match) {
+ anonymous = true;
+ }
+
+ // Look for an authentication header, if any, in the request.
+ //
+ // EG: Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
+ //
+ // This test only supports Basic auth. The value sent by the client is
+ // "username:password", obscured with base64 encoding.
+
+ var actual_user = "",
+ actual_pass = "",
+ authHeader,
+ authPresent = false;
+ if (request.hasHeader("Authorization")) {
+ authPresent = true;
+ authHeader = request.getHeader("Authorization");
+ match = /Basic (.+)/.exec(authHeader);
+ if (match.length != 2) {
+ throw Error("Couldn't parse auth header: " + authHeader);
+ }
+
+ var userpass = atob(match[1]);
+ match = /(.*):(.*)/.exec(userpass);
+ if (match.length != 3) {
+ throw Error("Couldn't decode auth header: " + userpass);
+ }
+ actual_user = match[1];
+ actual_pass = match[2];
+ }
+
+ var proxy_actual_user = "",
+ proxy_actual_pass = "";
+ if (request.hasHeader("Proxy-Authorization")) {
+ authHeader = request.getHeader("Proxy-Authorization");
+ match = /Basic (.+)/.exec(authHeader);
+ if (match.length != 2) {
+ throw Error("Couldn't parse auth header: " + authHeader);
+ }
+
+ userpass = atob(match[1]);
+ match = /(.*):(.*)/.exec(userpass);
+ if (match.length != 3) {
+ throw Error("Couldn't decode auth header: " + userpass);
+ }
+ proxy_actual_user = match[1];
+ proxy_actual_pass = match[2];
+ }
+
+ // Don't request authentication if the credentials we got were what we
+ // expected.
+ if (expected_user == actual_user && expected_pass == actual_pass) {
+ requestAuth = false;
+ }
+ if (
+ proxy_expected_user == proxy_actual_user &&
+ proxy_expected_pass == proxy_actual_pass
+ ) {
+ requestProxyAuth = false;
+ }
+
+ if (anonymous) {
+ if (authPresent) {
+ response.setStatusLine(
+ "1.0",
+ 400,
+ "Unexpected authorization header found"
+ );
+ } else {
+ response.setStatusLine("1.0", 200, "Authorization header not found");
+ }
+ } else if (requestProxyAuth) {
+ response.setStatusLine("1.0", 407, "Proxy authentication required");
+ for (let i = 0; i < authHeaderCount; ++i) {
+ response.setHeader(
+ "Proxy-Authenticate",
+ 'basic realm="' + proxy_realm + '"',
+ true
+ );
+ }
+ } else if (requestAuth) {
+ response.setStatusLine("1.0", 401, "Authentication required");
+ for (let i = 0; i < authHeaderCount; ++i) {
+ response.setHeader(
+ "WWW-Authenticate",
+ 'basic realm="' + realm + '"',
+ true
+ );
+ }
+ } else {
+ response.setStatusLine("1.0", 200, "OK");
+ }
+
+ response.setHeader("Content-Type", "application/xhtml+xml", false);
+ response.write("<html xmlns='http://www.w3.org/1999/xhtml'>");
+ response.write(
+ "<p>Login: <span id='ok'>" +
+ (requestAuth ? "FAIL" : "PASS") +
+ "</span></p>\n"
+ );
+ response.write(
+ "<p>Proxy: <span id='proxy'>" +
+ (requestProxyAuth ? "FAIL" : "PASS") +
+ "</span></p>\n"
+ );
+ response.write("<p>Auth: <span id='auth'>" + authHeader + "</span></p>\n");
+ response.write("<p>User: <span id='user'>" + actual_user + "</span></p>\n");
+ response.write("<p>Pass: <span id='pass'>" + actual_pass + "</span></p>\n");
+
+ if (huge) {
+ response.write("<div style='display: none'>");
+ for (let i = 0; i < 100000; i++) {
+ response.write("123456789\n");
+ }
+ response.write("</div>");
+ response.write(
+ "<span id='footnote'>This is a footnote after the huge content fill</span>"
+ );
+ }
+
+ if (plugin) {
+ response.write(
+ "<embed id='embedtest' style='width: 400px; height: 100px;' " +
+ "type='application/x-test'></embed>\n"
+ );
+ }
+
+ response.write("</html>");
+}
diff --git a/toolkit/components/thumbnails/test/background_red.html b/toolkit/components/thumbnails/test/background_red.html
new file mode 100644
index 0000000000..95159dd297
--- /dev/null
+++ b/toolkit/components/thumbnails/test/background_red.html
@@ -0,0 +1,3 @@
+<html>
+ <body bgcolor=ff0000></body>
+</html>
diff --git a/toolkit/components/thumbnails/test/background_red_redirect.sjs b/toolkit/components/thumbnails/test/background_red_redirect.sjs
new file mode 100644
index 0000000000..168772443d
--- /dev/null
+++ b/toolkit/components/thumbnails/test/background_red_redirect.sjs
@@ -0,0 +1,13 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function handleRequest(aRequest, aResponse) {
+ // Set HTTP Status.
+ aResponse.setStatusLine(aRequest.httpVersion, 301, "Moved Permanently");
+
+ // Set redirect URI.
+ aResponse.setHeader(
+ "Location",
+ "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/background_red.html"
+ );
+}
diff --git a/toolkit/components/thumbnails/test/background_red_scroll.html b/toolkit/components/thumbnails/test/background_red_scroll.html
new file mode 100644
index 0000000000..1e30bd3c67
--- /dev/null
+++ b/toolkit/components/thumbnails/test/background_red_scroll.html
@@ -0,0 +1,3 @@
+<html>
+ <body bgcolor=ff0000 style="overflow: scroll;"></body>
+</html>
diff --git a/toolkit/components/thumbnails/test/browser.ini b/toolkit/components/thumbnails/test/browser.ini
new file mode 100644
index 0000000000..8191b83de2
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser.ini
@@ -0,0 +1,52 @@
+[DEFAULT]
+support-files =
+ authenticate.sjs
+ background_red.html
+ background_red_redirect.sjs
+ background_red_scroll.html
+ sample_image_red_1920x1080.jpg
+ sample_image_green_1024x1024.jpg
+ sample_image_blue_300x600.jpg
+ head.js
+ privacy_cache_control.sjs
+ thumbnails_background.sjs
+ thumbnails_update.sjs
+
+[browser_thumbnails_bg_bad_url.js]
+[browser_thumbnails_bg_crash_during_capture.js]
+skip-if = !crashreporter
+ apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs
+[browser_thumbnails_bg_crash_while_idle.js]
+skip-if = !crashreporter
+[browser_thumbnails_bg_basic.js]
+[browser_thumbnails_bg_queueing.js]
+[browser_thumbnails_bg_timeout.js]
+[browser_thumbnails_bg_redirect.js]
+[browser_thumbnails_bg_destroy_browser.js]
+[browser_thumbnails_bg_no_cookies_sent.js]
+skip-if = verify
+[browser_thumbnails_bg_no_cookies_stored.js]
+skip-if = verify
+[browser_thumbnails_bg_no_auth_prompt.js]
+[browser_thumbnails_bg_no_alert.js]
+[browser_thumbnails_bg_no_duplicates.js]
+[browser_thumbnails_bg_captureIfMissing.js]
+[browser_thumbnails_bg_image_capture.js]
+[browser_thumbnails_bg_topsites.js]
+[browser_thumbnails_bug726727.js]
+[browser_thumbnails_bug727765.js]
+[browser_thumbnails_bug818225.js]
+skip-if = (verify && debug && (os == 'linux'))
+[browser_thumbnails_capture.js]
+[browser_thumbnails_capture_parent_process.js]
+skip-if = true # bug 1314039 # Bug 1345418
+[browser_thumbnails_expiration.js]
+[browser_thumbnails_fullViewport.js]
+[browser_thumbnails_privacy.js]
+[browser_thumbnails_redirect.js]
+[browser_thumbnails_removed_tab.js]
+[browser_thumbnails_storage.js]
+[browser_thumbnails_storage_migrate3.js]
+skip-if = true # Bug 1592079
+[browser_thumbnails_update.js]
+skip-if = os == 'win' #Bug 1539947
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_bad_url.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_bad_url.js
new file mode 100644
index 0000000000..7ebb843cd3
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_bad_url.js
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function thumbnails_bg_bad_url() {
+ let url = "invalid-protocol://ffggfsdfsdf/";
+ ok(!thumbnailExists(url), "Thumbnail should not be cached already.");
+ let numCalls = 0;
+ let observer = bgAddPageThumbObserver(url);
+ await new Promise(resolve => {
+ BackgroundPageThumbs.capture(url, {
+ onDone: function onDone(capturedURL) {
+ is(capturedURL, url, "Captured URL should be URL passed to capture");
+ is(numCalls++, 0, "onDone should be called only once");
+ ok(
+ !thumbnailExists(url),
+ "Capture failed so thumbnail should not be cached"
+ );
+ resolve();
+ },
+ });
+ });
+
+ await Assert.rejects(observer, /page-thumbnail:error/);
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_basic.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_basic.js
new file mode 100644
index 0000000000..1adb2f03f1
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_basic.js
@@ -0,0 +1,15 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function thumbnails_bg_basic() {
+ let url = "http://www.example.com/";
+ ok(!thumbnailExists(url), "Thumbnail should not be cached yet.");
+
+ let capturePromise = bgAddPageThumbObserver(url);
+ let [capturedURL] = await bgCapture(url);
+ is(capturedURL, url, "Captured URL should be URL passed to capture");
+ await capturePromise;
+
+ ok(thumbnailExists(url), "Thumbnail should be cached after capture");
+ removeThumbnail(url);
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_captureIfMissing.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_captureIfMissing.js
new file mode 100644
index 0000000000..a5ccafc801
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_captureIfMissing.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function thumbnails_bg_captureIfMissing() {
+ let numNotifications = 0;
+ function observe(subject, topic, data) {
+ is(topic, "page-thumbnail:create", "got expected topic");
+ numNotifications += 1;
+ }
+
+ Services.obs.addObserver(observe, "page-thumbnail:create");
+
+ let url = "https://example.com/";
+ let file = thumbnailFile(url);
+ ok(!file.exists(), "Thumbnail file should not already exist.");
+
+ let [capturedURL] = await bgCaptureIfMissing(url);
+ is(numNotifications, 1, "got notification of item being created.");
+ is(capturedURL, url, "Captured URL should be URL passed to capture");
+ ok(file.exists(url), "Thumbnail should be cached after capture");
+
+ let past = Date.now() - 1000000000;
+ let pastFudge = past + 30000;
+ file.lastModifiedTime = past;
+ ok(file.lastModifiedTime < pastFudge, "Last modified time should stick!");
+ [capturedURL] = await bgCaptureIfMissing(url);
+ is(numNotifications, 1, "still only 1 notification of item being created.");
+ is(capturedURL, url, "Captured URL should be URL passed to second capture");
+ ok(file.exists(), "Thumbnail should remain cached after second capture");
+ ok(
+ file.lastModifiedTime < pastFudge,
+ "File should not have been overwritten"
+ );
+
+ file.remove(false);
+ Services.obs.removeObserver(observe, "page-thumbnail:create");
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_crash_during_capture.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_crash_during_capture.js
new file mode 100644
index 0000000000..872965d7b2
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_crash_during_capture.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function thumbnails_bg_crash_during_capture() {
+ // make a good capture first - this ensures we have the <browser>
+ let goodUrl = bgTestPageURL();
+ await bgCapture(goodUrl);
+ ok(thumbnailExists(goodUrl), "Thumbnail should be cached after capture");
+ removeThumbnail(goodUrl);
+
+ // queue up 2 captures - the first has a wait, so this is the one that
+ // will die. The second one should immediately capture after the crash.
+ let waitUrl = bgTestPageURL({ wait: 30000 });
+ let sawWaitUrlCapture = false;
+ let failCapture = bgCapture(waitUrl, {
+ onDone: () => {
+ sawWaitUrlCapture = true;
+ ok(
+ !thumbnailExists(waitUrl),
+ "Thumbnail should not have been saved due to the crash"
+ );
+ },
+ });
+ let goodCapture = bgCapture(goodUrl, {
+ onDone: () => {
+ ok(sawWaitUrlCapture, "waitUrl capture should have finished first");
+ ok(
+ thumbnailExists(goodUrl),
+ "We should have recovered and completed the 2nd capture after the crash"
+ );
+ removeThumbnail(goodUrl);
+ },
+ });
+
+ let crashPromise = bgAddPageThumbObserver(waitUrl).catch(err => {
+ ok(/page-thumbnail:error/.test(err), "Got the right kind of error");
+ });
+ let capturePromise = bgAddPageThumbObserver(goodUrl);
+
+ info("Crashing the thumbnail content process.");
+ let crash = await BrowserTestUtils.crashFrame(
+ BackgroundPageThumbs._thumbBrowser,
+ false
+ );
+ ok(crash.CrashTime, "Saw a crash from this test");
+
+ await crashPromise;
+ await Promise.all([failCapture, goodCapture, capturePromise]);
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_crash_while_idle.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_crash_while_idle.js
new file mode 100644
index 0000000000..4808f39e1c
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_crash_while_idle.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function thumbnails_bg_crash_while_idle() {
+ // make a good capture first - this ensures we have the <browser>
+ let goodUrl = bgTestPageURL();
+ await bgCapture(goodUrl);
+ ok(thumbnailExists(goodUrl), "Thumbnail should be cached after capture");
+ removeThumbnail(goodUrl);
+
+ // Nothing is pending - crash the process.
+ info("Crashing the thumbnail content process.");
+ let crash = await BrowserTestUtils.crashFrame(
+ BackgroundPageThumbs._thumbBrowser,
+ false
+ );
+ ok(crash.CrashTime, "Saw a crash from this test");
+
+ // Now queue another capture and ensure it recovers.
+ await bgCapture(goodUrl, {
+ onDone: () => {
+ ok(
+ thumbnailExists(goodUrl),
+ "We should have recovered and handled new capture requests"
+ );
+ removeThumbnail(goodUrl);
+ },
+ });
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_destroy_browser.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_destroy_browser.js
new file mode 100644
index 0000000000..609d344972
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_destroy_browser.js
@@ -0,0 +1,48 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function thumbnails_bg_destroy_browser() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.ipc.processCount", 1]],
+ });
+
+ let url1 = "https://example.com/1";
+ ok(!thumbnailExists(url1), "First file should not exist yet.");
+
+ let url2 = "https://example.com/2";
+ ok(!thumbnailExists(url2), "Second file should not exist yet.");
+
+ let defaultTimeout = BackgroundPageThumbs._destroyBrowserTimeout;
+ BackgroundPageThumbs._destroyBrowserTimeout = 1000;
+
+ await bgCapture(url1);
+ ok(thumbnailExists(url1), "First file should exist after capture.");
+ removeThumbnail(url1);
+
+ // arbitrary wait - intermittent failures noted after 2 seconds
+ await TestUtils.waitForCondition(
+ () => {
+ return BackgroundPageThumbs._thumbBrowser === undefined;
+ },
+ "BackgroundPageThumbs._thumbBrowser should eventually be discarded.",
+ 1000,
+ 5
+ );
+
+ is(
+ BackgroundPageThumbs._thumbBrowser,
+ undefined,
+ "Thumb browser should be destroyed after timeout."
+ );
+ BackgroundPageThumbs._destroyBrowserTimeout = defaultTimeout;
+
+ await bgCapture(url2);
+ ok(thumbnailExists(url2), "Second file should exist after capture.");
+ removeThumbnail(url2);
+
+ isnot(
+ BackgroundPageThumbs._thumbBrowser,
+ undefined,
+ "Thumb browser should exist immediately after capture."
+ );
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_image_capture.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_image_capture.js
new file mode 100644
index 0000000000..03f30c544a
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_image_capture.js
@@ -0,0 +1,95 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const BASE_URL =
+ "http://mochi.test:8888/browser/toolkit/components/thumbnails/";
+
+/**
+ * These tests ensure that when trying to capture a url that is an image file,
+ * the image itself is captured instead of the the browser window displaying the
+ * image, and that the thumbnail maintains the image aspect ratio.
+ */
+add_task(async function thumbnails_bg_image_capture() {
+ // Test that malformed input causes _finishCurrentCapture to be called with
+ // the correct reason.
+ const emptyUrl = "data:text/plain,";
+ await bgCapture(emptyUrl, {
+ isImage: true,
+ onDone: (url, reason) => {
+ // BackgroundPageThumbs.TEL_CAPTURE_DONE_LOAD_FAILED === 6
+ is(reason, 6, "Should have the right failure reason");
+ },
+ });
+
+ for (const { url, color, width, height } of [
+ {
+ url: BASE_URL + "test/sample_image_red_1920x1080.jpg",
+ color: [255, 0, 0],
+ width: 1920,
+ height: 1080,
+ },
+ {
+ url: BASE_URL + "test/sample_image_green_1024x1024.jpg",
+ color: [0, 255, 0],
+ width: 1024,
+ height: 1024,
+ },
+ {
+ url: BASE_URL + "test/sample_image_blue_300x600.jpg",
+ color: [0, 0, 255],
+ width: 300,
+ height: 600,
+ },
+ ]) {
+ dontExpireThumbnailURLs([url]);
+ const capturedPromise = bgAddPageThumbObserver(url);
+ await bgCapture(url);
+ await capturedPromise;
+ ok(thumbnailExists(url), "The image thumbnail should exist after capture");
+
+ const thumb = PageThumbs.getThumbnailURL(url);
+ const htmlns = "http://www.w3.org/1999/xhtml";
+ const img = document.createElementNS(htmlns, "img");
+ img.src = thumb;
+ await BrowserTestUtils.waitForEvent(img, "load");
+
+ // 448px is the default max-width of an image thumbnail
+ const expectedWidth = Math.min(448, width);
+ // Tall images are clipped to {width}x{width}
+ const expectedHeight = Math.min(
+ (expectedWidth * height) / width,
+ expectedWidth
+ );
+ // Fuzzy equality to account for rounding
+ ok(
+ Math.abs(img.naturalWidth - expectedWidth) <= 1,
+ "The thumbnail should have the right width"
+ );
+ ok(
+ Math.abs(img.naturalHeight - expectedHeight) <= 1,
+ "The thumbnail should have the right height"
+ );
+
+ // Draw the image to a canvas and compare the pixel color values.
+ const canvas = document.createElementNS(htmlns, "canvas");
+ canvas.width = expectedWidth;
+ canvas.height = expectedHeight;
+ const ctx = canvas.getContext("2d");
+ ctx.drawImage(img, 0, 0, expectedWidth, expectedHeight);
+ const [r, g, b] = ctx.getImageData(
+ 0,
+ 0,
+ expectedWidth,
+ expectedHeight
+ ).data;
+ // Fuzzy equality to account for image encoding
+ ok(
+ Math.abs(r - color[0]) <= 2 &&
+ Math.abs(g - color[1]) <= 2 &&
+ Math.abs(b - color[2]) <= 2,
+ "The thumbnail should have the right color"
+ );
+
+ removeThumbnail(url);
+ }
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_alert.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_alert.js
new file mode 100644
index 0000000000..e0dc7f353c
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_alert.js
@@ -0,0 +1,16 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function thumbnails_bg_no_alert() {
+ let url =
+ "data:text/html,<script>try { alert('yo!'); } catch (e) {}</script>";
+ ok(!thumbnailExists(url), "Thumbnail file should not already exist.");
+
+ let [capturedURL] = await bgCapture(url);
+ is(capturedURL, url, "Captured URL should be URL passed to capture.");
+ ok(
+ thumbnailExists(url),
+ "Thumbnail file should exist even though it alerted."
+ );
+ removeThumbnail(url);
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_auth_prompt.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_auth_prompt.js
new file mode 100644
index 0000000000..e9dd51fa7d
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_auth_prompt.js
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * The following tests attempt to display modal dialogs. The test just
+ * relies on the fact that if the dialog was displayed the test will hang
+ * and timeout. IOW - the tests would pass if the dialogs appear and are
+ * manually closed by the user - so don't do that :) (obviously there is
+ * noone available to do that when run via tbpl etc, so this should be safe,
+ * and it's tricky to use the window-watcher to check a window *does not*
+ * appear - how long should the watcher be active before assuming it's not
+ * going to appear?)
+ */
+add_task(async function thumbnails_bg_no_auth_prompt() {
+ let url =
+ "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/authenticate.sjs?user=anyone";
+ ok(!thumbnailExists(url), "Thumbnail file should not already exist.");
+
+ let [capturedURL] = await bgCapture(url);
+ is(capturedURL, url, "Captured URL should be URL passed to capture.");
+ ok(
+ thumbnailExists(url),
+ "Thumbnail file should exist even though it requires auth."
+ );
+ removeThumbnail(url);
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_cookies_sent.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_cookies_sent.js
new file mode 100644
index 0000000000..9558e98223
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_cookies_sent.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function thumbnails_bg_no_cookies_sent() {
+ // Visit the test page in the browser and tell it to set a cookie.
+ let url = bgTestPageURL({ setGreenCookie: true });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url,
+ },
+ async browser => {
+ // The root element of the page shouldn't be green yet.
+ await SpecialPowers.spawn(browser, [], function () {
+ Assert.notEqual(
+ content.document.documentElement.style.backgroundColor,
+ "rgb(0, 255, 0)",
+ "The page shouldn't be green yet."
+ );
+ });
+
+ // Cookie should be set now. Reload the page to verify. Its root element
+ // will be green if the cookie's set.
+ browser.reload();
+ await BrowserTestUtils.browserLoaded(browser);
+ await SpecialPowers.spawn(browser, [], function () {
+ Assert.equal(
+ content.document.documentElement.style.backgroundColor,
+ "rgb(0, 255, 0)",
+ "The page should be green now."
+ );
+ });
+
+ // Capture the page. Get the image data of the capture and verify it's not
+ // green. (Checking only the first pixel suffices.)
+ await bgCapture(url);
+ ok(thumbnailExists(url), "Thumbnail file should exist after capture.");
+
+ let [r, g, b] = await retrieveImageDataForURL(url);
+ isnot(
+ [r, g, b].toString(),
+ [0, 255, 0].toString(),
+ "The captured page should not be green."
+ );
+ removeThumbnail(url);
+ }
+ );
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_cookies_stored.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_cookies_stored.js
new file mode 100644
index 0000000000..f7acf96ba9
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_cookies_stored.js
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Check that if a page captured in the background attempts to set a cookie,
+ * that cookie is not saved for subsequent requests.
+ */
+add_task(async function thumbnails_bg_no_cookies_stored() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.usercontext.about_newtab_segregation.enabled", true]],
+ });
+ let url = bgTestPageURL({
+ setRedCookie: true,
+ iframe: bgTestPageURL({ setRedCookie: true }),
+ xhr: bgTestPageURL({ setRedCookie: true }),
+ });
+ ok(!thumbnailExists(url), "Thumbnail file should not exist before capture.");
+ await bgCapture(url);
+ ok(thumbnailExists(url), "Thumbnail file should exist after capture.");
+ removeThumbnail(url);
+ // now load it up in a browser - it should *not* be red, otherwise the
+ // cookie above was saved.
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url,
+ },
+ async browser => {
+ // The root element of the page shouldn't be red.
+ await SpecialPowers.spawn(browser, [], function () {
+ Assert.notEqual(
+ content.document.documentElement.style.backgroundColor,
+ "rgb(255, 0, 0)",
+ "The page shouldn't be red."
+ );
+ });
+ }
+ );
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_duplicates.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_duplicates.js
new file mode 100644
index 0000000000..e4b3b42849
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_duplicates.js
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function thumbnails_bg_no_duplicates() {
+ let url = "https://example.com/1";
+ ok(!thumbnailExists(url), "Thumbnail file should not already exist.");
+
+ let firstCapture = bgCapture(url, {
+ onDone: doneUrl => {
+ is(doneUrl, url, "called back with correct url");
+ ok(thumbnailExists(url), "Thumbnail file should now exist.");
+ removeThumbnail(url);
+ },
+ });
+
+ let secondCapture = bgCapture(url, {
+ onDone: doneUrl => {
+ is(doneUrl, url, "called back with correct url");
+ ok(!thumbnailExists(url), "Thumbnail file should still be deleted.");
+ },
+ });
+
+ await Promise.all([firstCapture, secondCapture]);
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_queueing.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_queueing.js
new file mode 100644
index 0000000000..92e8711637
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_queueing.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function thumbnails_bg_queueing() {
+ let urls = [
+ "https://www.example.com/0",
+ "https://www.example.com/1",
+ // an item that will timeout to ensure timeouts work and we resume.
+ bgTestPageURL({ wait: 2002 }),
+ "https://www.example.com/2",
+ ];
+ dontExpireThumbnailURLs(urls);
+
+ let promises = [];
+
+ for (let url of urls) {
+ ok(!thumbnailExists(url), "Thumbnail should not exist yet: " + url);
+ let isTimeoutTest = url.includes("wait");
+
+ let promise = bgCapture(url, {
+ timeout: isTimeoutTest ? 100 : 30000,
+ onDone: capturedURL => {
+ ok(!!urls.length, "onDone called, so URLs should still remain");
+ is(
+ capturedURL,
+ urls.shift(),
+ "Captured URL should be currently expected URL (i.e., " +
+ "capture() callbacks should be called in the correct order)"
+ );
+ if (isTimeoutTest) {
+ ok(
+ !thumbnailExists(capturedURL),
+ "Thumbnail shouldn't exist for timed out capture"
+ );
+ } else {
+ ok(
+ thumbnailExists(capturedURL),
+ "Thumbnail should be cached after capture"
+ );
+ removeThumbnail(url);
+ }
+ },
+ });
+
+ promises.push(promise);
+ }
+
+ await Promise.all(promises);
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_redirect.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_redirect.js
new file mode 100644
index 0000000000..ae0096ff18
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_redirect.js
@@ -0,0 +1,32 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function thumbnails_bg_redirect() {
+ let finalURL = "https://example.com/redirected";
+ let originalURL = bgTestPageURL({ redirect: finalURL });
+
+ ok(
+ !thumbnailExists(originalURL),
+ "Thumbnail file for original URL should not exist yet."
+ );
+ ok(
+ !thumbnailExists(finalURL),
+ "Thumbnail file for final URL should not exist yet."
+ );
+
+ let captureOriginalPromise = bgAddPageThumbObserver(originalURL);
+ let captureFinalPromise = bgAddPageThumbObserver(finalURL);
+
+ let [capturedURL] = await bgCapture(originalURL);
+ is(capturedURL, originalURL, "Captured URL should be URL passed to capture");
+ await captureOriginalPromise;
+ await captureFinalPromise;
+ ok(
+ thumbnailExists(originalURL),
+ "Thumbnail for original URL should be cached"
+ );
+ ok(thumbnailExists(finalURL), "Thumbnail for final URL should be cached");
+
+ removeThumbnail(originalURL);
+ removeThumbnail(finalURL);
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_timeout.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_timeout.js
new file mode 100644
index 0000000000..4deabaf325
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_timeout.js
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function thumbnails_bg_timeout() {
+ let url = bgTestPageURL({ wait: 30000 });
+ ok(!thumbnailExists(url), "Thumbnail should not be cached already.");
+ let numCalls = 0;
+ let thumbnailErrorPromise = bgAddPageThumbObserver(url);
+
+ await bgCapture(url, {
+ timeout: 0,
+ onDone: capturedURL => {
+ is(capturedURL, url, "Captured URL should be URL passed to capture");
+ is(numCalls++, 0, "onDone should be called only once");
+ ok(
+ !thumbnailExists(url),
+ "Capture timed out so thumbnail should not be cached"
+ );
+ },
+ });
+
+ await Assert.rejects(thumbnailErrorPromise, /page-thumbnail:error/);
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_topsites.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_topsites.js
new file mode 100644
index 0000000000..fac37aff4a
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_topsites.js
@@ -0,0 +1,76 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const image1x1 =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg==";
+const image96x96 =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAAAJCAYAAADNYymqAAAAKklEQVR42u3RMQEAAAjDMFCO9CGDg1RC00lN6awGAACADQAACAAAAXjXApPGFm+IdJG9AAAAAElFTkSuQmCC";
+const baseURL = "http://mozilla${i}.com/";
+
+add_task(async function thumbnails_bg_topsites() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts",
+ false,
+ ],
+ ],
+ });
+ // Add 3 top sites - 2 visits each so it can pass frecency threshold of the top sites query
+ for (let i = 1; i <= 3; i++) {
+ await PlacesTestUtils.addVisits(baseURL.replace("${i}", i));
+ await PlacesTestUtils.addVisits(baseURL.replace("${i}", i));
+ }
+
+ // Add favicon data for 2 of the top sites
+ let faviconData = new Map();
+ faviconData.set("http://mozilla1.com/", image1x1);
+ faviconData.set("http://mozilla2.com/", image96x96);
+ await PlacesTestUtils.addFavicons(faviconData);
+
+ // Sanity check that we've successfully added all 3 urls to top sites
+ let links = await NewTabUtils.activityStreamLinks.getTopSites();
+ is(
+ links[0].url,
+ baseURL.replace("${i}", 3),
+ "Top site has been successfully added"
+ );
+ is(
+ links[1].url,
+ baseURL.replace("${i}", 2),
+ "Top site has been successfully added"
+ );
+ is(
+ links[2].url,
+ baseURL.replace("${i}", 1),
+ "Top site has been successfully added"
+ );
+
+ // Now, add a pinned site so we can also fetch a screenshot for that
+ const pinnedSite = { url: baseURL.replace("${i}", 4) };
+ NewTabUtils.pinnedLinks.pin(pinnedSite, 0);
+
+ // Check that the correct sites will capture screenshots
+ gBrowserThumbnails.clearTopSiteURLCache();
+ let topSites = await gBrowserThumbnails._topSiteURLs;
+ ok(
+ topSites.includes("http://mozilla1.com/"),
+ "Top site did not have a rich icon - get a screenshot"
+ );
+ ok(
+ topSites.includes("http://mozilla3.com/"),
+ "Top site did not have an icon - get a screenshot"
+ );
+ ok(
+ topSites.includes("http://mozilla4.com/"),
+ "Site is pinned - get a screenshot"
+ );
+ ok(
+ !topSites.includes("http://mozilla2.com/"),
+ "Top site had a rich icon - do not get a screenshot"
+ );
+
+ // Clean up
+ NewTabUtils.pinnedLinks.unpin(pinnedSite);
+ await PlacesUtils.history.clear();
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bug726727.js b/toolkit/components/thumbnails/test/browser_thumbnails_bug726727.js
new file mode 100644
index 0000000000..b7fc5f0ac6
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bug726727.js
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * These tests ensure that capturing a sites's thumbnail, saving it and
+ * retrieving it from the cache works.
+ */
+add_task(async function thumbnails_bg_bug726727() {
+ // Create a tab that shows an error page.
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ },
+ async browser => {
+ let errorPageLoaded = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ null,
+ true
+ );
+ BrowserTestUtils.loadURIString(browser, "http://127.0.0.1:1");
+ await errorPageLoaded;
+ let result = await PageThumbs.shouldStoreThumbnail(browser);
+ ok(!result, "we're not going to capture an error page");
+ }
+ );
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bug727765.js b/toolkit/components/thumbnails/test/browser_thumbnails_bug727765.js
new file mode 100644
index 0000000000..8e953e0ffa
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bug727765.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const URL =
+ "http://mochi.test:8888/browser/toolkit/components/thumbnails/" +
+ "test/background_red_scroll.html";
+
+function isRedThumbnailFuzz(r, g, b, expectedR, expectedB, expectedG, aFuzz) {
+ return (
+ Math.abs(r - expectedR) <= aFuzz &&
+ Math.abs(g - expectedG) <= aFuzz &&
+ Math.abs(b - expectedB) <= aFuzz
+ );
+}
+
+// Test for black borders caused by scrollbars.
+add_task(async function thumbnails_bg_bug727765() {
+ // Create a tab with a page with a red background and scrollbars.
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: URL,
+ },
+ async browser => {
+ await captureAndCheckColor(255, 0, 0, "we have a red thumbnail");
+
+ // Check the thumbnail color of the bottom right pixel.
+ await whenFileExists(URL);
+
+ let data = await retrieveImageDataForURL(URL);
+ let [r, g, b] = [].slice.call(data, -4);
+ let fuzz = 2; // Windows 8 x64 blends with the scrollbar a bit.
+ var message =
+ "Expected red thumbnail rgb(255, 0, 0), got " + r + "," + g + "," + b;
+ ok(isRedThumbnailFuzz(r, g, b, 255, 0, 0, fuzz), message);
+ }
+ );
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bug818225.js b/toolkit/components/thumbnails/test/browser_thumbnails_bug818225.js
new file mode 100644
index 0000000000..5f557c40a4
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_bug818225.js
@@ -0,0 +1,42 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const URL =
+ "http://mochi.test:8888/browser/toolkit/components/thumbnails/" +
+ "test/background_red.html?" +
+ Date.now();
+
+// Test PageThumbs API function getThumbnailPath
+add_task(async function thumbnails_bg_bug818225() {
+ let path = PageThumbs.getThumbnailPath(URL);
+ await testIfExists(path, false, "Thumbnail file does not exist");
+ await promiseAddVisitsAndRepopulateNewTabLinks(URL);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: URL,
+ },
+ async browser => {
+ gBrowserThumbnails.clearTopSiteURLCache();
+ await whenFileExists(URL);
+ }
+ );
+
+ path = PageThumbs.getThumbnailPath(URL);
+ let expectedPath = PageThumbsStorageService.getFilePathForURL(URL);
+ is(path, expectedPath, "Thumbnail file has correct path");
+
+ await testIfExists(path, true, "Thumbnail file exists");
+});
+
+function testIfExists(aPath, aExpected, aMessage) {
+ return IOUtils.exists(aPath).then(
+ function onSuccess(exists) {
+ is(exists, aExpected, aMessage);
+ },
+ function onFailure(error) {
+ ok(false, `IOUtils.exists() failed ${error}`);
+ }
+ );
+}
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_capture.js b/toolkit/components/thumbnails/test/browser_thumbnails_capture.js
new file mode 100644
index 0000000000..1fe7a66fe5
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_capture.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * These tests ensure that capturing a sites's thumbnail, saving it and
+ * retrieving it from the cache works.
+ */
+add_task(async function thumbnails_capture() {
+ // Create a tab with a red background.
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "data:text/html,<body bgcolor=ff0000></body>",
+ },
+ async browser => {
+ await captureAndCheckColor(255, 0, 0, "we have a red thumbnail");
+
+ // Load a page with a green background.
+ let loaded = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.loadURIString(
+ browser,
+ "data:text/html,<body bgcolor=00ff00></body>"
+ );
+ await loaded;
+ await captureAndCheckColor(0, 255, 0, "we have a green thumbnail");
+
+ // Load a page with a blue background.
+ loaded = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.loadURIString(
+ browser,
+ "data:text/html,<body bgcolor=0000ff></body>"
+ );
+ await loaded;
+ await captureAndCheckColor(0, 0, 255, "we have a blue thumbnail");
+ }
+ );
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_capture_parent_process.js b/toolkit/components/thumbnails/test/browser_thumbnails_capture_parent_process.js
new file mode 100644
index 0000000000..67cd6a79e2
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_capture_parent_process.js
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * These tests ensure that capturing a sites's thumbnail, saving it and
+ * retrieving it from the cache works, specifically for a parent process.
+ */
+add_task(async function thumbnails_capture() {
+ // Create a parent process tab with a red background.
+ // We do this by creating a parent process, then we update it to be a red page,
+ // before attempting to read the page colour.
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ // about:robots seems to be an simple parent process url we can test against,
+ // but any parent process url would have worked, example, about:home or about:config
+ url: "about:robots",
+ },
+ async browser => {
+ // Because about:robots is not generally a predictable testable page,
+ // we update its background to something we can test against.
+ browser.contentDocument.body.innerHTML = "";
+ browser.contentDocument.body.style.backgroundColor = "#ff0000";
+ await captureAndCheckColor(255, 0, 0, "we have a red thumbnail");
+ }
+ );
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_expiration.js b/toolkit/components/thumbnails/test/browser_thumbnails_expiration.js
new file mode 100644
index 0000000000..4be93ebb16
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_expiration.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const URL = "http://mochi.test:8888/?t=" + Date.now();
+const URL1 = URL + "#1";
+const URL2 = URL + "#2";
+const URL3 = URL + "#3";
+
+const EXPIRATION_MIN_CHUNK_SIZE = 50;
+const { PageThumbsExpiration } = ChromeUtils.importESModule(
+ "resource://gre/modules/PageThumbs.sys.mjs"
+);
+
+add_task(async function thumbnails_expiration() {
+ // Create dummy URLs.
+ let dummyURLs = [];
+ for (let i = 0; i < EXPIRATION_MIN_CHUNK_SIZE + 10; i++) {
+ dummyURLs.push(URL + "#dummy" + i);
+ }
+
+ // Make sure our thumbnails aren't expired too early.
+ dontExpireThumbnailURLs([URL1, URL2, URL3].concat(dummyURLs));
+
+ // Create three thumbnails.
+ await createDummyThumbnail(URL1);
+ ok(thumbnailExists(URL1), "first thumbnail created");
+
+ await createDummyThumbnail(URL2);
+ ok(thumbnailExists(URL2), "second thumbnail created");
+
+ await createDummyThumbnail(URL3);
+ ok(thumbnailExists(URL3), "third thumbnail created");
+
+ // Remove the third thumbnail.
+ await expireThumbnails([URL1, URL2]);
+ ok(thumbnailExists(URL1), "first thumbnail still exists");
+ ok(thumbnailExists(URL2), "second thumbnail still exists");
+ ok(!thumbnailExists(URL3), "third thumbnail has been removed");
+
+ // Remove the second thumbnail.
+ await expireThumbnails([URL1]);
+ ok(thumbnailExists(URL1), "first thumbnail still exists");
+ ok(!thumbnailExists(URL2), "second thumbnail has been removed");
+
+ // Remove all thumbnails.
+ await expireThumbnails([]);
+ ok(!thumbnailExists(URL1), "all thumbnails have been removed");
+
+ // Create some more files than the min chunk size.
+ for (let url of dummyURLs) {
+ await createDummyThumbnail(url);
+ }
+
+ ok(dummyURLs.every(thumbnailExists), "all dummy thumbnails created");
+
+ // Expire thumbnails and expect 10 remaining.
+ await expireThumbnails([]);
+ let remainingURLs = dummyURLs.filter(thumbnailExists);
+ is(remainingURLs.length, 10, "10 dummy thumbnails remaining");
+
+ // Expire thumbnails again. All should be gone by now.
+ await expireThumbnails([]);
+ remainingURLs = remainingURLs.filter(thumbnailExists);
+ is(remainingURLs.length, 0, "no dummy thumbnails remaining");
+});
+
+function createDummyThumbnail(aURL) {
+ info("Creating dummy thumbnail for " + aURL);
+ let dummy = new Uint8Array(10);
+ for (let i = 0; i < 10; ++i) {
+ dummy[i] = i;
+ }
+ return PageThumbsStorage.writeData(aURL, dummy);
+}
+
+function expireThumbnails(aKeep) {
+ return PageThumbsExpiration.expireThumbnails(aKeep);
+}
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_fullViewport.js b/toolkit/components/thumbnails/test/browser_thumbnails_fullViewport.js
new file mode 100644
index 0000000000..92533bdd3d
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_fullViewport.js
@@ -0,0 +1,74 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const BUILDER_URL = "https://example.com/document-builder.sjs?html=";
+const PAGE_MARKUP = `
+<html>
+ <head>
+ <style>
+ body {
+ background-color: rgb(0, 255, 0);
+ margin: 0;
+ }
+
+ div {
+ background-color: rgb(255, 0, 0);
+ height: 100vh;
+ width: 100vw;
+ margin-top: 100vh;
+ }
+ </style>
+ </head>
+ <body>
+ <div id="bigredblock"></div>
+ </body>
+</html>
+`;
+const PAGE_URL = BUILDER_URL + encodeURI(PAGE_MARKUP);
+
+/**
+ * These tests ensure that it's possible to capture the full viewport of
+ * a browser, and not just the top region.
+ */
+add_task(async function thumbnails_fullViewport_capture() {
+ // Create a tab with a green background.
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE_URL,
+ },
+ async browser => {
+ let canvas = PageThumbs.createCanvas(window);
+ await PageThumbs.captureToCanvas(browser, canvas, {
+ fullViewport: true,
+ });
+
+ // The red region isn't scrolled in yet, so we should get
+ // a green thumbnail.
+ let ctx = canvas.getContext("2d");
+ let [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
+ Assert.equal(r, 0, "No red channel");
+ Assert.equal(g, 255, "Full green channel");
+ Assert.equal(b, 0, "No blue channel");
+
+ // Now scroll the red region into view.
+ await SpecialPowers.spawn(browser, [], () => {
+ let redblock = content.document.getElementById("bigredblock");
+ redblock.scrollIntoView(true);
+ });
+
+ await PageThumbs.captureToCanvas(browser, canvas, {
+ fullViewport: true,
+ });
+
+ // The captured region should be red.
+ ctx = canvas.getContext("2d");
+ [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
+ Assert.equal(r, 255, "Full red channel");
+ Assert.equal(g, 0, "No green channel");
+ Assert.equal(b, 0, "No blue channel");
+ }
+ );
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_privacy.js b/toolkit/components/thumbnails/test/browser_thumbnails_privacy.js
new file mode 100644
index 0000000000..d03297f69d
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_privacy.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const PREF_DISK_CACHE_SSL = "browser.cache.disk_cache_ssl";
+const URL =
+ "://example.com/browser/toolkit/components/thumbnails/" +
+ "test/privacy_cache_control.sjs";
+
+add_task(async function thumbnails_privacy() {
+ registerCleanupFunction(function () {
+ Services.prefs.clearUserPref(PREF_DISK_CACHE_SSL);
+ });
+
+ let positive = [
+ // A normal HTTP page without any Cache-Control header.
+ { scheme: "http", cacheControl: null, diskCacheSSL: false },
+
+ // A normal HTTP page with 'Cache-Control: private'.
+ { scheme: "http", cacheControl: "private", diskCacheSSL: false },
+
+ // Capture HTTPS pages if browser.cache.disk_cache_ssl == true.
+ { scheme: "https", cacheControl: null, diskCacheSSL: true },
+ { scheme: "https", cacheControl: "public", diskCacheSSL: true },
+ { scheme: "https", cacheControl: "private", diskCacheSSL: true },
+ ];
+
+ let negative = [
+ // Never capture pages with 'Cache-Control: no-store'.
+ { scheme: "http", cacheControl: "no-store", diskCacheSSL: false },
+ { scheme: "http", cacheControl: "no-store", diskCacheSSL: true },
+ { scheme: "https", cacheControl: "no-store", diskCacheSSL: false },
+ { scheme: "https", cacheControl: "no-store", diskCacheSSL: true },
+
+ // Don't capture HTTPS pages by default.
+ { scheme: "https", cacheControl: null, diskCacheSSL: false },
+ { scheme: "https", cacheControl: "public", diskCacheSSL: false },
+ { scheme: "https", cacheControl: "private", diskCacheSSL: false },
+ ];
+
+ await checkCombinations(positive, true);
+ await checkCombinations(negative, false);
+});
+
+async function checkCombinations(aCombinations, aResult) {
+ for (let combination of aCombinations) {
+ let url = combination.scheme + URL;
+ if (combination.cacheControl) {
+ url += "?" + combination.cacheControl;
+ }
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_DISK_CACHE_SSL, combination.diskCacheSSL]],
+ });
+
+ // Add the test page as a top link so it has a chance to be thumbnailed
+ await promiseAddVisitsAndRepopulateNewTabLinks(url);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url,
+ },
+ async browser => {
+ let msg = JSON.stringify(combination) + " == " + aResult;
+ let aIsSafeSite = await PageThumbs.shouldStoreThumbnail(browser);
+ Assert.equal(aIsSafeSite, aResult, msg);
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+ }
+}
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_redirect.js b/toolkit/components/thumbnails/test/browser_thumbnails_redirect.js
new file mode 100644
index 0000000000..b77b4011c3
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_redirect.js
@@ -0,0 +1,42 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const URL =
+ "http://mochi.test:8888/browser/toolkit/components/thumbnails/" +
+ "test/background_red_redirect.sjs";
+// loading URL will redirect us to...
+const FINAL_URL =
+ "http://mochi.test:8888/browser/toolkit/components/" +
+ "thumbnails/test/background_red.html";
+
+/**
+ * These tests ensure that we save and provide thumbnails for redirecting sites.
+ */
+add_task(async function thumbnails_redirect() {
+ dontExpireThumbnailURLs([URL, FINAL_URL]);
+
+ // Kick off history by loading a tab first or the test fails in single mode.
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: URL,
+ },
+ browser => {}
+ );
+
+ // Create a tab, redirecting to a page with a red background.
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: URL,
+ },
+ async browser => {
+ await captureAndCheckColor(255, 0, 0, "we have a red thumbnail");
+
+ // Wait until the referrer's thumbnail's file has been written.
+ await whenFileExists(URL);
+ let [r, g, b] = await retrieveImageDataForURL(URL);
+ is("" + [r, g, b], "255,0,0", "referrer has a red thumbnail");
+ }
+ );
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_removed_tab.js b/toolkit/components/thumbnails/test/browser_thumbnails_removed_tab.js
new file mode 100644
index 0000000000..9d2138d3d0
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_removed_tab.js
@@ -0,0 +1,13 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function thumbnails_bug1775638() {
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+ const canvas = document.createElement("canvas");
+ const promise = PageThumbs.captureToCanvas(tab.linkedBrowser, canvas);
+ gBrowser.removeTab(tab);
+ is(await promise, canvas);
+});
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_storage.js b/toolkit/components/thumbnails/test/browser_thumbnails_storage.js
new file mode 100644
index 0000000000..6a9f1ed3f6
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_storage.js
@@ -0,0 +1,97 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const URL = "http://mochi.test:8888/";
+const URL_COPY = URL + "#copy";
+
+/**
+ * These tests ensure that the thumbnail storage is working as intended.
+ * Newly captured thumbnails should be saved as files and they should as well
+ * be removed when the user sanitizes their history.
+ */
+add_task(async function thumbnails_storage() {
+ dontExpireThumbnailURLs([URL, URL_COPY]);
+
+ await promiseClearHistory();
+ await promiseAddVisitsAndRepopulateNewTabLinks(URL);
+ await promiseCreateThumbnail();
+
+ // Make sure Storage.copy() updates an existing file.
+ await PageThumbsStorage.copy(URL, URL_COPY);
+ let copy = new FileUtils.File(
+ PageThumbsStorageService.getFilePathForURL(URL_COPY)
+ );
+ let mtime = (copy.lastModifiedTime -= 60);
+
+ await PageThumbsStorage.copy(URL, URL_COPY);
+ isnot(
+ new FileUtils.File(PageThumbsStorageService.getFilePathForURL(URL_COPY))
+ .lastModifiedTime,
+ mtime,
+ "thumbnail file was updated"
+ );
+
+ let file = new FileUtils.File(
+ PageThumbsStorageService.getFilePathForURL(URL)
+ );
+ let fileCopy = new FileUtils.File(
+ PageThumbsStorageService.getFilePathForURL(URL_COPY)
+ );
+
+ // Clear the browser history. Retry until the files are gone because Windows
+ // locks them sometimes.
+ info("Clearing history");
+ while (file.exists() || fileCopy.exists()) {
+ await promiseClearHistory();
+ }
+ info("History is clear");
+
+ info("Repopulating");
+ await promiseAddVisitsAndRepopulateNewTabLinks(URL);
+ await promiseCreateThumbnail();
+
+ info("Clearing the last 10 minutes of browsing history");
+ // Clear the last 10 minutes of browsing history.
+ await promiseClearHistory(true);
+
+ info("Attempt to clear file");
+ // Retry until the file is gone because Windows locks it sometimes.
+ await promiseClearFile(file, URL);
+
+ info("Done");
+});
+
+async function promiseClearFile(aFile, aURL) {
+ if (!aFile.exists()) {
+ return undefined;
+ }
+ // Re-add our URL to the history so that history observer's onDeleteURI()
+ // is called again.
+ await PlacesTestUtils.addVisits(makeURI(aURL));
+ await promiseClearHistory(true);
+ // Then retry.
+ return promiseClearFile(aFile, aURL);
+}
+
+function promiseClearHistory(aUseRange) {
+ let options = {};
+ if (aUseRange) {
+ let usec = Date.now() * 1000;
+ options.range = [usec - 10 * 60 * 1000 * 1000, usec];
+ options.ignoreTimespan = false;
+ }
+ return Sanitizer.sanitize(["history"], options);
+}
+
+async function promiseCreateThumbnail() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: URL,
+ },
+ async browser => {
+ gBrowserThumbnails.clearTopSiteURLCache();
+ await whenFileExists(URL);
+ }
+ );
+}
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_storage_migrate3.js b/toolkit/components/thumbnails/test/browser_thumbnails_storage_migrate3.js
new file mode 100644
index 0000000000..c2917e9dd7
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_storage_migrate3.js
@@ -0,0 +1,95 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const URL = "http://mochi.test:8888/migration3";
+const URL2 = URL + "#2";
+const URL3 = URL + "#3";
+const THUMBNAIL_DIRECTORY = "thumbnails";
+const PREF_STORAGE_VERSION = "browser.pagethumbnails.storage_version";
+
+var tmp = Cu.Sandbox(window, { wantGlobalProperties: ["ChromeUtils"] });
+Services.scriptloader.loadSubScript(
+ "resource://gre/modules/PageThumbs.jsm",
+ tmp
+);
+var { PageThumbsStorageMigrator } = tmp;
+
+/**
+ * This test makes sure we correctly migrate to thumbnail storage version 3.
+ * This means copying existing thumbnails from the roaming to the local profile
+ * directory and should just apply to Linux.
+ */
+function* runTests() {
+ // Prepare a local profile directory.
+ let localProfile = FileUtils.getDir("ProfD", ["local-test"], true);
+ changeLocation("ProfLD", localProfile);
+
+ let roaming = FileUtils.getDir("ProfD", [THUMBNAIL_DIRECTORY], true);
+
+ // Set up some data in the roaming profile.
+ let name = PageThumbsStorageService.getLeafNameForURL(URL);
+ let file = FileUtils.getFile("ProfD", [THUMBNAIL_DIRECTORY, name]);
+ writeDummyFile(file);
+
+ name = PageThumbsStorageService.getLeafNameForURL(URL2);
+ file = FileUtils.getFile("ProfD", [THUMBNAIL_DIRECTORY, name]);
+ writeDummyFile(file);
+
+ name = PageThumbsStorageService.getLeafNameForURL(URL3);
+ file = FileUtils.getFile("ProfD", [THUMBNAIL_DIRECTORY, name]);
+ writeDummyFile(file);
+
+ // Pretend to have one of the thumbnails
+ // already in place at the new storage site.
+ name = PageThumbsStorageService.getLeafNameForURL(URL3);
+ file = FileUtils.getFile("ProfLD", [THUMBNAIL_DIRECTORY, name]);
+ writeDummyFile(file, "no-overwrite-plz");
+
+ // Kick off thumbnail storage migration.
+ PageThumbsStorageMigrator.migrateToVersion3(localProfile.path);
+ ok(true, "migration finished");
+
+ // Wait until the first thumbnail was moved to its new location.
+ yield whenFileExists(URL);
+ ok(true, "first thumbnail moved");
+
+ // Wait for the second thumbnail to be moved as well.
+ yield whenFileExists(URL2);
+ ok(true, "second thumbnail moved");
+
+ yield whenFileRemoved(roaming);
+ ok(true, "roaming thumbnail directory removed");
+
+ // Check that our existing thumbnail wasn't overwritten.
+ is(
+ getFileContents(file),
+ "no-overwrite-plz",
+ "existing thumbnail was not overwritten"
+ );
+}
+
+function changeLocation(aLocation, aNewDir) {
+ let oldDir = Services.dirsvc.get(aLocation, Ci.nsIFile);
+ Services.dirsvc.undefine(aLocation);
+ Services.dirsvc.set(aLocation, aNewDir);
+
+ registerCleanupFunction(function () {
+ Services.dirsvc.undefine(aLocation);
+ Services.dirsvc.set(aLocation, oldDir);
+ });
+}
+
+function writeDummyFile(aFile, aContents) {
+ let fos = FileUtils.openSafeFileOutputStream(aFile);
+ let data = aContents || "dummy";
+ fos.write(data, data.length);
+ FileUtils.closeSafeFileOutputStream(fos);
+}
+
+function getFileContents(aFile) {
+ let istream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ istream.init(aFile, FileUtils.MODE_RDONLY, FileUtils.PERMS_FILE, 0);
+ return NetUtil.readInputStreamToString(istream, istream.available());
+}
diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_update.js b/toolkit/components/thumbnails/test/browser_thumbnails_update.js
new file mode 100644
index 0000000000..89e085d1d9
--- /dev/null
+++ b/toolkit/components/thumbnails/test/browser_thumbnails_update.js
@@ -0,0 +1,211 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * These tests check the auto-update facility of the thumbnail service.
+ */
+
+function ensureThumbnailStale(url) {
+ // We go behind the back of the thumbnail service and change the
+ // mtime of the file to be in the past.
+ let fname = PageThumbsStorageService.getFilePathForURL(url);
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(fname);
+ ok(file.exists(), fname + " should exist");
+ // Set it as very stale...
+ file.lastModifiedTime = Date.now() - 1000000000;
+}
+
+function getThumbnailModifiedTime(url) {
+ let fname = PageThumbsStorageService.getFilePathForURL(url);
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(fname);
+ return file.lastModifiedTime;
+}
+
+/**
+ * Check functionality of a normal captureAndStoreIfStale request
+ */
+add_task(async function thumbnails_captureAndStoreIfStale_normal() {
+ const URL =
+ "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_update.sjs?simple";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: URL,
+ },
+ async browser => {
+ let numNotifications = 0;
+
+ let observed = TestUtils.topicObserved(
+ "page-thumbnail:create",
+ (subject, data) => {
+ is(data, URL, "data is our test URL");
+
+ // Once we get the second notification, we saw the last captureAndStoreIsStale,
+ // and we can tear down.
+ if (++numNotifications == 2) {
+ return true;
+ }
+
+ return false;
+ }
+ );
+ await PageThumbs.captureAndStore(browser);
+ // We've got a capture so should have seen the observer.
+ is(numNotifications, 1, "got notification of item being created.");
+
+ await PageThumbs.captureAndStoreIfStale(browser);
+ is(
+ numNotifications,
+ 1,
+ "still only 1 notification of item being created."
+ );
+
+ ensureThumbnailStale(URL);
+ await PageThumbs.captureAndStoreIfStale(browser);
+ await observed;
+ }
+ );
+});
+
+/**
+ * Check functionality of captureAndStoreIfStale when there is an error response
+ * from the server.
+ */
+add_task(async function thumbnails_captureAndStoreIfStale_error_response() {
+ const URL =
+ "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_update.sjs?fail";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: URL,
+ },
+ async browser => {
+ await captureAndCheckColor(0, 255, 0, "we have a green thumbnail");
+
+ // update the thumbnail to be stale, then re-request it. The server will
+ // return a 400 response and a red thumbnail.
+ // The service should not save the thumbnail - so we (a) check the thumbnail
+ // remains green and (b) check the mtime of the file is < now.
+ ensureThumbnailStale(URL);
+ BrowserTestUtils.loadURIString(browser, URL);
+ await BrowserTestUtils.browserLoaded(browser);
+
+ // now() returns a higher-precision value than the modified time of a file.
+ // As we set the thumbnail very stale, allowing 1 second of "slop" here
+ // works around this while still keeping the test valid.
+ let now = Date.now() - 1000;
+ await PageThumbs.captureAndStoreIfStale(gBrowser.selectedBrowser);
+
+ ok(getThumbnailModifiedTime(URL) < now, "modified time should be < now");
+ let [r, g, b] = await retrieveImageDataForURL(URL);
+ is("" + [r, g, b], "" + [0, 255, 0], "thumbnail is still green");
+ }
+ );
+});
+
+/**
+ * Check functionality of captureAndStoreIfStale when there is a non-error
+ * response from the server. This test is somewhat redundant - although it is
+ * using a http:// URL instead of a data: url like most others.
+ */
+add_task(async function thumbnails_captureAndStoreIfStale_non_error_response() {
+ const URL =
+ "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_update.sjs?ok";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: URL,
+ },
+ async browser => {
+ await captureAndCheckColor(0, 255, 0, "we have a green thumbnail");
+ // update the thumbnail to be stale, then re-request it. The server will
+ // return a 200 response and a red thumbnail - so that new thumbnail should
+ // end up captured.
+ ensureThumbnailStale(URL);
+ BrowserTestUtils.loadURIString(browser, URL);
+ await BrowserTestUtils.browserLoaded(browser);
+
+ // now() returns a higher-precision value than the modified time of a file.
+ // As we set the thumbnail very stale, allowing 1 second of "slop" here
+ // works around this while still keeping the test valid.
+ let now = Date.now() - 1000;
+ await PageThumbs.captureAndStoreIfStale(browser);
+ Assert.greater(
+ getThumbnailModifiedTime(URL),
+ now,
+ "modified time should be >= now"
+ );
+ let [r, g, b] = await retrieveImageDataForURL(URL);
+ is("" + [r, g, b], "" + [255, 0, 0], "thumbnail is now red");
+ }
+ );
+});
+
+/**
+ * Check functionality of captureAndStore when there is an error response
+ * from the server.
+ */
+add_task(async function thumbnails_captureAndStore_error_response() {
+ const URL =
+ "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_update.sjs?fail";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: URL,
+ },
+ async browser => {
+ await captureAndCheckColor(0, 255, 0, "we have a green thumbnail");
+ }
+ );
+
+ // do it again - the server will return a 400, so the foreground service
+ // should not update it.
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: URL,
+ },
+ async browser => {
+ await captureAndCheckColor(0, 255, 0, "we still have a green thumbnail");
+ }
+ );
+});
+
+/**
+ * Check functionality of captureAndStore when there is an OK response
+ * from the server.
+ */
+add_task(async function thumbnails_captureAndStore_ok_response() {
+ const URL =
+ "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_update.sjs?ok";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: URL,
+ },
+ async browser => {
+ await captureAndCheckColor(0, 255, 0, "we have a green thumbnail");
+ }
+ );
+
+ // do it again - the server will return a 200, so the foreground service
+ // should update it.
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: URL,
+ },
+ async browser => {
+ await captureAndCheckColor(255, 0, 0, "we now have a red thumbnail");
+ }
+ );
+});
diff --git a/toolkit/components/thumbnails/test/head.js b/toolkit/components/thumbnails/test/head.js
new file mode 100644
index 0000000000..a531395bed
--- /dev/null
+++ b/toolkit/components/thumbnails/test/head.js
@@ -0,0 +1,235 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ BackgroundPageThumbs: "resource://gre/modules/BackgroundPageThumbs.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
+ PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs",
+ PageThumbsStorage: "resource://gre/modules/PageThumbs.sys.mjs",
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "PageThumbsStorageService",
+ "@mozilla.org/thumbnails/pagethumbs-service;1",
+ "nsIPageThumbsStorageService"
+);
+
+var oldEnabledPref = Services.prefs.getBoolPref(
+ "browser.pagethumbnails.capturing_disabled"
+);
+Services.prefs.setBoolPref("browser.pagethumbnails.capturing_disabled", false);
+
+registerCleanupFunction(function () {
+ while (gBrowser.tabs.length > 1) {
+ gBrowser.removeTab(gBrowser.tabs[1]);
+ }
+ Services.prefs.setBoolPref(
+ "browser.pagethumbnails.capturing_disabled",
+ oldEnabledPref
+ );
+});
+
+/**
+ * Captures a screenshot for the currently selected tab, stores it in the cache,
+ * retrieves it from the cache and compares pixel color values.
+ * @param aRed The red component's intensity.
+ * @param aGreen The green component's intensity.
+ * @param aBlue The blue component's intensity.
+ * @param aMessage The info message to print when comparing the pixel color.
+ */
+async function captureAndCheckColor(aRed, aGreen, aBlue, aMessage) {
+ let browser = gBrowser.selectedBrowser;
+ // We'll get oranges if the expiration filter removes the file during the
+ // test.
+ dontExpireThumbnailURLs([browser.currentURI.spec]);
+
+ // Capture the screenshot.
+ await PageThumbs.captureAndStore(browser);
+ let [r, g, b] = await retrieveImageDataForURL(browser.currentURI.spec);
+ is("" + [r, g, b], "" + [aRed, aGreen, aBlue], aMessage);
+}
+
+/**
+ * For a given URL, loads the corresponding thumbnail
+ * to a canvas and passes its image data to the callback.
+ * Note, not compat with e10s!
+ * @param aURL The url associated with the thumbnail.
+ * @returns Promise
+ */
+async function retrieveImageDataForURL(aURL) {
+ let width = 100,
+ height = 100;
+ let thumb = PageThumbs.getThumbnailURL(aURL, width, height);
+
+ let htmlns = "http://www.w3.org/1999/xhtml";
+ let img = document.createElementNS(htmlns, "img");
+ img.setAttribute("src", thumb);
+ await BrowserTestUtils.waitForEvent(img, "load", true);
+
+ let canvas = document.createElementNS(htmlns, "canvas");
+ canvas.setAttribute("width", width);
+ canvas.setAttribute("height", height);
+
+ // Draw the image to a canvas and compare the pixel color values.
+ let ctx = canvas.getContext("2d");
+ ctx.drawImage(img, 0, 0, width, height);
+ return ctx.getImageData(0, 0, 100, 100).data;
+}
+
+/**
+ * Returns the file of the thumbnail with the given URL.
+ * @param aURL The URL of the thumbnail.
+ */
+function thumbnailFile(aURL) {
+ return new FileUtils.File(PageThumbsStorageService.getFilePathForURL(aURL));
+}
+
+/**
+ * Checks if a thumbnail for the given URL exists.
+ * @param aURL The url associated to the thumbnail.
+ */
+function thumbnailExists(aURL) {
+ let file = thumbnailFile(aURL);
+ return file.exists() && file.fileSize;
+}
+
+/**
+ * Removes the thumbnail for the given URL.
+ * @param aURL The URL associated with the thumbnail.
+ */
+function removeThumbnail(aURL) {
+ let file = thumbnailFile(aURL);
+ file.remove(false);
+}
+
+/**
+ * Calls addVisits, and then forces the newtab module to repopulate its links.
+ * See addVisits for parameter descriptions.
+ */
+async function promiseAddVisitsAndRepopulateNewTabLinks(aPlaceInfo) {
+ await PlacesTestUtils.addVisits(makeURI(aPlaceInfo));
+ await new Promise(resolve => {
+ NewTabUtils.links.populateCache(resolve, true);
+ });
+}
+
+/**
+ * Resolves a Promise when the thumbnail for a given URL has been found
+ * on disk. Keeps trying until the thumbnail has been created.
+ *
+ * @param aURL The URL of the thumbnail's page.
+ * @returns Promise
+ */
+function whenFileExists(aURL) {
+ return TestUtils.waitForCondition(
+ () => {
+ return thumbnailExists(aURL);
+ },
+ `Waiting for ${aURL} to exist.`,
+ 1000,
+ 50
+ );
+}
+
+/**
+ * Resolves a Promise when the given file has been removed.
+ * Keeps trying until the file is removed.
+ *
+ * @param aFile The file that is being removed
+ * @returns Promise
+ */
+function whenFileRemoved(aFile) {
+ return TestUtils.waitForCondition(
+ () => {
+ return !aFile.exists();
+ },
+ `Waiting for ${aFile.leafName} to not exist.`,
+ 1000,
+ 50
+ );
+}
+
+/**
+ * Makes sure that a given list of URLs is not implicitly expired.
+ *
+ * @param aURLs The list of URLs that should not be expired.
+ */
+function dontExpireThumbnailURLs(aURLs) {
+ let dontExpireURLs = cb => cb(aURLs);
+ PageThumbs.addExpirationFilter(dontExpireURLs);
+
+ registerCleanupFunction(function () {
+ PageThumbs.removeExpirationFilter(dontExpireURLs);
+ });
+}
+
+function bgCapture(aURL, aOptions) {
+ return bgCaptureWithMethod("capture", aURL, aOptions);
+}
+
+function bgCaptureIfMissing(aURL, aOptions) {
+ return bgCaptureWithMethod("captureIfMissing", aURL, aOptions);
+}
+
+/**
+ * Queues a BackgroundPageThumbs capture with the supplied method.
+ *
+ * @param {String} aMethodName One of the method names on BackgroundPageThumbs
+ * for capturing thumbnails. Example: "capture", "captureIfMissing".
+ * @param {String} aURL The URL of the page to capture.
+ * @param {Object} aOptions The options object to pass to BackgroundPageThumbs.
+ *
+ * @returns {Promise}
+ * @resolves {Array} Resolves once the capture has completed with an Array of
+ * results. The first element of the Array is the URL of the captured page,
+ * and the second element is the completion reason from the BackgroundPageThumbs
+ * module.
+ */
+function bgCaptureWithMethod(aMethodName, aURL, aOptions = {}) {
+ // We'll get oranges if the expiration filter removes the file during the
+ // test.
+ dontExpireThumbnailURLs([aURL]);
+
+ return new Promise(resolve => {
+ let wrappedDoneFn = aOptions.onDone;
+ aOptions.onDone = (url, doneReason) => {
+ if (wrappedDoneFn) {
+ wrappedDoneFn(url, doneReason);
+ }
+ resolve([url, doneReason]);
+ };
+
+ BackgroundPageThumbs[aMethodName](aURL, aOptions);
+ });
+}
+
+function bgTestPageURL(aOpts = {}) {
+ let TEST_PAGE_URL =
+ "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_background.sjs";
+ return TEST_PAGE_URL + "?" + encodeURIComponent(JSON.stringify(aOpts));
+}
+
+function bgAddPageThumbObserver(url) {
+ return new Promise((resolve, reject) => {
+ function observe(subject, topic, data) {
+ if (data === url) {
+ switch (topic) {
+ case "page-thumbnail:create":
+ resolve();
+ break;
+ case "page-thumbnail:error":
+ reject(new Error("page-thumbnail:error"));
+ break;
+ }
+ Services.obs.removeObserver(observe, "page-thumbnail:create");
+ Services.obs.removeObserver(observe, "page-thumbnail:error");
+ }
+ }
+ Services.obs.addObserver(observe, "page-thumbnail:create");
+ Services.obs.addObserver(observe, "page-thumbnail:error");
+ });
+}
diff --git a/toolkit/components/thumbnails/test/privacy_cache_control.sjs b/toolkit/components/thumbnails/test/privacy_cache_control.sjs
new file mode 100644
index 0000000000..576b27f1f3
--- /dev/null
+++ b/toolkit/components/thumbnails/test/privacy_cache_control.sjs
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(aRequest, aResponse) {
+ // Set HTTP Status
+ aResponse.setStatusLine(aRequest.httpVersion, 200, "OK");
+
+ // Set Cache-Control header.
+ let value = aRequest.queryString;
+ if (value) {
+ aResponse.setHeader("Cache-Control", value);
+ }
+
+ // Set the response body.
+ aResponse.write("<!DOCTYPE html><html><body></body></html>");
+}
diff --git a/toolkit/components/thumbnails/test/sample_image_blue_300x600.jpg b/toolkit/components/thumbnails/test/sample_image_blue_300x600.jpg
new file mode 100644
index 0000000000..ad5b44ecbc
--- /dev/null
+++ b/toolkit/components/thumbnails/test/sample_image_blue_300x600.jpg
Binary files differ
diff --git a/toolkit/components/thumbnails/test/sample_image_green_1024x1024.jpg b/toolkit/components/thumbnails/test/sample_image_green_1024x1024.jpg
new file mode 100644
index 0000000000..7d91079ecd
--- /dev/null
+++ b/toolkit/components/thumbnails/test/sample_image_green_1024x1024.jpg
Binary files differ
diff --git a/toolkit/components/thumbnails/test/sample_image_red_1920x1080.jpg b/toolkit/components/thumbnails/test/sample_image_red_1920x1080.jpg
new file mode 100644
index 0000000000..7a2bec4d5e
--- /dev/null
+++ b/toolkit/components/thumbnails/test/sample_image_red_1920x1080.jpg
Binary files differ
diff --git a/toolkit/components/thumbnails/test/test_thumbnails_interfaces.js b/toolkit/components/thumbnails/test/test_thumbnails_interfaces.js
new file mode 100644
index 0000000000..394b8591a8
--- /dev/null
+++ b/toolkit/components/thumbnails/test/test_thumbnails_interfaces.js
@@ -0,0 +1,52 @@
+"use strict";
+
+// This is an xpcshell test and gets a browser test env applied, so we
+// need to still manually import NetUtil.
+// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix
+const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+
+// need profile so that PageThumbsStorageService can resolve the path to the underlying file
+do_get_profile();
+
+function run_test() {
+ // check the protocol handler implements the correct interface
+ let handler = Services.io.getProtocolHandler("moz-page-thumb");
+ ok(
+ handler instanceof Ci.nsIProtocolHandler,
+ "moz-page-thumb handler provides a protocol handler interface"
+ );
+
+ // create a dummy loadinfo which we can hand to newChannel.
+ let dummyURI = Services.io.newURI("https://www.example.com/1");
+ let dummyChannel = NetUtil.newChannel({
+ uri: dummyURI,
+ loadUsingSystemPrincipal: true,
+ });
+ let dummyLoadInfo = dummyChannel.loadInfo;
+
+ // and check that the error cases work as specified
+ let badhost = Services.io.newURI(
+ "moz-page-thumb://wronghost/?url=http%3A%2F%2Fwww.mozilla.org%2F"
+ );
+ Assert.throws(
+ () => handler.newChannel(badhost, dummyLoadInfo),
+ /NS_ERROR_NOT_AVAILABLE/i,
+ "moz-page-thumb object with wrong host must not resolve to a file path"
+ );
+
+ let badQuery = Services.io.newURI(
+ "moz-page-thumb://thumbnail/http%3A%2F%2Fwww.mozilla.org%2F"
+ );
+ Assert.throws(
+ () => handler.newChannel(badQuery, dummyLoadInfo),
+ /NS_ERROR_NOT_AVAILABLE/i,
+ "moz-page-thumb object with malformed query parameters must not resolve to a file path"
+ );
+
+ let noURL = Services.io.newURI("moz-page-thumb://thumbnail/?badStuff");
+ Assert.throws(
+ () => handler.newChannel(noURL, dummyLoadInfo),
+ /NS_ERROR_NOT_AVAILABLE/i,
+ "moz-page-thumb object without a URL parameter must not resolve to a file path"
+ );
+}
diff --git a/toolkit/components/thumbnails/test/thumbnails_background.sjs b/toolkit/components/thumbnails/test/thumbnails_background.sjs
new file mode 100644
index 0000000000..f0d00e82da
--- /dev/null
+++ b/toolkit/components/thumbnails/test/thumbnails_background.sjs
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// The timer never fires if it's not declared and set to this variable outside
+// handleRequest, as if it's getting GC'ed when handleRequest's scope goes away.
+// Shouldn't the timer thread hold a strong reference to it?
+var timer;
+
+function handleRequest(req, resp) {
+ resp.processAsync();
+ resp.setHeader("Cache-Control", "no-cache, no-store", false);
+ resp.setHeader("Content-Type", "text/html;charset=utf-8", false);
+
+ let opts = {};
+ try {
+ opts = JSON.parse(decodeURIComponent(req.queryString));
+ } catch (err) {}
+
+ let setCookieScript = "";
+ if (opts.setRedCookie) {
+ resp.setHeader("Set-Cookie", "red", false);
+ setCookieScript = '<script>document.cookie="red";</script>';
+ }
+
+ if (opts.setGreenCookie) {
+ resp.setHeader("Set-Cookie", "green", false);
+ setCookieScript = '<script>document.cookie="green";</script>';
+ }
+
+ if (opts.iframe) {
+ setCookieScript += '<iframe src="' + opts.iframe + '" />';
+ }
+
+ if (opts.xhr) {
+ setCookieScript += `
+ <script>
+ var req = new XMLHttpRequest();
+ req.open("GET", "${opts.xhr}", true);
+ req.send();
+ </script>
+ `;
+ }
+
+ if (
+ req.hasHeader("Cookie") &&
+ req.getHeader("Cookie").split(";").includes("red")
+ ) {
+ resp.write(
+ '<html style="background: #f00;">' + setCookieScript + "</html>"
+ );
+ resp.finish();
+ return;
+ }
+
+ if (
+ req.hasHeader("Cookie") &&
+ req.getHeader("Cookie").split(";").includes("green")
+ ) {
+ resp.write(
+ '<html style="background: #0f0;">' + setCookieScript + "</html>"
+ );
+ resp.finish();
+ return;
+ }
+
+ if (opts.redirect) {
+ resp.setHeader("Location", opts.redirect);
+ resp.setStatusLine(null, 303, null);
+ resp.finish();
+ return;
+ }
+
+ if (opts.wait) {
+ resp.write("Waiting " + opts.wait + " ms... ");
+ timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.init(
+ function ding() {
+ resp.write("OK!");
+ resp.finish();
+ },
+ opts.wait,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ return;
+ }
+
+ resp.write(
+ "<pre>" + JSON.stringify(opts, undefined, 2) + "</pre>" + setCookieScript
+ );
+ resp.finish();
+}
diff --git a/toolkit/components/thumbnails/test/thumbnails_update.sjs b/toolkit/components/thumbnails/test/thumbnails_update.sjs
new file mode 100644
index 0000000000..78716adff9
--- /dev/null
+++ b/toolkit/components/thumbnails/test/thumbnails_update.sjs
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This server-side script is used for browser_thumbnails_update. One of the
+// main things it must do in all cases is ensure a Cache-Control: no-store
+// header, so the foreground capture doesn't interfere with the testing.
+
+// If the querystring is "simple", then all it does it return some content -
+// it doesn't really matter what that content is.
+
+// Otherwise, its main role is that it must return different *content* for the
+// second request than it did for the first.
+// Also, it should be able to return an error response when requested for the
+// second response.
+// So the basic tests will be to grab the thumbnail, then request it to be
+// grabbed again:
+// * If the second request succeeded, the new thumbnail should exist.
+// * If the second request is an error, the new thumbnail should be ignored.
+
+function handleRequest(aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "text/html;charset=utf-8", false);
+ // we want to disable gBrowserThumbnails on-load capture for these responses,
+ // so set as a "no-store" response.
+ aResponse.setHeader("Cache-Control", "no-store");
+
+ // for the simple test - just return some content.
+ if (aRequest.queryString == "simple") {
+ aResponse.write("<html><body></body></html>");
+ aResponse.setStatusLine(aRequest.httpVersion, 200, "Its simply OK");
+ return;
+ }
+
+ // it's one of the more complex tests where the first request for the given
+ // URL must return different content than the second, and possibly an error
+ // response for the second
+ let doneError = getState(aRequest.queryString);
+ if (!doneError) {
+ // first request - return a response with a green body and 200 response.
+ aResponse.setStatusLine(aRequest.httpVersion, 200, "OK - It's green");
+ aResponse.write("<html><body bgcolor=00ff00></body></html>");
+ // set the state so the next request does the "second request" thing below.
+ setState(aRequest.queryString, "yep");
+ } else {
+ // second request - this will be done by the b/g service.
+ // We always return a red background, but depending on the query string we
+ // return either a 200 or 401 response.
+ if (aRequest.queryString == "fail") {
+ aResponse.setStatusLine(aRequest.httpVersion, 401, "Oh no you don't");
+ } else {
+ aResponse.setStatusLine(aRequest.httpVersion, 200, "OK - It's red");
+ }
+ aResponse.write("<html><body bgcolor=ff0000></body></html>");
+ // reset the error state incase this ends up being reused for the
+ // same url and querystring.
+ setState(aRequest.queryString, "");
+ }
+}
diff --git a/toolkit/components/thumbnails/test/xpcshell.ini b/toolkit/components/thumbnails/test/xpcshell.ini
new file mode 100644
index 0000000000..de47db2398
--- /dev/null
+++ b/toolkit/components/thumbnails/test/xpcshell.ini
@@ -0,0 +1,5 @@
+[DEFAULT]
+head =
+
+[test_thumbnails_interfaces.js]
+skip-if = os == 'android' # xpcom interface not packaged