Bug 1231819: [webext] Implement the captureVisibleTab API. r?billm
--- 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;