Bug 1231819: [webext] Implement the captureVisibleTab API. r?billm draft
authorKris Maglione <maglione.k@gmail.com>
Mon, 25 Jan 2016 17:48:54 -0800
changeset 327287 e90d622d92744de65aa03758c4584326cef20a27
parent 327265 32faeeebe2e0b4a1c667b16781ac4d0b6cde62e5
child 513681 b6fb5b5e4f05b2b88fa37f8ba6ebe95d25d70feb
push id10220
push usermaglione.k@gmail.com
push dateSat, 30 Jan 2016 03:25:46 +0000
reviewersbillm
bugs1231819
milestone47.0a1
Bug 1231819: [webext] Implement the captureVisibleTab API. r?billm
browser/components/extensions/ext-tabs.js
browser/components/extensions/test/browser/browser.ini
browser/components/extensions/test/browser/browser_ext_tabs_captureVisibleTab.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionContent.jsm
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -461,16 +461,53 @@ extensions.registerSchemaAPI("tabs", nul
             if (matches(window, tab)) {
               result.push(tab);
             }
           }
         }
         runSafe(context, callback, result);
       },
 
+      captureVisibleTab: function(windowId, options, callback) {
+        if (!extension.hasPermission("<all_urls>")) {
+          throw new context.contentWindow.Error("The <all_urls> permission is required to use the captureVisibleTab API");
+        }
+
+        let window = windowId == null ?
+          WindowManager.topWindow :
+          WindowManager.getWindow(windowId);
+
+        let browser = window.gBrowser.selectedBrowser;
+        let recipient = {
+          innerWindowID: browser.innerWindowID,
+        };
+
+        if (!options) {
+          options = {};
+        }
+        if (!options.format) {
+          options.format = "png";
+        }
+        if (!options.quality) {
+          options.quality = 92;
+        }
+
+        let message = {
+          options,
+          width: browser.clientWidth,
+          height: browser.clientHeight,
+        };
+
+        return context.wrapPromise(
+          context.sendMessage(browser.messageManager, "Extension:Capture",
+                              message, recipient)
+                 .then(url => [url]),
+          callback);
+      },
+
       _execute: function(tabId, details, kind) {
         let tab = tabId !== null ? TabManager.getTab(tabId) : TabManager.activeTab;
         let mm = tab.linkedBrowser.messageManager;
 
         let options = {
           js: [],
           css: [],
         };
--- a/browser/components/extensions/test/browser/browser.ini
+++ b/browser/components/extensions/test/browser/browser.ini
@@ -17,16 +17,17 @@ support-files =
 [browser_ext_pageAction_context.js]
 [browser_ext_pageAction_popup.js]
 [browser_ext_browserAction_popup.js]
 [browser_ext_popup_api_injection.js]
 [browser_ext_contextMenus.js]
 [browser_ext_getViews.js]
 [browser_ext_lastError.js]
 [browser_ext_tabs_audio.js]
+[browser_ext_tabs_captureVisibleTab.js]
 [browser_ext_tabs_executeScript.js]
 [browser_ext_tabs_executeScript_good.js]
 [browser_ext_tabs_executeScript_bad.js]
 [browser_ext_tabs_insertCSS.js]
 [browser_ext_tabs_query.js]
 [browser_ext_tabs_getCurrent.js]
 [browser_ext_tabs_create.js]
 [browser_ext_tabs_update.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_captureVisibleTab.js
@@ -0,0 +1,168 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+function* runTest(options) {
+  options.neutral = [0xaa, 0xaa, 0xaa];
+
+  let html = `
+    <!DOCTYPE html>
+    <html lang="en">
+    <head><meta charset="UTF-8"></head>
+    <body style="background-color: rgb(${options.color})">
+      <!-- Fill most of the image with a neutral color to test edge-to-edge scaling. -->
+      <div style="position: absolute;
+                  left: 2px;
+                  right: 2px;
+                  top: 2px;
+                  bottom: 2px;
+                  background: rgb(${options.neutral});"></div>
+    </body>
+    </html>
+  `;
+
+  let url = `data:text/html,${encodeURIComponent(html)}`;
+  let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, url, true);
+
+  tab.linkedBrowser.fullZoom = options.fullZoom;
+
+  function background(options) {
+    // Wrap API methods in promise-based variants.
+    let promiseTabs = {};
+    Object.keys(browser.tabs).forEach(method => {
+      promiseTabs[method] = (...args) => {
+        return new Promise(resolve => {
+          browser.tabs[method](...args, resolve);
+        });
+      };
+    });
+
+    browser.test.log(`Test color ${options.color} at fullZoom=${options.fullZoom}`);
+
+    promiseTabs.query({ currentWindow: true, active: true }).then(([tab]) => {
+      return Promise.all([
+        promiseTabs.captureVisibleTab(tab.windowId, { format: "jpeg", quality: 95 }),
+        promiseTabs.captureVisibleTab(tab.windowId, { format: "png", quality: 95 }),
+        promiseTabs.captureVisibleTab(tab.windowId, { quality: 95 }),
+        promiseTabs.captureVisibleTab(tab.windowId),
+      ]).then(([jpeg, ...pngs]) => {
+        let png = pngs[0];
+
+        browser.test.assertTrue(pngs.every(url => url == png), "All PNGs are identical");
+
+        browser.test.assertTrue(jpeg.startsWith("data:image/jpeg;base64,"), "jpeg is JPEG");
+        browser.test.assertTrue(png.startsWith("data:image/png;base64,"), "png is PNG");
+
+        let promises = [jpeg, png].map(url => new Promise(resolve => {
+          let img = new Image();
+          img.src = url;
+          img.onload = () => resolve(img);
+        }));
+        return Promise.all(promises);
+      }).then(([jpeg, png]) => {
+        let tabDims = `${tab.width}\u00d7${tab.height}`;
+
+        let images = { jpeg, png };
+        for (let format of Object.keys(images)) {
+          let img = images[format];
+
+          let dims = `${img.width}\u00d7${img.height}`;
+          browser.test.assertEq(tabDims, dims, `${format} dimensions are correct`);
+
+          let canvas = document.createElement("canvas");
+          canvas.width = img.width;
+          canvas.height = img.height;
+          canvas.mozOpaque = true;
+
+          let ctx = canvas.getContext("2d");
+          ctx.drawImage(img, 0, 0);
+
+          // Check the colors of the first and last pixels of the image, to make
+          // sure we capture the entire frame, and scale it correctly.
+          let coords = [
+            { x: 0, y: 0, color: options.color },
+            { x: img.width - 1, y: img.height - 1, color: options.color },
+            { x: img.width / 2 | 0, y: img.height / 2 | 0, color: options.neutral },
+          ];
+
+          for (let { x, y, color } of coords) {
+            let imageData = ctx.getImageData(x, y, 1, 1).data;
+
+            if (format == "png") {
+              browser.test.assertEq(`rgba(${color},255)`, `rgba(${[...imageData]})`, `${format} image color is correct at (${x}, ${y})`);
+            } else {
+              // Allow for some deviation in JPEG version due to lossy compression.
+              const SLOP = 2;
+
+              browser.test.log(`Testing ${format} image color at (${x}, ${y}), have rgba(${[...imageData]}), expecting approx. rgba(${color},255)`);
+
+              browser.test.assertTrue(Math.abs(color[0] - imageData[0]) <= SLOP, `${format} image color.red is correct at (${x}, ${y})`);
+              browser.test.assertTrue(Math.abs(color[1] - imageData[1]) <= SLOP, `${format} image color.green is correct at (${x}, ${y})`);
+              browser.test.assertTrue(Math.abs(color[2] - imageData[2]) <= SLOP, `${format} image color.blue is correct at (${x}, ${y})`);
+              browser.test.assertEq(255, imageData[3], `${format} image color.alpha is correct at (${x}, ${y})`);
+            }
+          }
+        }
+
+        browser.test.notifyPass("captureVisibleTab");
+      });
+    }).catch(e => {
+      browser.test.fail(`Error: ${e} :: ${e.stack}`);
+      browser.test.notifyFail("captureVisibleTab");
+    });
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": ["<all_urls>"],
+    },
+
+    background: `(${background})(${JSON.stringify(options)})`,
+  });
+
+  yield extension.startup();
+
+  yield extension.awaitFinish("captureVisibleTab");
+
+  yield extension.unload();
+
+  yield BrowserTestUtils.removeTab(tab);
+}
+
+add_task(function* testCaptureVisibleTab() {
+  yield runTest({ color: [0, 0, 0], fullZoom: 1 });
+
+  yield runTest({ color: [0, 0, 0], fullZoom: 2 });
+
+  yield runTest({ color: [0, 0, 0], fullZoom: 0.5 });
+
+  yield runTest({ color: [255, 255, 255], fullZoom: 1 });
+});
+
+add_task(function* testCaptureVisibleTabPermissions() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": ["tabs"],
+    },
+
+    background: function(x) {
+      browser.tabs.query({ currentWindow: true, active: true }, tab => {
+        try {
+          browser.tabs.captureVisibleTab(tab.windowId, () => {});
+        } catch (e) {
+          if (e.message == "The <all_urls> permission is required to use the captureVisibleTab API") {
+            browser.test.notifyPass("captureVisibleTabPermissions");
+            return;
+          }
+        }
+        browser.test.notifyFail("captureVisibleTabPermissions");
+      });
+    },
+  });
+
+  yield extension.startup();
+
+  yield extension.awaitFinish("captureVisibleTabPermissions");
+
+  yield extension.unload();
+});
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -955,19 +955,18 @@ Extension.prototype = extend(Object.crea
   },
 
   runManifest(manifest) {
     let permissions = manifest.permissions || [];
     let webAccessibleResources = manifest.web_accessible_resources || [];
 
     let whitelist = [];
     for (let perm of permissions) {
-      if (/^\w+(\.\w+)*$/.test(perm)) {
-        this.permissions.add(perm);
-      } else {
+      this.permissions.add(perm);
+      if (!/^\w+(\.\w+)*$/.test(perm)) {
         whitelist.push(perm);
       }
     }
     this.whiteListedHosts = new MatchPattern(whitelist);
 
     let resources = new Set();
     for (let url of webAccessibleResources) {
       resources.add(url);
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -661,16 +661,17 @@ ExtensionManager = {
     }
   },
 };
 
 class ExtensionGlobal {
   constructor(global) {
     this.global = global;
 
+    MessageChannel.addListener(global, "Extension:Capture", this);
     MessageChannel.addListener(global, "Extension:Execute", this);
 
     this.broker = new MessageBroker([global]);
 
     this.windowId = global.content
                           .QueryInterface(Ci.nsIInterfaceRequestor)
                           .getInterface(Ci.nsIDOMWindowUtils)
                           .outerWindowID;
@@ -688,16 +689,38 @@ class ExtensionGlobal {
                          .QueryInterface(Ci.nsIInterfaceRequestor)
                          .getInterface(Ci.nsIDOMWindowUtils)
                          .currentInnerWindowID,
     };
   }
 
   receiveMessage({ target, messageName, recipient, data }) {
     switch (messageName) {
+      case "Extension:Capture":
+        let win = this.global.content;
+
+        const XHTML_NS = "http://www.w3.org/1999/xhtml";
+        let canvas = win.document.createElementNS(XHTML_NS, "canvas");
+        canvas.width = data.width;
+        canvas.height = data.height;
+        canvas.mozOpaque = true;
+
+        let ctx = canvas.getContext("2d");
+
+        // We need to scale the image to the visible size of the browser,
+        // in order for the result to appear as the user sees it when
+        // settings like full zoom come into play.
+        ctx.scale(canvas.width / win.innerWidth,
+                  canvas.height / win.innerHeight);
+
+        ctx.drawWindow(win, win.scrollX, win.scrollY, win.innerWidth, win.innerHeight, "#fff");
+
+        return canvas.toDataURL(`image/${data.options.format}`,
+                                data.options.quality / 100);
+
       case "Extension:Execute":
         let deferred = PromiseUtils.defer();
 
         let script = new Script(data.options, deferred);
         let { extensionId } = recipient;
         DocumentManager.executeScript(target, extensionId, script);
 
         return deferred.promise;