author | Kris Maglione <maglione.k@gmail.com> |
Mon, 08 Feb 2016 17:40:02 -0800 | |
changeset 324001 | 36d6bc68fe0f21d87b306c7712e939d8ae537b88 |
parent 324000 | 88df606b81dadf05046728d14428214dfc00e0af |
child 324002 | b5c0cd56381547fe527c724d86eb955c209e0a6e |
push id | 1128 |
push user | jlund@mozilla.com |
push date | Wed, 01 Jun 2016 01:31:59 +0000 |
treeherder | mozilla-release@fe0d30de989d [default view] [failures only] |
perfherder | [talos] [build metrics] [platform microbench] (compared to previous push) |
reviewers | billm |
bugs | 1213993 |
milestone | 47.0a1 |
first release with | nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
|
last release without | nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
|
--- a/browser/components/extensions/ext-tabs.js +++ b/browser/components/extensions/ext-tabs.js @@ -638,25 +638,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>"]; @@ -672,32 +681,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_commands.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] @@ -24,25 +26,26 @@ support-files = [browser_ext_lastError.js] [browser_ext_runtime_setUninstallURL.js] [browser_ext_tabs_audio.js] [browser_ext_tabs_captureVisibleTab.js] [browser_ext_tabs_events.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_create_invalid_url.js] [browser_ext_tabs_duplicate.js] [browser_ext_tabs_update.js] [browser_ext_tabs_update_url.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_create_tabId.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,160 @@ "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 => { + browser.test.log(`FRAMES: ${frames[1].frameId} ${JSON.stringify(frames)}\n`); + 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.tabs.executeScript({ + code: "location.href;", + runAt: "document_end", + allFrames: true, + }).then(result => { + browser.test.assertTrue(Array.isArray(result), "Result is an array"); - browser.runtime.onMessage.addListener(message => { - browser.test.assertEq("script ran", message, "Expected runtime message"); - browser.test.sendMessage("got message", message); + 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"); + }), + + new Promise(resolve => { + browser.runtime.onMessage.addListener(message => { + browser.test.assertEq("script ran", message, "Expected runtime message"); + resolve(); + }); + }), + ]); + }).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 @@ -161,43 +161,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 @@ -213,69 +219,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}); @@ -344,16 +349,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"}); @@ -367,17 +393,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(), @@ -388,22 +414,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) { @@ -470,35 +496,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) { @@ -534,21 +573,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. @@ -573,23 +612,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; @@ -700,29 +747,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) { @@ -741,22 +785,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.jsm +++ b/toolkit/components/extensions/Schemas.jsm @@ -660,17 +660,17 @@ class IntegerType extends Type { normalize(value, context) { let r = this.normalizeBase("integer", value, context); if (r.error) { return r; } // Ensure it's between -2**31 and 2**31-1 - if ((value | 0) !== value) { + if (!Number.isSafeInteger(value)) { return context.error("Integer is out of range"); } if (value < this.minimum) { return context.error(`Integer ${value} is too small (must be at least ${this.minimum})`); } if (value > this.maximum) { return context.error(`Integer ${value} is too big (must be at most ${this.maximum})`);
--- a/toolkit/components/extensions/schemas/extension_types.json +++ b/toolkit/components/extensions/schemas/extension_types.json @@ -42,16 +42,22 @@ "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", + "minimum": 0, + "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,43 +74,69 @@ 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. + * @return {nsIDocShell?} the docShell with the given frameId, or null + * if no match. */ -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 = { iterateDocShellTree, + 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, };