Bug 698371 - Add async thumbnail support for remote browsers. r=dao
authorJim Mathies <jmathies@mozilla.com>
Sat, 18 Oct 2014 09:09:32 -0500
changeset 211233 57f8522b4b09514a594240a61e090fcfdc79e770
parent 211232 00b3768628aef5f793a57c92dd82d9dba55cfeed
child 211234 d67b8fed95c695f302f380cc99ebad0227e7e539
push id1
push userroot
push dateMon, 20 Oct 2014 17:29:22 +0000
reviewersdao
bugs698371
milestone36.0a1
Bug 698371 - Add async thumbnail support for remote browsers. r=dao
toolkit/components/thumbnails/PageThumbs.jsm
toolkit/components/thumbnails/content/backgroundPageThumbsContent.js
toolkit/content/browser-child.js
--- a/toolkit/components/thumbnails/PageThumbs.jsm
+++ b/toolkit/components/thumbnails/PageThumbs.jsm
@@ -5,38 +5,34 @@
 "use strict";
 
 this.EXPORTED_SYMBOLS = ["PageThumbs", "PageThumbsStorage"];
 
 const Cu = Components.utils;
 const Cc = Components.classes;
 const Ci = Components.interfaces;
 
-const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
 const PREF_STORAGE_VERSION = "browser.pagethumbnails.storage_version";
 const LATEST_STORAGE_VERSION = 3;
 
 const EXPIRATION_MIN_CHUNK_SIZE = 50;
 const EXPIRATION_INTERVAL_SECS = 3600;
 
+var gRemoteThumbId = 0;
+
 // 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";
 
-/**
- * The default background color for page thumbnails.
- */
-const THUMBNAIL_BG_COLOR = "#fff";
-
 Cu.import("resource://gre/modules/XPCOMUtils.jsm", this);
 Cu.import("resource://gre/modules/PromiseWorker.jsm", this);
 Cu.import("resource://gre/modules/Promise.jsm", this);
 Cu.import("resource://gre/modules/osfile.jsm", this);
 
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
   "resource://gre/modules/NetUtil.jsm");
 
