--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -492,25 +492,34 @@ extensions.registerSchemaAPI("tabs", nul
width: browser.clientWidth,
height: browser.clientHeight,
};
return context.sendMessage(browser.messageManager, "Extension:Capture",
message, recipient);
},
- _execute: function(tabId, details, kind) {
+ _execute: function(tabId, details, kind, method) {
let tab = tabId !== null ? TabManager.getTab(tabId) : TabManager.activeTab;
let mm = tab.linkedBrowser.messageManager;
let options = {
js: [],
css: [],
};
+ // We require a `code` or a `file` property, but we can't accept both.
+ if ((details.code === null) == (details.file === null)) {
+ return Promise.reject({ message: `${method} requires either a 'code' or a 'file' property, but not both` });
+ }
+
+ if (details.frameId !== null && details.allFrames) {
+ return Promise.reject({ message: `'frameId' and 'allFrames' are mutually exclusive` });
+ }
+
let recipient = {
innerWindowID: tab.linkedBrowser.innerWindowID,
};
if (TabManager.for(extension).hasActiveTabPermission(tab)) {
// If we have the "activeTab" permission for this tab, ignore
// the host whitelist.
options.matchesHost = ["<all_urls>"];
@@ -526,32 +535,37 @@ extensions.registerSchemaAPI("tabs", nul
if (!extension.isExtensionURL(url)) {
return Promise.reject({ message: "Files to be injected must be within the extension" });
}
options[kind].push(url);
}
if (details.allFrames) {
options.all_frames = details.allFrames;
}
+ if (details.frameId !== null) {
+ options.frame_id = details.frameId;
+ }
if (details.matchAboutBlank) {
options.match_about_blank = details.matchAboutBlank;
}
if (details.runAt !== null) {
options.run_at = details.runAt;
+ } else {
+ options.run_at = "document_idle";
}
return context.sendMessage(mm, "Extension:Execute", { options }, recipient);
},
executeScript: function(tabId, details) {
- return self.tabs._execute(tabId, details, "js");
+ return self.tabs._execute(tabId, details, "js", "executeScript");
},
insertCSS: function(tabId, details) {
- return self.tabs._execute(tabId, details, "css");
+ return self.tabs._execute(tabId, details, "css", "insertCSS");
},
connect: function(tabId, connectInfo) {
let tab = TabManager.getTab(tabId);
let mm = tab.linkedBrowser.messageManager;
let name = "";
if (connectInfo && connectInfo.name !== null) {
--- a/browser/components/extensions/test/browser/browser.ini
+++ b/browser/components/extensions/test/browser/browser.ini
@@ -2,16 +2,18 @@
support-files =
head.js
context.html
ctxmenu-image.png
context_tabs_onUpdated_page.html
context_tabs_onUpdated_iframe.html
file_popup_api_injection_a.html
file_popup_api_injection_b.html
+ file_iframe_document.html
+ file_iframe_document.sjs
[browser_ext_simple.js]
[browser_ext_currentWindow.js]
[browser_ext_browserAction_simple.js]
[browser_ext_browserAction_pageAction_icon.js]
[browser_ext_browserAction_context.js]
[browser_ext_browserAction_disabled.js]
[browser_ext_pageAction_context.js]
@@ -22,21 +24,22 @@ support-files =
[browser_ext_getViews.js]
[browser_ext_lastError.js]
[browser_ext_runtime_setUninstallURL.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_executeScript_runAt.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]
[browser_ext_tabs_onUpdated.js]
[browser_ext_tabs_sendMessage.js]
[browser_ext_tabs_move.js]
[browser_ext_tabs_move_window.js]
[browser_ext_windows_update.js]
[browser_ext_contentscript_connect.js]
[browser_ext_tab_runtimeConnect.js]
-[browser_ext_webNavigation_getFrames.js]
\ No newline at end of file
+[browser_ext_webNavigation_getFrames.js]
--- a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript.js
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript.js
@@ -3,61 +3,156 @@
"use strict";
add_task(function* testExecuteScript() {
let {MessageChannel} = Cu.import("resource://gre/modules/MessageChannel.jsm", {});
let messageManagersSize = MessageChannel.messageManagers.size;
let responseManagersSize = MessageChannel.responseManagers.size;
- let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/", true);
+ const BASE = "http://mochi.test:8888/browser/browser/components/extensions/test/browser/";
+ const URL = BASE + "file_iframe_document.html";
+ let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, URL, true);
function background() {
- browser.tabs.executeScript({
- file: "script.js",
- code: "42",
- }, result => {
- browser.test.assertEq(42, result, "Expected callback result");
- browser.test.sendMessage("got result", result);
- });
+ browser.tabs.query({ active: true, currentWindow: true }).then(tabs => {
+ return browser.webNavigation.getAllFrames({ tabId: tabs[0].id });
+ }).then(frames => {
+ return Promise.all([
+ browser.tabs.executeScript({
+ code: "42",
+ }).then(result => {
+ browser.test.assertEq(42, result, "Expected callback result");
+ }),
+
+ browser.tabs.executeScript({
+ file: "script.js",
+ code: "42",
+ }).then(result => {
+ browser.test.fail("Expected not to be able to execute a script with both file and code");
+ }, error => {
+ browser.test.assertTrue(/a 'code' or a 'file' property, but not both/.test(error.message),
+ "Got expected error");
+ }),
+
+ browser.tabs.executeScript({
+ file: "script.js",
+ }).then(result => {
+ browser.test.assertEq(undefined, result, "Expected callback result");
+ }),
+
+ browser.tabs.executeScript({
+ file: "script2.js",
+ }).then(result => {
+ browser.test.assertEq(27, result, "Expected callback result");
+ }),
+
+ browser.tabs.executeScript({
+ code: "location.href;",
+ allFrames: true,
+ }).then(result => {
+ browser.test.assertTrue(Array.isArray(result), "Result is an array");
+
+ browser.test.assertEq(2, result.length, "Result has correct length");
+
+ browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), "First result is correct");
+ browser.test.assertEq("http://mochi.test:8888/", result[1], "Second result is correct");
+ }),
+
+ browser.tabs.executeScript({
+ code: "location.href;",
+ runAt: "document_end",
+ }).then(result => {
+ browser.test.assertTrue(typeof(result) == "string", "Result is a string");
+
+ browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result), "Result is correct");
+ }),
- browser.tabs.executeScript({
- file: "script2.js",
- }, result => {
- browser.test.assertEq(27, result, "Expected callback result");
- browser.test.sendMessage("got callback", result);
- });
+ browser.tabs.executeScript({
+ code: "window",
+ }).then(result => {
+ browser.test.fail("Expected error when returning non-structured-clonable object");
+ }, error => {
+ browser.test.assertEq("Script returned non-structured-clonable data",
+ error.message, "Got expected error");
+ }),
+
+ browser.tabs.executeScript({
+ code: "Promise.resolve(window)",
+ }).then(result => {
+ browser.test.fail("Expected error when returning non-structured-clonable object");
+ }, error => {
+ browser.test.assertEq("Script returned non-structured-clonable data",
+ error.message, "Got expected error");
+ }),
+
+ browser.tabs.executeScript({
+ code: "Promise.resolve(42)",
+ }).then(result => {
+ browser.test.assertEq(42, result, "Got expected promise resolution value as result");
+ }),
- browser.runtime.onMessage.addListener(message => {
- browser.test.assertEq("script ran", message, "Expected runtime message");
- browser.test.sendMessage("got message", message);
+ browser.tabs.executeScript({
+ code: "location.href;",
+ runAt: "document_end",
+ allFrames: true,
+ }).then(result => {
+ browser.test.assertTrue(Array.isArray(result), "Result is an array");
+
+ browser.test.assertEq(2, result.length, "Result has correct length");
+
+ browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), "First result is correct");
+ browser.test.assertEq("http://mochi.test:8888/", result[1], "Second result is correct");
+ }),
+
+ browser.tabs.executeScript({
+ code: "location.href;",
+ frameId: frames[0].frameId,
+ }).then(result => {
+ browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result), `Result for frameId[0] is correct: ${result}`);
+ }),
+
+ browser.tabs.executeScript({
+ code: "location.href;",
+ frameId: frames[1].frameId,
+ }).then(result => {
+ browser.test.assertEq("http://mochi.test:8888/", result, "Result for frameId[1] is correct");
+ }),
+
+ browser.runtime.onMessage.addListener(message => {
+ browser.test.assertEq("script ran", message, "Expected runtime message");
+ }),
+ ]);
+ }).then(() => {
+ browser.test.notifyPass("executeScript");
+ }).catch(e => {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ browser.test.notifyFail("executeScript");
});
}
let extension = ExtensionTestUtils.loadExtension({
manifest: {
- "permissions": ["http://mochi.test/"],
+ "permissions": ["http://mochi.test/", "webNavigation"],
},
background,
files: {
"script.js": function() {
browser.runtime.sendMessage("script ran");
},
"script2.js": "27",
},
});
yield extension.startup();
- yield extension.awaitMessage("got result");
- yield extension.awaitMessage("got callback");
- yield extension.awaitMessage("got message");
+ yield extension.awaitFinish("executeScript");
yield extension.unload();
yield BrowserTestUtils.removeTab(tab);
// Make sure that we're not holding on to references to closed message
// managers.
is(MessageChannel.messageManagers.size, messageManagersSize, "Message manager count");
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_runAt.js
@@ -0,0 +1,110 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/**
+ * These tests ensure that the runAt argument to tabs.executeScript delays
+ * script execution until the document has reached the correct state.
+ *
+ * Since tests of this nature are especially race-prone, it relies on a
+ * server-JS script to delay the completion of our test page's load cycle long
+ * enough for us to attempt to load our scripts in the earlies phase we support.
+ *
+ * And since we can't actually rely on that timing, it retries any attempts that
+ * fail to load as early as expected, but don't load at any illegal time.
+ */
+
+add_task(function* testExecuteScript() {
+ let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank", true);
+
+ function background() {
+ let tab;
+
+ const BASE = "http://mochi.test:8888/browser/browser/components/extensions/test/browser/";
+ const URL = BASE + "file_iframe_document.sjs";
+
+ const MAX_TRIES = 10;
+ let tries = 0;
+
+ function again() {
+ if (tries++ == MAX_TRIES) {
+ return Promise.reject(new Error("Max tries exceeded"));
+ }
+
+ let loadingPromise = new Promise(resolve => {
+ browser.tabs.onUpdated.addListener(function listener(tabId, changed, tab_) {
+ if (tabId == tab.id && changed.status == "loading" && tab_.url == URL) {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+
+ // TODO: Test allFrames and frameId.
+
+ return browser.tabs.update({ url: URL }).then(() => {
+ return loadingPromise;
+ }).then(() => {
+ return Promise.all([
+ // Send the executeScript requests in the reverse order that we expect
+ // them to execute in, to avoid them passing only because of timing
+ // races.
+ browser.tabs.executeScript({
+ code: "document.readyState",
+ runAt: "document_idle",
+ }),
+ browser.tabs.executeScript({
+ code: "document.readyState",
+ runAt: "document_end",
+ }),
+ browser.tabs.executeScript({
+ code: "document.readyState",
+ runAt: "document_start",
+ }),
+ ].reverse());
+ }).then(states => {
+ browser.test.log(`Got states: ${states}`);
+
+ // Make sure that none of our scripts executed earlier than expected,
+ // regardless of retries.
+ browser.test.assertTrue(states[1] == "interactive" || states[1] == "complete",
+ `document_end state is valid: ${states[1]}`);
+ browser.test.assertTrue(states[2] == "complete",
+ `document_idle state is valid: ${states[2]}`);
+
+ // If we have the earliest valid states for each script, we're done.
+ // Otherwise, try again.
+ if (states[0] != "loading" || states[1] != "interactive" || states[2] != "complete") {
+ return again();
+ }
+ });
+ }
+
+ browser.tabs.query({ active: true, currentWindow: true }).then(tabs => {
+ tab = tabs[0];
+
+ return again();
+ }).then(() => {
+ browser.test.notifyPass("executeScript-runAt");
+ }).catch(e => {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ browser.test.notifyFail("executeScript-runAt");
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ "permissions": ["http://mochi.test/", "tabs"],
+ },
+
+ background,
+ });
+
+ yield extension.startup();
+
+ yield extension.awaitFinish("executeScript-runAt");
+
+ yield extension.unload();
+
+ yield BrowserTestUtils.removeTab(tab);
+});
--- a/browser/components/extensions/test/browser/browser_ext_tabs_insertCSS.js
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_insertCSS.js
@@ -8,89 +8,75 @@ add_task(function* testExecuteScript() {
let messageManagersSize = MessageChannel.messageManagers.size;
let responseManagersSize = MessageChannel.responseManagers.size;
let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/", true);
function background() {
let promises = [
{
- background: "rgb(0, 0, 0)",
- foreground: "rgb(255, 192, 203)",
- promise: resolve => {
- browser.tabs.insertCSS({
- file: "file1.css",
- code: "* { background: black }",
- }, result => {
- browser.test.assertEq(undefined, result, "Expected callback result");
- resolve();
- });
- },
- },
- {
- background: "rgb(0, 0, 0)",
+ background: "transparent",
foreground: "rgb(0, 113, 4)",
- promise: resolve => {
- browser.tabs.insertCSS({
+ promise: () => {
+ return browser.tabs.insertCSS({
file: "file2.css",
- }, result => {
- browser.test.assertEq(undefined, result, "Expected callback result");
- resolve();
});
},
},
{
background: "rgb(42, 42, 42)",
foreground: "rgb(0, 113, 4)",
- promise: resolve => {
- browser.tabs.insertCSS({
+ promise: () => {
+ return browser.tabs.insertCSS({
code: "* { background: rgb(42, 42, 42) }",
- }, result => {
- browser.test.assertEq(undefined, result, "Expected callback result");
- resolve();
});
},
},
];
function checkCSS() {
let computedStyle = window.getComputedStyle(document.body);
return [computedStyle.backgroundColor, computedStyle.color];
}
function next() {
if (!promises.length) {
- browser.test.notifyPass("insertCSS");
return;
}
let { promise, background, foreground } = promises.shift();
- new Promise(promise).then(() => {
- browser.tabs.executeScript({
+ return promise().then(result => {
+ browser.test.assertEq(undefined, result, "Expected callback result");
+
+ return browser.tabs.executeScript({
code: `(${checkCSS})()`,
- }, result => {
- browser.test.assertEq(background, result[0], "Expected background color");
- browser.test.assertEq(foreground, result[1], "Expected foreground color");
- next();
});
+ }).then(result => {
+ browser.test.assertEq(background, result[0], "Expected background color");
+ browser.test.assertEq(foreground, result[1], "Expected foreground color");
+ return next();
});
}
- next();
+ next().then(() => {
+ browser.test.notifyPass("insertCSS");
+ }).catch(e => {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ browser.test.notifyFailure("insertCSS");
+ });
}
let extension = ExtensionTestUtils.loadExtension({
manifest: {
"permissions": ["http://mochi.test/"],
},
background,
files: {
- "file1.css": "* { color: pink }",
"file2.css": "* { color: rgb(0, 113, 4) }",
},
});
yield extension.startup();
yield extension.awaitFinish("insertCSS");
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/file_iframe_document.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title></title>
+</head>
+<body>
+ <iframe src="/"></iframe>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/file_iframe_document.sjs
@@ -0,0 +1,40 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */
+"use strict";
+
+// This script slows the load of an HTML document so that we can reliably test
+// all phases of the load cycle supported by the extension API.
+
+/* eslint-disable no-unused-vars */
+
+const DELAY = 1 * 1000; // Delay one second before completing the request.
+
+const Ci = Components.interfaces;
+
+let nsTimer = Components.Constructor("@mozilla.org/timer;1", "nsITimer", "initWithCallback");
+
+let timer;
+
+function handleRequest(request, response) {
+ response.processAsync();
+
+ response.setHeader("Content-Type", "text/html", false);
+ response.write(`<!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title></title>
+ </head>
+ <body>
+ `);
+
+ // Note: We need to store a reference to the timer to prevent it from being
+ // canceled when it's GCed.
+ timer = new nsTimer(() => {
+ response.write(`
+ <iframe src="/"></iframe>
+ </body>
+ </html>`);
+ response.finish();
+ }, DELAY, Ci.nsITimer.TYPE_ONE_SHOT);
+}
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -146,43 +146,49 @@ Script.prototype = {
if (!(this.matches_.matches(uri) || this.matches_host_.matchesIgnoringPath(uri))) {
return false;
}
if (this.exclude_matches_.matches(uri)) {
return false;
}
- if (!this.options.all_frames && window.top != window) {
+ if (this.options.frame_id != null) {
+ if (WebNavigationFrames.getFrameId(window) != this.options.frame_id) {
+ return false;
+ }
+ } else if (!this.options.all_frames && window.top != window) {
return false;
}
// TODO: match_about_blank.
return true;
},
tryInject(extension, window, sandbox, shouldRun) {
if (!this.matches(window)) {
- this.deferred.reject();
+ this.deferred.reject({ message: "No matching window" });
return;
}
if (shouldRun("document_start")) {
let winUtils = window.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDOMWindowUtils);
for (let url of this.css) {
url = extension.baseURI.resolve(url);
runSafeSyncWithoutClone(winUtils.loadSheetUsingURIString, url, winUtils.AUTHOR_SHEET);
+ this.deferred.resolve();
}
if (this.options.cssCode) {
let url = "data:text/css;charset=utf-8," + encodeURIComponent(this.options.cssCode);
runSafeSyncWithoutClone(winUtils.loadSheetUsingURIString, url, winUtils.AUTHOR_SHEET);
+ this.deferred.resolve();
}
}
let result;
let scheduled = this.run_at || "document_idle";
if (shouldRun(scheduled)) {
for (let url of this.js) {
// On gonk we need to load the resources asynchronously because the
@@ -198,69 +204,68 @@ Script.prototype = {
target: sandbox,
charset: "UTF-8",
async: AppConstants.platform == "gonk",
};
try {
result = Services.scriptloader.loadSubScriptWithOptions(url, options);
} catch (e) {
Cu.reportError(e);
- this.deferred.reject(e.message);
+ this.deferred.reject(e);
}
}
if (this.options.jsCode) {
try {
result = Cu.evalInSandbox(this.options.jsCode, sandbox, "latest");
} catch (e) {
Cu.reportError(e);
- this.deferred.reject(e.message);
+ this.deferred.reject(e);
}
}
+
+ this.deferred.resolve(result);
}
-
- // TODO: Handle this correctly when we support runAt and allFrames.
- this.deferred.resolve(result);
},
};
function getWindowMessageManager(contentWindow) {
let ir = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDocShell)
.QueryInterface(Ci.nsIInterfaceRequestor);
try {
return ir.getInterface(Ci.nsIContentFrameMessageManager);
} catch (e) {
// Some windows don't support this interface (hidden window).
return null;
}
}
+var DocumentManager;
var ExtensionManager;
// Scope in which extension content script code can run. It uses
// Cu.Sandbox to run the code. There is a separate scope for each
// frame.
class ExtensionContext extends BaseContext {
constructor(extensionId, contentWindow, contextOptions = {}) {
super();
let { isExtensionPage } = contextOptions;
this.isExtensionPage = isExtensionPage;
this.extension = ExtensionManager.get(extensionId);
this.extensionId = extensionId;
this.contentWindow = contentWindow;
- let utils = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
- .getInterface(Ci.nsIDOMWindowUtils);
- let outerWindowId = utils.outerWindowID;
- let frameId = contentWindow == contentWindow.top ? 0 : outerWindowId;
+ let frameId = WebNavigationFrames.getFrameId(contentWindow);
this.frameId = frameId;
+ this.scripts = [];
+
let mm = getWindowMessageManager(contentWindow);
this.messageManager = mm;
let prin;
let contentPrincipal = contentWindow.document.nodePrincipal;
let ssm = Services.scriptSecurityManager;
let extensionPrincipal = ssm.createCodebasePrincipal(this.extension.baseURI, {addonId: extensionId});
@@ -329,16 +334,37 @@ class ExtensionContext extends BaseConte
get cloneScope() {
return this.sandbox;
}
execute(script, shouldRun) {
script.tryInject(this.extension, this.contentWindow, this.sandbox, shouldRun);
}
+ addScript(script) {
+ let state = DocumentManager.getWindowState(this.contentWindow);
+ this.execute(script, scheduled => isWhenBeforeOrSame(scheduled, state));
+
+ // Save the script in case it has pending operations in later load
+ // states, but only if we're before document_idle.
+ if (state != "document_idle") {
+ this.scripts.push(script);
+ }
+ }
+
+ triggerScripts(documentState) {
+ for (let script of this.scripts) {
+ this.execute(script, scheduled => scheduled == documentState);
+ }
+ if (documentState == "document_idle") {
+ // Don't bother saving scripts after document_idle.
+ this.scripts.length = 0;
+ }
+ }
+
close() {
super.unload();
// Overwrite the content script APIs with an empty object if the APIs objects are still
// defined in the content window (See Bug 1214658 for rationale).
if (this.isExtensionPage && !Cu.isDeadWrapper(this.contentWindow) &&
Cu.waiveXrays(this.contentWindow).browser === this.chromeObj) {
Cu.createObjectIn(this.contentWindow, { defineAs: "browser" });
@@ -352,17 +378,17 @@ class ExtensionContext extends BaseConte
function windowId(window) {
return window.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDOMWindowUtils)
.currentInnerWindowID;
}
// Responsible for creating ExtensionContexts and injecting content
// scripts into them when new documents are created.
-var DocumentManager = {
+DocumentManager = {
extensionCount: 0,
// Map[windowId -> Map[extensionId -> ExtensionContext]]
contentScriptWindows: new Map(),
// Map[windowId -> ExtensionContext]
extensionPageWindows: new Map(),
@@ -373,22 +399,22 @@ var DocumentManager = {
uninit() {
Services.obs.removeObserver(this, "document-element-inserted");
Services.obs.removeObserver(this, "inner-window-destroyed");
},
getWindowState(contentWindow) {
let readyState = contentWindow.document.readyState;
- if (readyState == "loading") {
- return "document_start";
+ if (readyState == "complete") {
+ return "document_idle";
} else if (readyState == "interactive") {
return "document_end";
} else {
- return "document_idle";
+ return "document_start";
}
},
observe: function(subject, topic, data) {
if (topic == "document-element-inserted") {
let document = subject;
let window = document && document.defaultView;
if (!document || !document.location || !window) {
@@ -455,35 +481,48 @@ var DocumentManager = {
if (event.type == "DOMContentLoaded") {
this.trigger("document_end", window);
} else if (event.type == "load") {
this.trigger("document_idle", window);
}
},
- executeScript(global, extensionId, script) {
- let window = global.content;
- let context = this.getContentScriptContext(extensionId, window);
- if (!context) {
- throw new Error("Unexpected add-on ID");
- }
+ executeScript(global, extensionId, options) {
+ let executeInWin = (window) => {
+ let deferred = PromiseUtils.defer();
+ let script = new Script(options, deferred);
+
+ if (script.matches(window)) {
+ let context = this.getContentScriptContext(extensionId, window);
+ context.addScript(script);
+ return deferred.promise;
+ }
+ return null;
+ };
- // TODO: Somehow make sure we have the right permissions for this origin!
+ let promises = Array.from(this.enumerateWindows(global.docShell), executeInWin)
+ .filter(promise => promise);
- // FIXME: Script should be executed only if current state has
- // already reached its run_at state, or we have to keep it around
- // somewhere to execute later.
- context.execute(script, scheduled => true);
+ if (!promises.length) {
+ return Promise.reject({ message: `No matching window` });
+ }
+ if (options.all_frames) {
+ return Promise.all(promises);
+ }
+ if (promises.length > 1) {
+ return Promise.reject({ message: `Internal error: Script matched multiple windows` });
+ }
+ return promises[0];
},
enumerateWindows: function*(docShell) {
let window = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDOMWindow);
- yield [window, this.getWindowState(window)];
+ yield window;
for (let i = 0; i < docShell.childCount; i++) {
let child = docShell.getChildAt(i).QueryInterface(Ci.nsIDocShell);
yield* this.enumerateWindows(child);
}
},
getContentScriptContext(extensionId, window) {
@@ -519,21 +558,21 @@ var DocumentManager = {
}
this.extensionCount++;
let extension = ExtensionManager.get(extensionId);
for (let global of ExtensionContent.globals.keys()) {
// Note that we miss windows in the bfcache here. In theory we
// could execute content scripts on a pageshow event for that
// window, but that seems extreme.
- for (let [window, state] of this.enumerateWindows(global.docShell)) {
+ for (let window of this.enumerateWindows(global.docShell)) {
for (let script of extension.scripts) {
if (script.matches(window)) {
let context = this.getContentScriptContext(extensionId, window);
- context.execute(script, scheduled => isWhenBeforeOrSame(scheduled, state));
+ context.addScript(script);
}
}
}
}
},
shutdownExtension(extensionId) {
// Clean up content-script contexts on extension shutdown.
@@ -558,23 +597,31 @@ var DocumentManager = {
this.extensionCount--;
if (this.extensionCount == 0) {
this.uninit();
}
},
trigger(when, window) {
let state = this.getWindowState(window);
- for (let [extensionId, extension] of ExtensionManager.extensions) {
- for (let script of extension.scripts) {
- if (script.matches(window)) {
- let context = this.getContentScriptContext(extensionId, window);
- context.execute(script, scheduled => scheduled == state);
+
+ if (state == "document_start") {
+ for (let [extensionId, extension] of ExtensionManager.extensions) {
+ for (let script of extension.scripts) {
+ if (script.matches(window)) {
+ let context = this.getContentScriptContext(extensionId, window);
+ context.addScript(script);
+ }
}
}
+ } else {
+ let contexts = this.contentScriptWindows.get(windowId(window)) || new Map();
+ for (let context of contexts.values()) {
+ context.triggerScripts(state);
+ }
}
},
};
// Represents a browser extension in the content process.
function BrowserExtensionContent(data) {
this.id = data.id;
this.uuid = data.uuid;
@@ -685,29 +732,26 @@ class ExtensionGlobal {
}
uninit() {
this.global.sendAsyncMessage("Extension:RemoveTopWindowID", { windowId: this.windowId });
}
get messageFilter() {
return {
- innerWindowID: this.global.content
- .QueryInterface(Ci.nsIInterfaceRequestor)
- .getInterface(Ci.nsIDOMWindowUtils)
- .currentInnerWindowID,
+ innerWindowID: windowId(this.global.content),
};
}
receiveMessage({ target, messageName, recipient, data }) {
switch (messageName) {
case "Extension:Capture":
return this.handleExtensionCapture(data.width, data.height, data.options);
case "Extension:Execute":
- return this.handleExtensionExecute(target, recipient, data.options);
+ return this.handleExtensionExecute(target, recipient.extensionId, data.options);
case "WebNavigation:GetFrame":
return this.handleWebNavigationGetFrame(data.options);
case "WebNavigation:GetAllFrames":
return this.handleWebNavigationGetAllFrames();
}
}
handleExtensionCapture(width, height, options) {
@@ -726,22 +770,27 @@ class ExtensionGlobal {
// 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/${options.format}`, options.quality / 100);
}
- handleExtensionExecute(target, recipient, options) {
- let deferred = PromiseUtils.defer();
- let script = new Script(options, deferred);
- let { extensionId } = recipient;
- DocumentManager.executeScript(target, extensionId, script);
- return deferred.promise;
+ handleExtensionExecute(target, extensionId, options) {
+ return DocumentManager.executeScript(target, extensionId, options).then(result => {
+ try {
+ // Make sure we can structured-clone the result value before
+ // we try to send it back over the message manager.
+ Cu.cloneInto(result, target);
+ } catch (e) {
+ return Promise.reject({ message: "Script returned non-structured-clonable data" });
+ }
+ return result;
+ });
}
handleWebNavigationGetFrame({ frameId }) {
return WebNavigationFrames.getFrame(this.global.docShell, frameId);
}
handleWebNavigationGetAllFrames() {
return WebNavigationFrames.getAllFrames(this.global.docShell);
--- a/toolkit/components/extensions/schemas/extension_types.json
+++ b/toolkit/components/extensions/schemas/extension_types.json
@@ -42,16 +42,21 @@
"id": "InjectDetails",
"type": "object",
"description": "Details of the script or CSS to inject. Either the code or the file property must be set, but both may not be set at the same time.",
"properties": {
"code": {"type": "string", "optional": true, "description": "JavaScript or CSS code to inject.<br><br><b>Warning:</b><br>Be careful using the <code>code</code> parameter. Incorrect use of it may open your extension to <a href=\"https://en.wikipedia.org/wiki/Cross-site_scripting\">cross site scripting</a> attacks."},
"file": {"type": "string", "optional": true, "description": "JavaScript or CSS file to inject."},
"allFrames": {"type": "boolean", "optional": true, "description": "If allFrames is <code>true</code>, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's <code>false</code> and is only injected into the top frame."},
"matchAboutBlank": {"type": "boolean", "optional": true, "description": "If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is <code>false</code>."},
+ "frameId": {
+ "type": "integer",
+ "optional": true,
+ "description": "The ID of the frame to inject the script into. This may not be used in combination with <code>allFrames</code>."
+ },
"runAt": {
"$ref": "RunAt",
"optional": true,
"description": "The soonest that the JavaScript or CSS will be injected into the tab. Defaults to \"document_idle\"."
}
}
}
]
--- a/toolkit/modules/addons/WebNavigationFrames.jsm
+++ b/toolkit/modules/addons/WebNavigationFrames.jsm
@@ -74,41 +74,66 @@ function* iterateDocShellTree(docShell)
while (docShellsEnum.hasMoreElements()) {
yield docShellsEnum.getNext();
}
return null;
}
/**
+ * Returns the frame ID of the given window. If the window is the
+ * top-level content window, its frame ID is 0. Otherwise, its frame ID
+ * is its outer window ID.
+ *
+ * @param {Window} window - The window to retrieve the frame ID for.
+ * @returns {number}
+ */
+function getFrameId(window) {
+ let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDocShell);
+
+ if (!docShell.sameTypeParent) {
+ return 0;
+ }
+
+ let utils = window.getInterface(Ci.nsIDOMWindowUtils);
+ return utils.outerWindowID;
+}
+
+/**
* Search for a frame starting from the passed root docShell and
* convert it to its related frame detail representation.
*
- * @param {number} windowId - the windowId of the frame to retrieve
+ * @param {number} frameId - the frame ID of the frame to retrieve, as
+ * described in getFrameId.
* @param {nsIDocShell} docShell - the root docShell object
* @return {FrameDetail} the FrameDetail JSON object which represents the docShell.
*/
-function findFrame(windowId, rootDocShell) {
+function findDocShell(frameId, rootDocShell) {
for (let docShell of iterateDocShellTree(rootDocShell)) {
- if (windowId == getWindowId(docShellToWindow(docShell))) {
- return convertDocShellToFrameDetail(docShell);
+ if (frameId == getFrameId(docShellToWindow(docShell))) {
+ return docShell;
}
}
return null;
}
var WebNavigationFrames = {
+ findDocShell,
+
getFrame(docShell, frameId) {
- if (frameId == 0) {
- return convertDocShellToFrameDetail(docShell);
+ let result = findDocShell(frameId, docShell);
+ if (result) {
+ return convertDocShellToFrameDetail(result);
}
+ return null;
+ },
- return findFrame(frameId, docShell);
- },
+ getFrameId,
getAllFrames(docShell) {
return Array.from(iterateDocShellTree(docShell), convertDocShellToFrameDetail);
},
getWindowId,
getParentWindowId,
};