@@ -64,16 +60,18 @@ XPCOMUtils.defineLazyGetter(this, "gUnic
 });
 
 XPCOMUtils.defineLazyModuleGetter(this, "Task",
   "resource://gre/modules/Task.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
   "resource://gre/modules/Deprecated.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown",
   "resource://gre/modules/AsyncShutdown.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PageThumbUtils",
+  "resource://gre/modules/PageThumbUtils.jsm");
 
 /**
  * Utilities for dealing with promises and Task.jsm
  */
 const TaskUtils = {
   /**
    * Read the bytes from a blob, asynchronously.
    *
@@ -163,123 +161,208 @@ this.PageThumbs = {
     * @param aUrl The web page's url.
     * @return The path of the thumbnail file.
     */
    getThumbnailPath: function PageThumbs_getThumbnailPath(aUrl) {
      return PageThumbsStorage.getFilePathForURL(aUrl);
    },
 
   /**
-   * Captures a thumbnail for the given window.
-   * @param aWindow The DOM window to capture a thumbnail from.
-   * @param aCallback The function to be called when the thumbnail has been
-   *                  captured. The first argument will be the data stream
-   *                  containing the image data.
-   */
-  capture: function PageThumbs_capture(aWindow, aCallback) {
-    if (!this._prefEnabled()) {
-      return;
-    }
-
-    let canvas = this.createCanvas();
-    this.captureToCanvas(aWindow, canvas);
-
-    // Fetch the canvas data on the next event loop tick so that we allow
-    // some event processing in between drawing to the canvas and encoding
-    // its data. We want to block the UI as short as possible. See bug 744100.
-    Services.tm.currentThread.dispatch(function () {
-      canvas.mozFetchAsStream(aCallback, this.contentType);
-    }.bind(this), Ci.nsIThread.DISPATCH_NORMAL);
-  },
-
-
-  /**
-   * Captures a thumbnail for the given window.
+   * Asynchronously returns a thumbnail as a blob for the given
+   * window.
    *
-   * @param aWindow The DOM window to capture a thumbnail from.
+   * @param aBrowser The <browser> to capture a thumbnail from.
    * @return {Promise}
    * @resolve {Blob} The thumbnail, as a Blob.
    */
-  captureToBlob: function PageThumbs_captureToBlob(aWindow) {
+  captureToBlob: function PageThumbs_captureToBlob(aBrowser) {
     if (!this._prefEnabled()) {
       return null;
     }
 
-    let canvas = this.createCanvas();
-    this.captureToCanvas(aWindow, canvas);
+    let deferred = Promise.defer();
 
-    let deferred = Promise.defer();
-    let type = this.contentType;
-    // Fetch the canvas data on the next event loop tick so that we allow
-    // some event processing in between drawing to the canvas and encoding
-    // its data. We want to block the UI as short as possible. See bug 744100.
-    canvas.toBlob(function asBlob(blob) {
-      deferred.resolve(blob, type);
+    let canvas = this.createCanvas();
+    this.captureToCanvas(aBrowser, canvas, () => {
+      canvas.toBlob(blob => {
+        deferred.resolve(blob, this.contentType);
+      });
     });
+
     return deferred.promise;
   },
 
   /**
    * Captures a thumbnail from a given window and draws it to the given canvas.
-   * @param aWindow The DOM window to capture a thumbnail from.
+   * 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.
+   * @param aCallback (optional) A callback invoked once the thumbnail has been
+   * rendered to aCanvas.
    */
-  captureToCanvas: function PageThumbs_captureToCanvas(aWindow, aCanvas) {
+  captureToCanvas: function PageThumbs_captureToCanvas(aBrowser, aCanvas, aCallback) {
     let telemetryCaptureTime = new Date();
-    this._captureToCanvas(aWindow, aCanvas);
-    let telemetry = Services.telemetry;
-    telemetry.getHistogramById("FX_THUMBNAILS_CAPTURE_TIME_MS")
-      .add(new Date() - telemetryCaptureTime);
+    this._captureToCanvas(aBrowser, aCanvas, function () {
+      Services.telemetry
+              .getHistogramById("FX_THUMBNAILS_CAPTURE_TIME_MS")
+              .add(new Date() - telemetryCaptureTime);
+      if (aCallback) {
+        aCallback(aCanvas);
+      }
+    });
   },
 
   // The background thumbnail service captures to canvas but doesn't want to
   // participate in this service's telemetry, which is why this method exists.
-  _captureToCanvas: function PageThumbs__captureToCanvas(aWindow, aCanvas) {
-    let [sw, sh, scale] = this._determineCropSize(aWindow, aCanvas);
+  _captureToCanvas: function (aBrowser, aCanvas, aCallback) {
+    if (aBrowser.isRemoteBrowser) {
+      let [sw, sh, scale] =
+        PageThumbUtils.determineCropSize(aBrowser.contentWindowAsCPOW, aCanvas);
+      Task.spawn(function () {
+        let data =
+          yield this._captureRemoteThumbnail(aBrowser, sw, sh, scale,
+                                             PageThumbUtils.THUMBNAIL_BG_COLOR);
+        let canvas = data.thumbnail;
+        let ctx = canvas.getContext("2d");
+        let imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+        aCanvas.getContext("2d").putImageData(imgData, 0, 0);
+        if (aCallback) {
+          aCallback(aCanvas);
+        }
+      }.bind(this));
+      return;
+    }
+
+    // Generate in-process content thumbnail
+    let [sw, sh, scale] =
+      PageThumbUtils.determineCropSize(aBrowser.contentWindow, aCanvas);
     let ctx = aCanvas.getContext("2d");
 
     // Scale the canvas accordingly.
     ctx.save();
     ctx.scale(scale, scale);
 
     try {
       // Draw the window contents to the canvas.
-      ctx.drawWindow(aWindow, 0, 0, sw, sh, THUMBNAIL_BG_COLOR,
+      ctx.drawWindow(aBrowser.contentWindow, 0, 0, sw, sh,
+                     PageThumbUtils.THUMBNAIL_BG_COLOR,
                      ctx.DRAWWINDOW_DO_NOT_FLUSH);
     } catch (e) {
       // We couldn't draw to the canvas for some reason.
     }
+    ctx.restore();
 
-    ctx.restore();
+    if (aCallback) {
+      aCallback(aCanvas);
+    }
   },
 
   /**
+   * Request a thumbnail using requested bounds and scale factor.
+   * @param aWidth - (optional) a width value less than or equal to the
+   *   innerWidth of the dom window. Defaults to the visible frame.
+   * @param aHeight - (optional) a height value less than or equal to the
+   *   innerHeight of the dom window. Defaults to the visible frame.
+   * @param aScaleFactor - (optional) 0.0 - 1.0 scale factor applied to the
+   *   returned thumbnail. Defaults to 1.0.
+   * @param aCssBackground - (optional) a css '#fff' color value to use as
+   *   the background color of the thumbnail.
+   */
+  _captureRemoteThumbnail: function (aBrowser,  aWidth, aHeight,
+                                     aScaleFactor, aCssBackground) {
+    let deferred = Promise.defer();
+
+    // The index we send with the request so we can identify the
+    // correct response.
+    let index = gRemoteThumbId++;
+
+    // Thumbnail request response handler
+    let mm = aBrowser.messageManager;
+
+    // Browser:Thumbnail:Response handler
+    let thumbFunc = function (aMsg) {
+      // Ignore events unrelated to our request
+      if (aMsg.data.id != index) {
+        return;
+      }
+
+      mm.removeMessageListener("Browser:Thumbnail:Response", thumbFunc);
+      let imageBlob = aMsg.data.thumbnail;
+      let doc = aBrowser.parentElement.ownerDocument;
+      let reader = Cc["@mozilla.org/files/filereader;1"].
+                   createInstance(Ci.nsIDOMFileReader);
+      reader.addEventListener("loadend", function() {
+        let image = doc.createElementNS(PageThumbUtils.HTML_NAMESPACE, "img");
+        image.onload = function () {
+          let thumbnail = doc.createElementNS(PageThumbUtils.HTML_NAMESPACE, "canvas");
+          thumbnail.width = image.naturalWidth;
+          thumbnail.height = image.naturalHeight;
+          let ctx = thumbnail.getContext("2d");
+          ctx.drawImage(image, 0, 0);
+          deferred.resolve({
+            thumbnail: thumbnail
+          });
+        }
+        image.src = reader.result;
+      });
+      // xxx wish there was a way to skip this encoding step
+      reader.readAsDataURL(imageBlob);
+    }
+    mm.addMessageListener("Browser:Thumbnail:Response", thumbFunc);
+
+    // Send a thumbnail request
+    let width = aWidth || 0;
+    let height = aHeight || 0;
+    let scale = aScaleFactor || 1.0;
+    let background = aCssBackground || "#fff";
+    mm.sendAsyncMessage("Browser:Thumbnail:Request", {
+      width: width,
+      height: height,
+      scale: scale,
+      background: background,
+      id: index
+    });
+
+    return deferred.promise;
+  },
+
+  /**
    * Captures a thumbnail for the given browser and stores it to the cache.
    * @param aBrowser The browser to capture a thumbnail for.
    * @param aCallback The function to be called when finished (optional).
    */
   captureAndStore: function PageThumbs_captureAndStore(aBrowser, aCallback) {
     if (!this._prefEnabled()) {
       return;
     }
 
     let url = aBrowser.currentURI.spec;
-    let channel = aBrowser.docShell.currentDocumentChannel;
-    let originalURL = channel.originalURI.spec;
+    let originalURL;
+    let channelError = false;
 
-    // see if this was an error response.
-    let wasError = this._isChannelErrorResponse(channel);
+    if (!aBrowser.isRemoteBrowser) {
+      let channel = aBrowser.docShell.currentDocumentChannel;
+      originalURL = channel.originalURI.spec;
+      // see if this was an error response.
+      channelError = this._isChannelErrorResponse(channel);
+    } else {
+      // We need channel info (bug 1073957)
+      originalURL = url;
+    }
 
     Task.spawn((function task() {
       let isSuccess = true;
       try {
-        let blob = yield this.captureToBlob(aBrowser.contentWindow);
+        let blob = yield this.captureToBlob(aBrowser);
         let buffer = yield TaskUtils.readBlob(blob);
-        yield this._store(originalURL, url, buffer, wasError);
-      } catch (_) {
+        yield this._store(originalURL, url, buffer, channelError);
+      } catch (ex) {
+        Components.utils.reportError("Exception thrown during thumbnail capture: '" + ex + "'");
         isSuccess = false;
       }
       if (aCallback) {
         aCallback(isSuccess);
       }
     }).bind(this));
   },
 
--- a/toolkit/components/thumbnails/content/backgroundPageThumbsContent.js
+++ b/toolkit/components/thumbnails/content/backgroundPageThumbsContent.js
@@ -1,19 +1,17 @@
 /* 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/. */
 
-(function () { // bug 673569 workaround :(
-
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
 Cu.importGlobalProperties(['Blob']);
 
-Cu.import("resource://gre/modules/PageThumbs.jsm");
+Cu.import("resource://gre/modules/PageThumbUtils.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 
 const STATE_LOADING = 1;
 const STATE_CAPTURING = 2;
 const STATE_CANCELED = 3;
 
 const backgroundPageThumbsContent = {
@@ -44,17 +42,17 @@ const backgroundPageThumbsContent = {
       addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW);
   },
 
   observe: function (subj, topic, data) {
     // Arrange to prevent (most) popup dialogs for this window - popups done
     // in the parent (eg, auth) aren't prevented, but alert() etc are.
     // disableDialogs only works on the current inner window, so it has
     // to be called every page load, but before scripts run.
-    if (subj == content.document) {
+    if (content && subj == content.document) {
       content.
         QueryInterface(Ci.nsIInterfaceRequestor).
         getInterface(Ci.nsIDOMWindowUtils).
         disableDialogs();
     }
   },
 
   get _webNav() {
@@ -124,19 +122,29 @@ const backgroundPageThumbsContent = {
     }
   },
 
   _captureCurrentPage: function () {
     let capture = this._currentCapture;
     capture.finalURL = this._webNav.currentURI.spec;
     capture.pageLoadTime = new Date() - capture.pageLoadStartDate;
 
-    let canvas = PageThumbs.createCanvas(content);
     let canvasDrawDate = new Date();
-    PageThumbs._captureToCanvas(content, canvas);
+
+    let canvas = PageThumbUtils.createCanvas(content);
+    let [sw, sh, scale] = PageThumbUtils.determineCropSize(content, canvas);
+
+    let ctx = canvas.getContext("2d");
+    ctx.save();
+    ctx.scale(scale, scale);
+    ctx.drawWindow(content, 0, 0, sw, sh,
+                   PageThumbUtils.THUMBNAIL_BG_COLOR,
+                   ctx.DRAWWINDOW_DO_NOT_FLUSH);
+    ctx.restore();
+
     capture.canvasDrawTime = new Date() - canvasDrawDate;
 
     canvas.toBlob(blob => {
       capture.imageBlob = new Blob([blob]);
       // Load about:blank to finish the capture and wait for onStateChange.
       this._loadAboutBlank();
     });
   },
@@ -179,10 +187,8 @@ const backgroundPageThumbsContent = {
   QueryInterface: XPCOMUtils.generateQI([
     Ci.nsIWebProgressListener,
     Ci.nsISupportsWeakReference,
     Ci.nsIObserver,
   ]),
 };
 
 backgroundPageThumbsContent.init();
-
-})();
--- a/toolkit/content/browser-child.js
+++ b/toolkit/content/browser-child.js
@@ -6,16 +6,18 @@ let Cc = Components.classes;
 let Ci = Components.interfaces;
 let Cu = Components.utils;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import('resource://gre/modules/XPCOMUtils.jsm');
 Cu.import("resource://gre/modules/RemoteAddonsChild.jsm");
 Cu.import("resource://gre/modules/Timer.jsm");
 
+const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
+
 #ifdef MOZ_CRASHREPORTER
 XPCOMUtils.defineLazyServiceGetter(this, "CrashReporter",
                                    "@mozilla.org/xre/app-info;1",
                                    "nsICrashReporter");
 #endif
 
 let FocusSyncHandler = {
   init: function() {
@@ -363,16 +365,46 @@ addEventListener("ZoomChangeUsingMouseWh
   sendAsyncMessage("ZoomChangeUsingMouseWheel", {});
 }, false);
 
 addMessageListener("UpdateCharacterSet", function (aMessage) {
   docShell.charset = aMessage.data.value;
   docShell.gatherCharsetMenuTelemetry();
 });
 
+/**
+ * Remote thumbnail request handler for PageThumbs thumbnails.
+ */
+addMessageListener("Browser:Thumbnail:Request", function (aMessage) {
+  let thumbnail = content.document.createElementNS(HTML_NAMESPACE, "canvas");
+  thumbnail.mozOpaque = true;
+  thumbnail.mozImageSmoothingEnabled = true;
+
+  // width and height are crop dims
+  let width = aMessage.data.width || content.innerWidth;
+  let height = aMessage.data.height || content.innerHeight;
+  thumbnail.width = Math.round(width * aMessage.data.scale);
+  thumbnail.height = Math.round(height * aMessage.data.scale);
+
+  let ctx = thumbnail.getContext("2d");
+  ctx.save();
+  ctx.scale(aMessage.data.scale, aMessage.data.scale);
+  ctx.drawWindow(content, 0, 0, width, height,
+                 aMessage.data.background,
+                 ctx.DRAWWINDOW_DO_NOT_FLUSH);
+  ctx.restore();
+
+  thumbnail.toBlob(function (aBlob) {
+    sendAsyncMessage("Browser:Thumbnail:Response", {
+      thumbnail: aBlob,
+      id: aMessage.data.id
+    });
+  });
+});
+
 // The AddonsChild needs to be rooted so that it stays alive as long as
 // the tab.
 let AddonsChild;
 if (Services.appinfo.browserTabsRemoteAutostart) {
   // Currently, the addon shims are only supported when autostarting
   // with remote tabs.
   AddonsChild = RemoteAddonsChild.init(this);