diff --git a/dom/bindings/Bindings.conf b/dom/bindings/Bindings.conf index 93ba34d7ee..3fd3af1442 100644 --- a/dom/bindings/Bindings.conf +++ b/dom/bindings/Bindings.conf @@ -568,6 +568,12 @@ DOMInterfaces = { 'notflattened': True }, +'Cookie': { + 'nativeType': 'nsICookie2', + 'headerFile': 'nsICookie2.h', + 'notflattened': True, +}, + 'IntersectionObserver': { 'nativeType': 'mozilla::dom::DOMIntersectionObserver', }, @@ -595,6 +601,21 @@ DOMInterfaces = { 'nativeType': 'mozilla::DOMLocalMediaStream' }, +'MatchGlob': { + 'nativeType': 'mozilla::extensions::MatchGlob', + 'headerFile': 'mozilla/extensions/MatchGlob.h', +}, + +'MatchPattern': { + 'nativeType': 'mozilla::extensions::MatchPattern', + 'headerFile': 'mozilla/extensions/MatchPattern.h', +}, + +'MatchPatternSet': { + 'nativeType': 'mozilla::extensions::MatchPatternSet', + 'headerFile': 'mozilla/extensions/MatchPattern.h', +}, + 'MediaList': { 'nativeType': 'nsMediaList', 'headerFile': 'nsIMediaList.h', @@ -644,6 +665,12 @@ DOMInterfaces = { 'notflattened': True }, +'LoadInfo': { + 'nativeType': 'nsILoadInfo', + 'headerFile': 'nsILoadInfo.h', + 'notflattened': True, +}, + 'MozSpeakerManager': { 'nativeType': 'mozilla::dom::SpeakerManager', 'headerFile': 'SpeakerManager.h' @@ -1390,6 +1417,16 @@ DOMInterfaces = { 'concrete': False, }, +'WebExtensionContentScript': { + 'nativeType': 'mozilla::extensions::WebExtensionContentScript', + 'headerFile': 'mozilla/extensions/WebExtensionContentScript.h', +}, + +'WebExtensionPolicy': { + 'nativeType': 'mozilla::extensions::WebExtensionPolicy', + 'headerFile': 'mozilla/extensions/WebExtensionPolicy.h', +}, + 'Window': { 'nativeType': 'nsGlobalWindow', 'binaryNames': { diff --git a/dom/webidl/MatchGlob.webidl b/dom/webidl/MatchGlob.webidl new file mode 100644 index 0000000000..8762d28346 --- /dev/null +++ b/dom/webidl/MatchGlob.webidl @@ -0,0 +1,24 @@ +/* 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/. */ + +/** + * Represents a simple glob pattern matcher. Any occurrence of "*" in the glob + * pattern matches any literal string of characters in the string being + * compared. Additionally, if created with `allowQuestion = true`, any + * occurrence of "?" in the glob matches any single literal character. + */ +[Constructor(DOMString glob, optional boolean allowQuestion = true), + ChromeOnly, Exposed=(Window,System)] +interface MatchGlob { + /** + * Returns true if the string matches the glob. + */ + boolean matches(DOMString string); + + /** + * The glob string this MatchGlob represents. + */ + [Constant] + readonly attribute DOMString glob; +}; diff --git a/dom/webidl/MatchPattern.webidl b/dom/webidl/MatchPattern.webidl new file mode 100644 index 0000000000..545151cca8 --- /dev/null +++ b/dom/webidl/MatchPattern.webidl @@ -0,0 +1,121 @@ +/* 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/. */ + +interface Cookie; +interface URI; + +/** + * A URL match pattern as used by the WebExtension and Chrome extension APIs. + * + * A match pattern is a string with one of the following formats: + * + * - "" + * The literal string "" matches any URL with a supported + * protocol. + * + * - :/// + * A URL pattern with the following placeholders: + * + * - + * The protocol to match, or "*" to match either "http" or "https". + * - + * The hostname to match. May be either a complete, literal hostname to + * match a specific host, the wildcard character "*", to match any host, + * or a subdomain pattern, with "*." followed by a domain name, to match + * that domain name or any subdomain thereof. + * - + * A glob pattern for paths to match. A "*" may appear anywhere within + * the path, and will match any string of characters. If no "*" appears, + * the URL path must exactly match the pattern path. + */ +[Constructor(DOMString pattern, optional MatchPatternOptions options), + ChromeOnly, Exposed=(Window,System)] +interface MatchPattern { + /** + * Returns true if the given URI matches the pattern. + * + * If explicit is true, only explicit domain matches, without wildcards, are + * considered. + */ + boolean matches(URI uri, optional boolean explicit = false); + + /** + * Returns true if a URL exists which a) would be able to access the given + * cookie, and b) would be matched by this match pattern. + */ + boolean matchesCookie(Cookie cookie); + + /** + * Returns true if this pattern will match any host which would be matched + * by the given pattern. + */ + boolean subsumes(MatchPattern pattern); + + /** + * Returns true if there is any host which would be matched by both this + * pattern and the given pattern. + */ + boolean overlaps(MatchPattern pattern); + + /** + * The match pattern string represented by this pattern. + */ + [Constant] + readonly attribute DOMString pattern; +}; + +/** + * A set of MatchPattern objects, which implements the MatchPattern API and + * matches when any of its sub-patterns matches. + */ +[Constructor(sequence<(DOMString or MatchPattern)> patterns, + optional MatchPatternOptions options), + ChromeOnly, Exposed=(Window,System)] +interface MatchPatternSet { + /** + * Returns true if the given URI matches any sub-pattern. + * + * If explicit is true, only explicit domain matches, without wildcards, are + * considered. + */ + boolean matches(URI uri, optional boolean explicit = false); + + /** + * Returns true if any sub-pattern matches the given cookie. + */ + boolean matchesCookie(Cookie cookie); + + /** + * Returns true if any sub-pattern subsumes the given pattern. + */ + boolean subsumes(MatchPattern pattern); + + /** + * Returns true if any sub-pattern overlaps the given pattern. + */ + boolean overlaps(MatchPattern pattern); + + /** + * Returns true if any sub-pattern overlaps any sub-pattern the given + * pattern set. + */ + boolean overlaps(MatchPatternSet patternSet); + + /** + * Returns true if any sub-pattern overlaps *every* sub-pattern in the given + * pattern set. + */ + boolean overlapsAll(MatchPatternSet patternSet); + + [Cached, Constant, Frozen] + readonly attribute sequence patterns; +}; + +dictionary MatchPatternOptions { + /** + * If true, the path portion of the pattern is ignored, and replaced with a + * wildcard. The `pattern` property is updated to reflect this. + */ + boolean ignorePath = false; +}; diff --git a/dom/webidl/WebExtensionContentScript.webidl b/dom/webidl/WebExtensionContentScript.webidl new file mode 100644 index 0000000000..b0d1a8ff63 --- /dev/null +++ b/dom/webidl/WebExtensionContentScript.webidl @@ -0,0 +1,161 @@ +/* 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/. */ + +interface LoadInfo; +interface URI; +interface WindowProxy; + +/** + * Describes the earliest point in the load cycle at which a script should + * run. + */ +enum ContentScriptRunAt { + /** + * The point in the load cycle just after the document element has been + * inserted, before any page scripts have been allowed to run. + */ + "document_start", + /** + * The point after which the page DOM has fully loaded, but before all page + * resources have necessarily been loaded. Corresponds approximately to the + * DOMContentLoaded event. + */ + "document_end", + /** + * The first point after the page and all of its resources has fully loaded + * when the event loop is idle, and can run scripts without delaying a paint + * event. + */ + "document_idle", +}; + +[Constructor(WebExtensionPolicy extension, WebExtensionContentScriptInit options), ChromeOnly, Exposed=System] +interface WebExtensionContentScript { + /** + * Returns true if the script's match and exclude patterns match the given + * URI, without reference to attributes such as `allFrames`. + */ + boolean matchesURI(URI uri); + + /** + * Returns true if the script matches the given URI and LoadInfo objects. + * This should be used to determine whether to begin pre-loading a content + * script based on network events. + */ + boolean matchesLoadInfo(URI uri, LoadInfo loadInfo); + + /** + * Returns true if the script matches the given window. This should be used + * to determine whether to run a script in a window at load time. + */ + boolean matchesWindow(WindowProxy window); + + /** + * The policy object for the extension that this script belongs to. + */ + [Constant] + readonly attribute WebExtensionPolicy extension; + + /** + * If true, this script runs in all frames. If false, it only runs in + * top-level frames. + */ + [Constant] + readonly attribute boolean allFrames; + + /** + * If true, this (misleadingly-named, but inherited from Chrome) attribute + * causes the script to run in frames with URLs which inherit a principal + * that matches one of the match patterns, such as about:blank or + * about:srcdoc. If false, the script only runs in frames with an explicit + * matching URL. + */ + [Constant] + readonly attribute boolean matchAboutBlank; + + /** + * The earliest point in the load cycle at which this script should run. For + * static content scripts, in extensions which were present at browser + * startup, the browser makes every effort to make sure that the script runs + * no later than this point in the load cycle. For dynamic content scripts, + * and scripts from extensions installed during this session, the scripts + * may run at a later point. + */ + [Constant] + readonly attribute ContentScriptRunAt runAt; + + /** + * The outer window ID of the frame in which to run the script, or 0 if it + * should run in the top-level frame. Should only be used for + * dynamically-injected scripts. + */ + [Constant] + readonly attribute unsigned long long? frameID; + + /** + * The set of match patterns for URIs of pages in which this script should + * run. This attribute is mandatory, and is a prerequisite for all other + * match patterns. + */ + [Constant] + readonly attribute MatchPatternSet matches; + + /** + * A set of match patterns for URLs in which this script should not run, + * even if they match other include patterns or globs. + */ + [Constant] + readonly attribute MatchPatternSet? excludeMatches; + + /** + * A set of glob matchers for URLs in which this script should run. If this + * list is present, the script will only run in URLs which match the + * `matches` pattern as well as one of these globs. + */ + [Cached, Constant, Frozen] + readonly attribute sequence? includeGlobs; + + /** + * A set of glob matchers for URLs in which this script should not run, even + * if they match other include patterns or globs. + */ + [Cached, Constant, Frozen] + readonly attribute sequence? excludeGlobs; + + /** + * A set of paths, relative to the extension root, of CSS sheets to inject + * into matching pages. + */ + [Cached, Constant, Frozen] + readonly attribute sequence cssPaths; + + /** + * A set of paths, relative to the extension root, of JavaScript scripts to + * execute in matching pages. + */ + [Cached, Constant, Frozen] + readonly attribute sequence jsPaths; +}; + +dictionary WebExtensionContentScriptInit { + boolean allFrames = false; + + boolean matchAboutBlank = false; + + ContentScriptRunAt runAt = "document_idle"; + + unsigned long long? frameID = null; + + required MatchPatternSet matches; + + MatchPatternSet? excludeMatches = null; + + sequence? includeGlobs = null; + + sequence? excludeGlobs = null; + + sequence cssPaths = []; + + sequence jsPaths = []; +}; diff --git a/dom/webidl/WebExtensionPolicy.webidl b/dom/webidl/WebExtensionPolicy.webidl new file mode 100644 index 0000000000..f1cedbb33c --- /dev/null +++ b/dom/webidl/WebExtensionPolicy.webidl @@ -0,0 +1,158 @@ +/* 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/. */ + +interface URI; +interface WindowProxy; + +callback WebExtensionLocalizeCallback = DOMString (DOMString unlocalizedText); + +/** + * Defines the platform-level policies for a WebExtension, including its + * permissions and the characteristics of its moz-extension: URLs. + */ +[Constructor(WebExtensionInit options), ChromeOnly, Exposed=System] +interface WebExtensionPolicy { + /** + * The add-on's internal ID, as specified in its manifest.json file or its + * XPI signature. + */ + [Constant, StoreInSlot] + readonly attribute DOMString id; + + /** + * The hostname part of the extension's moz-extension: URLs. This value is + * generated randomly at install time. + */ + [Constant, StoreInSlot] + readonly attribute ByteString mozExtensionHostname; + + /** + * The file: or jar: URL to use for the base of the extension's + * moz-extension: URL root. + */ + [Constant] + readonly attribute ByteString baseURL; + + /** + * The content security policy string to apply to all pages loaded from the + * extension's moz-extension: protocol. + */ + [Constant] + readonly attribute DOMString contentSecurityPolicy; + + + /** + * The list of currently-active permissions for the extension, as specified + * in its manifest.json file. May be updated to reflect changes in the + * extension's optional permissions. + */ + [Cached, Frozen, Pure] + attribute sequence permissions; + + /** + * Match patterns for the set of web origins to which the extension is + * currently allowed access. May be updated to reflect changes in the + * extension's optional permissions. + */ + [Pure] + attribute MatchPatternSet allowedOrigins; + + /** + * The set of content scripts active for this extension. + */ + [Cached, Constant, Frozen] + readonly attribute sequence contentScripts; + + /** + * True if the extension is currently active, false otherwise. When active, + * the extension's moz-extension: protocol will point to the given baseURI, + * and the set of policies for this object will be active for its ID. + * + * Only one extension policy with a given ID or hostname may be active at a + * time. Attempting to activate a policy while a conflicting policy is + * active will raise an error. + */ + [Affects=Everything, SetterThrows] + attribute boolean active; + + + static readonly attribute boolean isExtensionProcess; + + + /** + * Returns true if the extension has cross-origin access to the given URI. + */ + boolean canAccessURI(URI uri, optional boolean explicit = false); + + /** + * Returns true if the extension currently has the given permission. + */ + boolean hasPermission(DOMString permission); + + /** + * Returns true if the given path relative to the extension's moz-extension: + * URL root may be accessed by web content. + */ + boolean isPathWebAccessible(DOMString pathname); + + /** + * Replaces localization placeholders in the given string with localized + * text from the extension's currently active locale. + */ + DOMString localize(DOMString unlocalizedText); + + /** + * Returns the moz-extension: URL for the given path. + */ + [Throws] + DOMString getURL(optional DOMString path = ""); + + + /** + * Returns the list of currently active extension policies. + */ + static sequence getActiveExtensions(); + + /** + * Returns the currently-active policy for the extension with the given ID, + * or null if no policy is active for that ID. + */ + static WebExtensionPolicy? getByID(DOMString id); + + /** + * Returns the currently-active policy for the extension with the given + * moz-extension: hostname, or null if no policy is active for that + * hostname. + */ + static WebExtensionPolicy? getByHostname(ByteString hostname); + + /** + * Returns the currently-active policy for the extension extension URI, or + * null if the URI is not an extension URI, or no policy is currently active + * for it. + */ + static WebExtensionPolicy? getByURI(URI uri); +}; + +dictionary WebExtensionInit { + required DOMString id; + + required ByteString mozExtensionHostname; + + required DOMString baseURL; + + required WebExtensionLocalizeCallback localizeCallback; + + required MatchPatternSet allowedOrigins; + + sequence permissions = []; + + sequence webAccessibleResources = []; + + sequence contentScripts = []; + + DOMString? contentSecurityPolicy = null; + + sequence? backgroundScripts = null; +}; diff --git a/dom/webidl/moz.build b/dom/webidl/moz.build index dedaff0f74..3681e297af 100644 --- a/dom/webidl/moz.build +++ b/dom/webidl/moz.build @@ -297,6 +297,8 @@ WEBIDL_FILES = [ 'ListBoxObject.webidl', 'LocalMediaStream.webidl', 'Location.webidl', + 'MatchGlob.webidl', + 'MatchPattern.webidl', 'MathMLElement.webidl', 'MediaDeviceInfo.webidl', 'MediaDevices.webidl', @@ -560,6 +562,8 @@ WEBIDL_FILES = [ 'VTTRegion.webidl', 'WaveShaperNode.webidl', 'WebComponents.webidl', + 'WebExtensionContentScript.webidl', + 'WebExtensionPolicy.webidl', 'WebGL2RenderingContext.webidl', 'WebGLRenderingContext.webidl', 'WebKitCSSMatrix.webidl', diff --git a/toolkit/components/extensions/.eslintrc.js b/toolkit/components/extensions/.eslintrc.js new file mode 100644 index 0000000000..2b0cfac55d --- /dev/null +++ b/toolkit/components/extensions/.eslintrc.js @@ -0,0 +1,317 @@ +"use strict"; + +module.exports = { + + "globals": { + // These are defined in the WebExtension script scopes by ExtensionCommon.jsm + "Cc": true, + "Ci": true, + "Cr": true, + "Cu": true, + "AppConstants": true, + "ExtensionAPI": true, + "ExtensionCommon": true, + "ExtensionUtils": true, + "extensions": true, + "global": true, + "require": false, + "Services": true, + "XPCOMUtils": true, + }, + + "rules": { + // Rules from the mozilla plugin + "mozilla/balanced-listeners": "error", + "mozilla/no-aArgs": "error", + "mozilla/no-cpows-in-tests": "warn", + "mozilla/var-only-at-top-level": "warn", + + "valid-jsdoc": ["error", { + "prefer": { + "return": "returns", + }, + "preferType": { + "Boolean": "boolean", + "Number": "number", + "String": "string", + "bool": "boolean", + }, + "requireParamDescription": false, + "requireReturn": false, + "requireReturnDescription": false, + }], + + // Forbid spaces inside the square brackets of array literals. + "array-bracket-spacing": ["error", "never"], + + // Forbid spaces inside the curly brackets of object literals. + "object-curly-spacing": ["error", "never"], + + // No space padding in parentheses + "space-in-parens": ["error", "never"], + + // Commas at the end of the line not the start + "comma-style": "error", + + // Functions are not required to consistently return something or nothing + "consistent-return": "off", + + // Require braces around blocks that start a new line + "curly": ["error", "all"], + + // Require function* name() + "generator-star-spacing": ["error", {"before": false, "after": true}], + + // Two space indent + "indent": ["error", 2, {"SwitchCase": 1, "ArrayExpression": "first", "ObjectExpression": "first"}], + + // Always require parenthesis for new calls + "new-parens": "error", + + // Disallow empty statements. This will report an error for: + // try { something(); } catch (e) {} + // but will not report it for: + // try { something(); } catch (e) { /* Silencing the error because ...*/ } + // which is a valid use case. + "no-empty": "error", + + // No mixing different operators without parens + "no-mixed-operators": ["error", {"groups": [["&&", "||"], ["==", "!=", "===", "!==", ">", ">=", "<", "<="], ["in", "instanceof"]]}], + + // Disallow use of multiple spaces (sometimes used to align const values, + // array or object items, etc.). It's hard to maintain and doesn't add that + // much benefit. + "no-multi-spaces": "warn", + + // No expressions where a statement is expected + "no-unused-expressions": "error", + + // No declaring variables that are never used + "no-unused-vars": ["error", {"args": "none", "varsIgnorePattern": "^(Cc|Ci|Cr|Cu|EXPORTED_SYMBOLS)$"}], + + // No using variables before defined + "no-use-before-define": "error", + + // Always require semicolon at end of statement + "semi": ["error", "always"], + + // Never use spaces before function parentheses + "space-before-function-paren": ["error", {"anonymous": "never", "named": "never"}], + + // ++ and -- should not need spacing + "space-unary-ops": ["warn", {"nonwords": false, "words": true, "overrides": {"typeof": false}}], + + // Disallow using variables outside the blocks they are defined (especially + // since only let and const are used, see "no-var"). + "block-scoped-var": "error", + + // Allow trailing commas for easy list extension. Having them does not + // impair readability, but also not required either. + "comma-dangle": ["error", "always-multiline"], + + // Warn about cyclomatic complexity in functions. + "complexity": "error", + + // Don't warn for inconsistent naming when capturing this (not so important + // with auto-binding fat arrow functions). + // "consistent-this": ["error", "self"], + + // Don't require a default case in switch statements. Avoid being forced to + // add a bogus default when you know all possible cases are handled. + "default-case": "off", + + // Enforce dots on the next line with property name. + "dot-location": ["error", "property"], + + // Encourage the use of dot notation whenever possible. + "dot-notation": "error", + + // Allow using == instead of ===, in the interest of landing something since + // the devtools codebase is split on convention here. + "eqeqeq": "off", + + // Don't require function expressions to have a name. + // This makes the code more verbose and hard to read. Our engine already + // does a fantastic job assigning a name to the function, which includes + // the enclosing function name, and worst case you have a line number that + // you can just look up. + "func-names": "off", + + // Allow use of function declarations and expressions. + "func-style": "off", + + // Maximum length of a line. + // Disabled because we exceed this in too many places. + "max-len": [0, 80], + + // Maximum depth callbacks can be nested. + "max-nested-callbacks": ["error", 4], + + // Don't limit the number of parameters that can be used in a function. + "max-params": "off", + + // Don't limit the maximum number of statement allowed in a function. We + // already have the complexity rule that's a better measurement. + "max-statements": "off", + + // Don't require a capital letter for constructors, only check if all new + // operators are followed by a capital letter. Don't warn when capitalized + // functions are used without the new operator. + "new-cap": ["off", {"capIsNew": false}], + + // Allow use of bitwise operators. + "no-bitwise": "off", + + // Disallow use of arguments.caller or arguments.callee. + "no-caller": "error", + + // Disallow the catch clause parameter name being the same as a variable in + // the outer scope, to avoid confusion. + "no-catch-shadow": "off", + + // Disallow using the console API. + "no-console": "error", + + // Allow using constant expressions in conditions like while (true) + "no-constant-condition": "off", + + // Allow use of the continue statement. + "no-continue": "off", + + // Allow division operators explicitly at beginning of regular expression. + "no-div-regex": "off", + + // Disallow adding to native types + "no-extend-native": "error", + + // Allow unnecessary parentheses, as they may make the code more readable. + "no-extra-parens": "off", + + // Disallow fallthrough of case statements, except if there is a comment. + "no-fallthrough": "error", + + // Allow the use of leading or trailing decimal points in numeric literals. + "no-floating-decimal": "off", + + // Allow comments inline after code. + "no-inline-comments": "off", + + // Disallow use of labels for anything other then loops and switches. + "no-labels": ["error", {"allowLoop": true}], + + // Disallow use of multiline strings (use template strings instead). + "no-multi-str": "warn", + + // Disallow multiple empty lines. + "no-multiple-empty-lines": [1, {"max": 2}], + + // Allow reassignment of function parameters. + "no-param-reassign": "off", + + // Allow string concatenation with __dirname and __filename (not a node env). + "no-path-concat": "off", + + // Allow use of unary operators, ++ and --. + "no-plusplus": "off", + + // Allow using process.env (not a node environment). + "no-process-env": "off", + + // Allow using process.exit (not a node environment). + "no-process-exit": "off", + + // Disallow usage of __proto__ property. + "no-proto": "error", + + // Allow reserved words being used as object literal keys. + "no-reserved-keys": "off", + + // Don't restrict usage of specified node modules (not a node environment). + "no-restricted-modules": "off", + + // Disallow use of assignment in return statement. It is preferable for a + // single line of code to have only one easily predictable effect. + "no-return-assign": "error", + + // Don't warn about declaration of variables already declared in the outer scope. + "no-shadow": "off", + + // Allow use of synchronous methods (not a node environment). + "no-sync": "off", + + // Allow the use of ternary operators. + "no-ternary": "off", + + // Disallow throwing literals (eg. throw "error" instead of + // throw new Error("error")). + "no-throw-literal": "error", + + // Allow dangling underscores in identifiers (for privates). + "no-underscore-dangle": "off", + + // Allow use of undefined variable. + "no-undefined": "off", + + // Disallow the use of Boolean literals in conditional expressions. + "no-unneeded-ternary": "error", + + // We use var-only-at-top-level instead of no-var as we allow top level + // vars. + "no-var": "off", + + // Allow using TODO/FIXME comments. + "no-warning-comments": "off", + + // Don't require method and property shorthand syntax for object literals. + // We use this in the code a lot, but not consistently, and this seems more + // like something to check at code review time. + "object-shorthand": "off", + + // Allow more than one variable declaration per function. + "one-var": "off", + + // Disallow padding within blocks. + "padded-blocks": ["warn", "never"], + + // Don't require quotes around object literal property names. + "quote-props": "off", + + // Require use of the second argument for parseInt(). + "radix": "error", + + // Enforce spacing after semicolons. + "semi-spacing": ["error", {"before": false, "after": true}], + + // Don't require to sort variables within the same declaration block. + // Anyway, one-var is disabled. + "sort-vars": "off", + + // Require a space immediately following the // in a line comment. + "spaced-comment": ["error", "always"], + + // Require "use strict" to be defined globally in the script. + "strict": ["error", "global"], + + // Allow vars to be declared anywhere in the scope. + "vars-on-top": "off", + + // Don't require immediate function invocation to be wrapped in parentheses. + "wrap-iife": "off", + + // Don't require regex literals to be wrapped in parentheses (which + // supposedly prevent them from being mistaken for division operators). + "wrap-regex": "off", + + // Disallow Yoda conditions (where literal value comes first). + "yoda": "error", + + // Disallow function or variable declarations in nested blocks + "no-inner-declarations": "error", + + // Disallow labels that share a name with a variable + "no-label-var": "error", + + // Disallow creating new instances of String, Number, and Boolean + "no-new-wrappers": "error", + }, +}; diff --git a/toolkit/components/extensions/AddonManagerWebAPI.cpp b/toolkit/components/extensions/AddonManagerWebAPI.cpp new file mode 100644 index 0000000000..61a8b9c843 --- /dev/null +++ b/toolkit/components/extensions/AddonManagerWebAPI.cpp @@ -0,0 +1,137 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#include "mozilla/AddonManagerWebAPI.h" + +#include "js/TypeDecls.h" + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/Navigator.h" +#include "nsContentUtils.h" +#include "nsIDocShell.h" +#include "nsIDocShellTreeItem.h" +#include "nsIScriptObjectPrincipal.h" +#include "nsIPrincipal.h" +#include "nsNetUtil.h" +#include "xpcpublic.h" + +namespace mozilla { + +using namespace mozilla::dom; + +#ifndef MOZ_THUNDERBIRD +# define MOZ_AMO_HOSTNAME "addons.mozilla.org" +# define MOZ_AMO_STAGE_HOSTNAME "addons.allizom.org" +# define MOZ_AMO_DEV_HOSTNAME "addons-dev.allizom.org" +#else +# define MOZ_AMO_HOSTNAME "addons.thunderbird.net" +# define MOZ_AMO_STAGE_HOSTNAME "addons-stage.thunderbird.net" +# undef MOZ_AMO_DEV_HOSTNAME +#endif + +static bool IsValidHost(const nsACString& host) +{ + if (host.EqualsLiteral(MOZ_AMO_HOSTNAME)) { + return true; + } + + if (xpc::IsInAutomation()) { + if (host.LowerCaseEqualsLiteral(MOZ_AMO_STAGE_HOSTNAME) || +#ifdef MOZ_AMO_DEV_HOSTNAME + host.LowerCaseEqualsLiteral(MOZ_AMO_DEV_HOSTNAME) || +#endif + host.LowerCaseEqualsLiteral("example.com")) { + return true; + } + } + + return false; +} + +bool +AddonManagerWebAPI::IsValidSite(nsIURI* uri) +{ + if (!uri) { + return false; + } + + bool isHttps = false; + if (NS_FAILED(uri->SchemeIs("https", &isHttps)) || !isHttps) { + bool isHttp = false; + if (!(xpc::IsInAutomation() && + NS_SUCCEEDED(uri->SchemeIs("http", &isHttp)) && isHttp)) { + return false; + } + } + + nsAutoCString host; + if (NS_FAILED(uri->GetHost(host))) { + return false; + } + + return IsValidHost(host); +} + +bool +AddonManagerWebAPI::IsAPIEnabled(JSContext* aCx, JSObject* aGlobal) +{ + MOZ_DIAGNOSTIC_ASSERT(JS_IsGlobalObject(aGlobal)); + + nsCOMPtr win = Navigator::GetWindowFromGlobal(aGlobal); + if (!win) { + return false; + } + + while (win) { + nsCOMPtr sop = do_QueryInterface(win); + if (!sop) { + return false; + } + + nsCOMPtr principal = sop->GetPrincipal(); + if (!principal) { + return false; + } + + if (nsContentUtils::IsSystemPrincipal(principal)) { + return true; + } + + if (!IsValidSite(win->GetDocumentURI())) { + return false; + } + + nsCOMPtr docShell = win->GetDocShell(); + if (!docShell) { + return false; + } + + nsCOMPtr parent; + if (NS_FAILED(docShell->GetSameTypeParent(getter_AddRefs(parent)))) { + return false; + } + + if (!parent) { + return true; + } + + return false; + } + + return false; +} + +namespace dom { + +bool +AddonManagerPermissions::IsHostPermitted(const GlobalObject&, const nsAString& host) +{ + return IsValidHost(NS_ConvertUTF16toUTF8(host)); +} + +} // namespace dom + +} // namespace mozilla \ No newline at end of file diff --git a/toolkit/components/extensions/AddonManagerWebAPI.h b/toolkit/components/extensions/AddonManagerWebAPI.h new file mode 100644 index 0000000000..4ebdd55501 --- /dev/null +++ b/toolkit/components/extensions/AddonManagerWebAPI.h @@ -0,0 +1,37 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef addonmanagerwebapi_h_ +#define addonmanagerwebapi_h_ + +#include "mozilla/dom/BindingDeclarations.h" +#include "nsPIDOMWindow.h" + +class nsIURI; +struct JSContext; +class JSObject; + +namespace mozilla { + +class AddonManagerWebAPI { + public: + static bool IsAPIEnabled(JSContext* aCx, JSObject* aGlobal); + + static bool IsValidSite(nsIURI* uri); +}; + +namespace dom { + +class AddonManagerPermissions { + public: + static bool IsHostPermitted(const GlobalObject&, const nsAString& host); +}; + +} // namespace dom + +} // namespace mozilla + +#endif // addonmanagerwebapi_h_ \ No newline at end of file diff --git a/toolkit/components/extensions/Extension.jsm b/toolkit/components/extensions/Extension.jsm new file mode 100644 index 0000000000..abd64db8e0 --- /dev/null +++ b/toolkit/components/extensions/Extension.jsm @@ -0,0 +1,1198 @@ +/* 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/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["Extension", "ExtensionData"]; + +/* exported Extension, ExtensionData */ +/* globals Extension ExtensionData */ + +/* + * This file is the main entry point for extensions. When an extension + * loads, its bootstrap.js file creates a Extension instance + * and calls .startup() on it. It calls .shutdown() when the extension + * unloads. Extension manages any extension-specific state in + * the chrome process. + * + * TODO(rpl): we are current restricting the extensions to a single process + * (set as the current default value of the "dom.ipc.processCount.extension" + * preference), if we switch to use more than one extension process, we have to + * be sure that all the browser's frameLoader are associated to the same process, + * e.g. by using the `sameProcessAsFrameLoader` property. + * (http://searchfox.org/mozilla-central/source/dom/interfaces/base/nsIBrowser.idl) + * + * At that point we are going to keep track of the existing browsers associated to + * a webextension to ensure that they are all running in the same process (and we + * are also going to do the same with the browser element provided to the + * addon debugging Remote Debugging actor, e.g. because the addon has been + * reloaded by the user, we have to ensure that the new extension pages are going + * to run in the same process of the existing addon debugging browser element). + */ + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.importGlobalProperties(["TextEncoder"]); + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +/* globals processCount */ + +XPCOMUtils.defineLazyPreferenceGetter(this, "processCount", "dom.ipc.processCount.extension"); + +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionAPIs", + "resource://gre/modules/ExtensionAPI.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionCommon", + "resource://gre/modules/ExtensionCommon.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionPermissions", + "resource://gre/modules/ExtensionPermissions.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorage", + "resource://gre/modules/ExtensionStorage.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionTestCommon", + "resource://testing-common/ExtensionTestCommon.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Locale", + "resource://gre/modules/Locale.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Log", + "resource://gre/modules/Log.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel", + "resource://gre/modules/MessageChannel.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Preferences", + "resource://gre/modules/Preferences.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "require", + "resource://devtools/shared/Loader.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Schemas", + "resource://gre/modules/Schemas.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TelemetryStopwatch", + "resource://gre/modules/TelemetryStopwatch.jsm"); + +XPCOMUtils.defineLazyGetter( + this, "processScript", + () => Cc["@mozilla.org/webextensions/extension-process-script;1"] + .getService().wrappedJSObject); + +Cu.import("resource://gre/modules/ExtensionParent.jsm"); +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "uuidGen", + "@mozilla.org/uuid-generator;1", + "nsIUUIDGenerator"); + +XPCOMUtils.defineLazyPreferenceGetter(this, "useRemoteWebExtensions", + "extensions.webextensions.remote", false); + +var { + GlobalManager, + ParentAPIManager, + StartupCache, + apiManager: Management, +} = ExtensionParent; + +const { + EventEmitter, + getUniqueId, +} = ExtensionUtils; + +XPCOMUtils.defineLazyGetter(this, "console", ExtensionUtils.getConsole); + +XPCOMUtils.defineLazyGetter(this, "LocaleData", () => ExtensionCommon.LocaleData); + + +// The list of properties that themes are allowed to contain. +XPCOMUtils.defineLazyGetter(this, "allowedThemeProperties", () => { + Cu.import("resource://gre/modules/ExtensionParent.jsm"); + let propertiesInBaseManifest = ExtensionParent.baseManifestProperties; + + // The properties found in the base manifest contain all of the properties that + // themes are allowed to have. However, the list also contains several properties + // that aren't allowed, so we need to filter them out first before the list can + // be used to validate themes. + return propertiesInBaseManifest.filter(prop => { + const propertiesToRemove = ["background", "content_scripts", "permissions"]; + return !propertiesToRemove.includes(prop); + }); +}); + +/** + * Validates a theme to ensure it only contains static resources. + * + * @param {Array} manifestProperties The list of top-level keys found in the + * the extension's manifest. + * @returns {Array} A list of invalid properties or an empty list + * if none are found. + */ +function validateThemeManifest(manifestProperties) { + let invalidProps = []; + for (let propName of manifestProperties) { + if (propName != "theme" && !allowedThemeProperties.includes(propName)) { + invalidProps.push(propName); + } + } + return invalidProps; +} + +/** + * Classify an individual permission from a webextension manifest + * as a host/origin permission, an api permission, or a regular permission. + * + * @param {string} perm The permission string to classify + * + * @returns {object} + * An object with exactly one of the following properties: + * "origin" to indicate this is a host/origin permission. + * "api" to indicate this is an api permission + * (as used for webextensions experiments). + * "permission" to indicate this is a regular permission. + */ +function classifyPermission(perm) { + let match = /^(\w+)(?:\.(\w+)(?:\.\w+)*)?$/.exec(perm); + if (!match) { + return {origin: perm}; + } else if (match[1] == "experiments" && match[2]) { + return {api: match[2]}; + } + return {permission: perm}; +} + +const LOGGER_ID_BASE = "addons.webextension."; +const UUID_MAP_PREF = "extensions.webextensions.uuids"; +const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall"; +const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall"; + +const COMMENT_REGEXP = new RegExp(String.raw` + ^ + ( + (?: + [^"\n] | + " (?:[^"\\\n] | \\.)* " + )*? + ) + + //.* + `.replace(/\s+/g, ""), "gm"); + +// All moz-extension URIs use a machine-specific UUID rather than the +// extension's own ID in the host component. This makes it more +// difficult for web pages to detect whether a user has a given add-on +// installed (by trying to load a moz-extension URI referring to a +// web_accessible_resource from the extension). UUIDMap.get() +// returns the UUID for a given add-on ID. +var UUIDMap = { + _read() { + let pref = Preferences.get(UUID_MAP_PREF, "{}"); + try { + return JSON.parse(pref); + } catch (e) { + Cu.reportError(`Error parsing ${UUID_MAP_PREF}.`); + return {}; + } + }, + + _write(map) { + Preferences.set(UUID_MAP_PREF, JSON.stringify(map)); + }, + + get(id, create = true) { + let map = this._read(); + + if (id in map) { + return map[id]; + } + + let uuid = null; + if (create) { + uuid = uuidGen.generateUUID().number; + uuid = uuid.slice(1, -1); // Strip { and } off the UUID. + + map[id] = uuid; + this._write(map); + } + return uuid; + }, + + remove(id) { + let map = this._read(); + delete map[id]; + this._write(map); + }, +}; + +// This is the old interface that UUIDMap replaced, to be removed when +// the references listed in bug 1291399 are updated. +/* exported getExtensionUUID */ +function getExtensionUUID(id) { + return UUIDMap.get(id, true); +} + +// For extensions that have called setUninstallURL(), send an event +// so the browser can display the URL. +var UninstallObserver = { + initialized: false, + + init() { + if (!this.initialized) { + AddonManager.addAddonListener(this); + XPCOMUtils.defineLazyPreferenceGetter(this, "leaveStorage", LEAVE_STORAGE_PREF, false); + XPCOMUtils.defineLazyPreferenceGetter(this, "leaveUuid", LEAVE_UUID_PREF, false); + this.initialized = true; + } + }, + + onUninstalling(addon) { + let extension = GlobalManager.extensionMap.get(addon.id); + if (extension) { + // Let any other interested listeners respond + // (e.g., display the uninstall URL) + Management.emit("uninstall", extension); + } + }, + + onUninstalled(addon) { + let uuid = UUIDMap.get(addon.id, false); + if (!uuid) { + return; + } + + if (!this.leaveStorage) { + // Clear browser.local.storage + ExtensionStorage.clear(addon.id); + + // Clear any IndexedDB storage created by the extension + let baseURI = NetUtil.newURI(`moz-extension://${uuid}/`); + let principal = Services.scriptSecurityManager.createCodebasePrincipal( + baseURI, {}); + Services.qms.clearStoragesForPrincipal(principal); + + // Clear localStorage created by the extension + let storage = Services.domStorageManager.getStorage(null, principal); + if (storage) { + storage.clear(); + } + } + + if (!this.leaveUuid) { + // Clear the entry in the UUID map + UUIDMap.remove(addon.id); + } + }, +}; + +UninstallObserver.init(); + +// Represents the data contained in an extension, contained either +// in a directory or a zip file, which may or may not be installed. +// This class implements the functionality of the Extension class, +// primarily related to manifest parsing and localization, which is +// useful prior to extension installation or initialization. +// +// No functionality of this class is guaranteed to work before +// |loadManifest| has been called, and completed. +this.ExtensionData = class { + constructor(rootURI) { + this.rootURI = rootURI; + + this.manifest = null; + this.id = null; + this.uuid = null; + this.localeData = null; + this._promiseLocales = null; + + this.apiNames = new Set(); + this.dependencies = new Set(); + this.permissions = new Set(); + + this.errors = []; + } + + get builtinMessages() { + return null; + } + + get logger() { + let id = this.id || ""; + return Log.repository.getLogger(LOGGER_ID_BASE + id); + } + + // Report an error about the extension's manifest file. + manifestError(message) { + this.packagingError(`Reading manifest: ${message}`); + } + + // Report an error about the extension's general packaging. + packagingError(message) { + this.errors.push(message); + this.logger.error(`Loading extension '${this.id}': ${message}`); + } + + /** + * Returns the moz-extension: URL for the given path within this + * extension. + * + * Must not be called unless either the `id` or `uuid` property has + * already been set. + * + * @param {string} path The path portion of the URL. + * @returns {string} + */ + getURL(path = "") { + if (!(this.id || this.uuid)) { + throw new Error("getURL may not be called before an `id` or `uuid` has been set"); + } + if (!this.uuid) { + this.uuid = UUIDMap.get(this.id); + } + return `moz-extension://${this.uuid}/${path}`; + } + + async readDirectory(path) { + if (this.rootURI instanceof Ci.nsIFileURL) { + let uri = NetUtil.newURI(this.rootURI.resolve("./" + path)); + let fullPath = uri.QueryInterface(Ci.nsIFileURL).file.path; + + let iter = new OS.File.DirectoryIterator(fullPath); + let results = []; + + try { + await iter.forEach(entry => { + results.push(entry); + }); + } catch (e) { + // Always return a list, even if the directory does not exist (or is + // not a directory) for symmetry with the ZipReader behavior. + } + iter.close(); + + return results; + } + + // FIXME: We need a way to do this without main thread IO. + + let uri = this.rootURI.QueryInterface(Ci.nsIJARURI); + + let file = uri.JARFile.QueryInterface(Ci.nsIFileURL).file; + let zipReader = Cc["@mozilla.org/libjar/zip-reader;1"].createInstance(Ci.nsIZipReader); + zipReader.open(file); + try { + let results = []; + + // Normalize the directory path. + path = `${uri.JAREntry}/${path}`; + path = path.replace(/\/\/+/g, "/").replace(/^\/|\/$/g, "") + "/"; + + // Escape pattern metacharacters. + let pattern = path.replace(/[[\]()?*~|$\\]/g, "\\$&"); + + let enumerator = zipReader.findEntries(pattern + "*"); + while (enumerator.hasMore()) { + let name = enumerator.getNext(); + if (!name.startsWith(path)) { + throw new Error("Unexpected ZipReader entry"); + } + + // The enumerator returns the full path of all entries. + // Trim off the leading path, and filter out entries from + // subdirectories. + name = name.slice(path.length); + if (name && !/\/./.test(name)) { + results.push({ + name: name.replace("/", ""), + isDir: name.endsWith("/"), + }); + } + } + + return results; + } finally { + zipReader.close(); + } + } + + readJSON(path) { + return new Promise((resolve, reject) => { + let uri = this.rootURI.resolve(`./${path}`); + + NetUtil.asyncFetch({uri, loadUsingSystemPrincipal: true}, (inputStream, status) => { + if (!Components.isSuccessCode(status)) { + // Convert status code to a string + let e = Components.Exception("", status); + reject(new Error(`Error while loading '${uri}' (${e.name})`)); + return; + } + try { + let text = NetUtil.readInputStreamToString(inputStream, inputStream.available(), + {charset: "utf-8"}); + + text = text.replace(COMMENT_REGEXP, "$1"); + + resolve(JSON.parse(text)); + } catch (e) { + reject(e); + } + }); + }); + } + + // This method should return a structured representation of any + // capabilities this extension has access to, as derived from the + // manifest. The current implementation just returns the contents + // of the permissions attribute, if we add things like url_overrides, + // they should also be added here. + get userPermissions() { + let result = { + origins: this.whiteListedHosts.patterns.map(matcher => matcher.pattern), + apis: [...this.apiNames], + }; + + if (Array.isArray(this.manifest.content_scripts)) { + for (let entry of this.manifest.content_scripts) { + result.origins.push(...entry.matches); + } + } + const EXP_PATTERN = /^experiments\.\w+/; + result.permissions = [...this.permissions] + .filter(p => !result.origins.includes(p) && !EXP_PATTERN.test(p)); + return result; + } + + // Compute the difference between two sets of permissions, suitable + // for presenting to the user. + static comparePermissions(oldPermissions, newPermissions) { + // See bug 1331769: should we do something more complicated to + // compare host permissions? + // e.g., if we go from to a specific host or from + // a *.domain.com to specific-host.domain.com that's actually a + // drop in permissions but the simple test below will cause a prompt. + return { + origins: newPermissions.origins.filter(perm => !oldPermissions.origins.includes(perm)), + permissions: newPermissions.permissions.filter(perm => !oldPermissions.permissions.includes(perm)), + }; + } + + parseManifest() { + return Promise.all([ + this.readJSON("manifest.json"), + Management.lazyInit(), + ]).then(([manifest]) => { + this.manifest = manifest; + this.rawManifest = manifest; + + if (manifest && manifest.default_locale) { + return this.initLocale(); + } + }).then(() => { + let context = { + url: this.baseURI && this.baseURI.spec, + + principal: this.principal, + + logError: error => { + this.logger.warn(`Loading extension '${this.id}': Reading manifest: ${error}`); + }, + + preprocessors: {}, + }; + + if (this.manifest.theme) { + let invalidProps = validateThemeManifest(Object.getOwnPropertyNames(this.manifest)); + + if (invalidProps.length) { + let message = `Themes defined in the manifest may only contain static resources. ` + + `If you would like to use additional properties, please use the "theme" permission instead. ` + + `(the invalid properties found are: ${invalidProps})`; + this.manifestError(message); + } + } + + if (this.localeData) { + context.preprocessors.localize = (value, context) => this.localize(value); + } + + let normalized = Schemas.normalize(this.manifest, "manifest.WebExtensionManifest", context); + if (normalized.error) { + this.manifestError(normalized.error); + } else { + return normalized.value; + } + }); + } + + // Reads the extension's |manifest.json| file, and stores its + // parsed contents in |this.manifest|. + async loadManifest() { + [this.manifest] = await Promise.all([ + this.parseManifest(), + Management.lazyInit(), + ]); + + if (!this.manifest) { + return; + } + + try { + // Do not override the add-on id that has been already assigned. + if (!this.id && this.manifest.applications.gecko.id) { + this.id = this.manifest.applications.gecko.id; + } + } catch (e) { + // Errors are handled by the type checks above. + } + + let whitelist = []; + for (let perm of this.manifest.permissions) { + if (perm === "geckoProfiler") { + const acceptedExtensions = Preferences.get("extensions.geckoProfiler.acceptedExtensionIds"); + if (!acceptedExtensions.split(",").includes(this.id)) { + this.manifestError("Only whitelisted extensions are allowed to access the geckoProfiler."); + continue; + } + } + + let type = classifyPermission(perm); + if (type.origin) { + let matcher = new MatchPattern(perm, {ignorePath: true}); + + whitelist.push(matcher); + perm = matcher.pattern; + } else if (type.api) { + this.apiNames.add(type.api); + } + + this.permissions.add(perm); + } + this.whiteListedHosts = new MatchPatternSet(whitelist); + + for (let api of this.apiNames) { + this.dependencies.add(`${api}@experiments.addons.mozilla.org`); + } + + return this.manifest; + } + + localizeMessage(...args) { + return this.localeData.localizeMessage(...args); + } + + localize(...args) { + return this.localeData.localize(...args); + } + + // If a "default_locale" is specified in that manifest, returns it + // as a Gecko-compatible locale string. Otherwise, returns null. + get defaultLocale() { + if (this.manifest.default_locale != null) { + return this.normalizeLocaleCode(this.manifest.default_locale); + } + + return null; + } + + // Normalizes a Chrome-compatible locale code to the appropriate + // Gecko-compatible variant. Currently, this means simply + // replacing underscores with hyphens. + normalizeLocaleCode(locale) { + return locale.replace(/_/g, "-"); + } + + // Reads the locale file for the given Gecko-compatible locale code, and + // stores its parsed contents in |this.localeMessages.get(locale)|. + async readLocaleFile(locale) { + let locales = await this.promiseLocales(); + let dir = locales.get(locale) || locale; + let file = `_locales/${dir}/messages.json`; + + try { + let messages = await this.readJSON(file); + return this.localeData.addLocale(locale, messages, this); + } catch (e) { + this.packagingError(`Loading locale file ${file}: ${e}`); + return new Map(); + } + } + + // Reads the list of locales available in the extension, and returns a + // Promise which resolves to a Map upon completion. + // Each map key is a Gecko-compatible locale code, and each value is the + // "_locales" subdirectory containing that locale: + // + // Map(gecko-locale-code -> locale-directory-name) + promiseLocales() { + if (!this._promiseLocales) { + this._promiseLocales = (async () => { + let locales = new Map(); + + let entries = await this.readDirectory("_locales"); + for (let file of entries) { + if (file.isDir) { + let locale = this.normalizeLocaleCode(file.name); + locales.set(locale, file.name); + } + } + + this.localeData = new LocaleData({ + defaultLocale: this.defaultLocale, + locales, + builtinMessages: this.builtinMessages, + }); + + return locales; + })(); + } + + return this._promiseLocales; + } + + // Reads the locale messages for all locales, and returns a promise which + // resolves to a Map of locale messages upon completion. Each key in the map + // is a Gecko-compatible locale code, and each value is a locale data object + // as returned by |readLocaleFile|. + async initAllLocales() { + let locales = await this.promiseLocales(); + + await Promise.all(Array.from(locales.keys(), + locale => this.readLocaleFile(locale))); + + let defaultLocale = this.defaultLocale; + if (defaultLocale) { + if (!locales.has(defaultLocale)) { + this.manifestError('Value for "default_locale" property must correspond to ' + + 'a directory in "_locales/". Not found: ' + + JSON.stringify(`_locales/${this.manifest.default_locale}/`)); + } + } else if (locales.size) { + this.manifestError('The "default_locale" property is required when a ' + + '"_locales/" directory is present.'); + } + + return this.localeData.messages; + } + + // Reads the locale file for the given Gecko-compatible locale code, or the + // default locale if no locale code is given, and sets it as the currently + // selected locale on success. + // + // Pre-loads the default locale for fallback message processing, regardless + // of the locale specified. + // + // If no locales are unavailable, resolves to |null|. + async initLocale(locale = this.defaultLocale) { + if (locale == null) { + return null; + } + + let promises = [this.readLocaleFile(locale)]; + + let {defaultLocale} = this; + if (locale != defaultLocale && !this.localeData.has(defaultLocale)) { + promises.push(this.readLocaleFile(defaultLocale)); + } + + let results = await Promise.all(promises); + + this.localeData.selectedLocale = locale; + return results[0]; + } +}; + +const PROXIED_EVENTS = new Set(["test-harness-message", "add-permissions", "remove-permissions"]); + +// We create one instance of this class per extension. |addonData| +// comes directly from bootstrap.js when initializing. +this.Extension = class extends ExtensionData { + constructor(addonData, startupReason) { + super(addonData.resourceURI); + + this.uuid = UUIDMap.get(addonData.id); + this.instanceId = getUniqueId(); + + this.MESSAGE_EMIT_EVENT = `Extension:EmitEvent:${this.instanceId}`; + Services.ppmm.addMessageListener(this.MESSAGE_EMIT_EVENT, this); + + if (addonData.cleanupFile) { + Services.obs.addObserver(this, "xpcom-shutdown"); + this.cleanupFile = addonData.cleanupFile || null; + delete addonData.cleanupFile; + } + + this.addonData = addonData; + this.startupReason = startupReason; + + if (["ADDON_UPGRADE", "ADDON_DOWNGRADE"].includes(startupReason)) { + StartupCache.clearAddonData(addonData.id); + } + + this.remote = useRemoteWebExtensions; + + if (this.remote && processCount !== 1) { + throw new Error("Out-of-process WebExtensions are not supported with multiple child processes"); + } + if (this.remote && !Services.prefs.getBoolPref("layers.popups.compositing.enabled", false)) { + Cu.reportError(new Error("Remote extensions should not be enabled without also setting " + + "the layers.popups.compositing.enabled preference to true")); + } + + // This is filled in the first time an extension child is created. + this.parentMessageManager = null; + + this.id = addonData.id; + this.version = addonData.version; + this.baseURI = NetUtil.newURI(this.getURL("")).QueryInterface(Ci.nsIURL); + this.principal = this.createPrincipal(); + + this.onStartup = null; + + this.hasShutdown = false; + this.onShutdown = new Set(); + + this.uninstallURL = null; + + this.apis = []; + this.whiteListedHosts = null; + this._optionalOrigins = null; + this.webAccessibleResources = null; + + this.emitter = new EventEmitter(); + + /* eslint-disable mozilla/balanced-listeners */ + this.on("add-permissions", (ignoreEvent, permissions) => { + for (let perm of permissions.permissions) { + this.permissions.add(perm); + } + + if (permissions.origins.length > 0) { + let patterns = this.whiteListedHosts.patterns.map(host => host.pattern); + + this.whiteListedHosts = new MatchPatternSet([...patterns, ...permissions.origins], + {ignorePath: true}); + } + + this.policy.permissions = Array.from(this.permissions); + this.policy.allowedOrigins = this.whiteListedHosts; + }); + + this.on("remove-permissions", (ignoreEvent, permissions) => { + for (let perm of permissions.permissions) { + this.permissions.delete(perm); + } + + let origins = permissions.origins.map( + origin => new MatchPattern(origin, {ignorePath: true}).pattern); + + this.whiteListedHosts = new MatchPatternSet( + this.whiteListedHosts.patterns + .filter(host => !origins.includes(host.pattern))); + + this.policy.permissions = Array.from(this.permissions); + this.policy.allowedOrigins = this.whiteListedHosts; + }); + /* eslint-enable mozilla/balanced-listeners */ + } + + static generateXPI(data) { + return ExtensionTestCommon.generateXPI(data); + } + + static generateZipFile(files, baseName = "generated-extension.xpi") { + return ExtensionTestCommon.generateZipFile(files, baseName); + } + + static generate(data) { + return ExtensionTestCommon.generate(data); + } + + on(hook, f) { + return this.emitter.on(hook, f); + } + + off(hook, f) { + return this.emitter.off(hook, f); + } + + once(hook, f) { + return this.emitter.once(hook, f); + } + + emit(event, ...args) { + if (PROXIED_EVENTS.has(event)) { + Services.ppmm.broadcastAsyncMessage(this.MESSAGE_EMIT_EVENT, {event, args}); + } + + return this.emitter.emit(event, ...args); + } + + receiveMessage({name, data}) { + if (name === this.MESSAGE_EMIT_EVENT) { + this.emitter.emit(data.event, ...data.args); + } + } + + testMessage(...args) { + this.emit("test-harness-message", ...args); + } + + createPrincipal(uri = this.baseURI) { + return Services.scriptSecurityManager.createCodebasePrincipal(uri, {}); + } + + // Checks that the given URL is a child of our baseURI. + isExtensionURL(url) { + let uri = Services.io.newURI(url); + + let common = this.baseURI.getCommonBaseSpec(uri); + return common == this.baseURI.spec; + } + + readLocaleFile(locale) { + return StartupCache.locales.get([this.id, this.version, locale], + () => super.readLocaleFile(locale)) + .then(result => { + this.localeData.messages.set(locale, result); + }); + } + + parseManifest() { + return StartupCache.manifests.get([this.id, this.version, Locale.getLocale()], + () => super.parseManifest()); + } + + loadManifest() { + return super.loadManifest().then(manifest => { + if (this.errors.length) { + return Promise.reject({errors: this.errors}); + } + + // Load Experiments APIs that this extension depends on. + return Promise.all( + Array.from(this.apiNames, api => ExtensionAPIs.load(api)) + ).then(apis => { + for (let API of apis) { + this.apis.push(new API(this)); + } + + return manifest; + }); + }); + } + + // Representation of the extension to send to content + // processes. This should include anything the content process might + // need. + serialize() { + return { + id: this.id, + uuid: this.uuid, + instanceId: this.instanceId, + manifest: this.manifest, + resourceURL: this.addonData.resourceURI.spec, + baseURL: this.baseURI.spec, + content_scripts: this.manifest.content_scripts || [], // eslint-disable-line camelcase + webAccessibleResources: this.webAccessibleResources.map(res => res.glob), + whiteListedHosts: this.whiteListedHosts.patterns.map(pat => pat.pattern), + localeData: this.localeData.serialize(), + permissions: this.permissions, + principal: this.principal, + optionalPermissions: this.manifest.optional_permissions, + }; + } + + broadcast(msg, data) { + return new Promise(resolve => { + let {ppmm} = Services; + let children = new Set(); + for (let i = 0; i < ppmm.childCount; i++) { + children.add(ppmm.getChildAt(i)); + } + + let maybeResolve; + function listener(data) { + children.delete(data.target); + maybeResolve(); + } + function observer(subject, topic, data) { + children.delete(subject); + maybeResolve(); + } + + maybeResolve = () => { + if (children.size === 0) { + ppmm.removeMessageListener(msg + "Complete", listener); + Services.obs.removeObserver(observer, "message-manager-close"); + Services.obs.removeObserver(observer, "message-manager-disconnect"); + resolve(); + } + }; + ppmm.addMessageListener(msg + "Complete", listener); + Services.obs.addObserver(observer, "message-manager-close"); + Services.obs.addObserver(observer, "message-manager-disconnect"); + + ppmm.broadcastAsyncMessage(msg, data); + }); + } + + runManifest(manifest) { + let promises = []; + for (let directive in manifest) { + if (manifest[directive] !== null) { + promises.push(Management.emit(`manifest_${directive}`, directive, this, manifest)); + + promises.push(Management.asyncEmitManifestEntry(this, directive)); + } + } + + let data = Services.ppmm.initialProcessData; + if (!data["Extension:Extensions"]) { + data["Extension:Extensions"] = []; + } + let serial = this.serialize(); + data["Extension:Extensions"].push(serial); + + return this.broadcast("Extension:Startup", serial).then(() => { + return Promise.all(promises); + }); + } + + callOnClose(obj) { + this.onShutdown.add(obj); + } + + forgetOnClose(obj) { + this.onShutdown.delete(obj); + } + + get builtinMessages() { + return new Map([ + ["@@extension_id", this.uuid], + ]); + } + + // Reads the locale file for the given Gecko-compatible locale code, or if + // no locale is given, the available locale closest to the UI locale. + // Sets the currently selected locale on success. + async initLocale(locale = undefined) { + if (locale === undefined) { + let locales = await this.promiseLocales(); + + let localeList = Array.from(locales.keys(), locale => { + return {name: locale, locales: [locale]}; + }); + + let match = Locale.findClosestLocale(localeList); + locale = match ? match.name : this.defaultLocale; + } + + return super.initLocale(locale); + } + + startup() { + this.startupPromise = this._startup(); + return this.startupPromise; + } + + async _startup() { + // Create a temporary policy object for the devtools and add-on + // manager callers that depend on it being available early. + this.policy = new WebExtensionPolicy({ + id: this.id, + mozExtensionHostname: this.uuid, + baseURL: this.baseURI.spec, + allowedOrigins: new MatchPatternSet([]), + localizeCallback() {}, + }); + if (!WebExtensionPolicy.getByID(this.id)) { + // The add-on manager doesn't handle async startup and shutdown, + // so during upgrades and add-on restarts, startup() gets called + // before the last shutdown has completed, and this fails when + // there's another active add-on with the same ID. + this.policy.active = true; + } + + TelemetryStopwatch.start("WEBEXT_EXTENSION_STARTUP_MS", this); + try { + let [, perms] = await Promise.all([this.loadManifest(), ExtensionPermissions.get(this)]); + + if (!this.hasShutdown) { + await this.initLocale(); + } + + if (this.errors.length) { + return Promise.reject({errors: this.errors}); + } + + if (this.hasShutdown) { + return; + } + + GlobalManager.init(this); + + // Apply optional permissions + for (let perm of perms.permissions) { + this.permissions.add(perm); + } + if (perms.origins.length > 0) { + let patterns = this.whiteListedHosts.patterns.map(host => host.pattern); + + this.whiteListedHosts = new MatchPatternSet([...patterns, ...perms.origins], + {ignorePath: true}); + } + + // Normalize all patterns to contain a single leading / + let resources = (this.manifest.web_accessible_resources || []) + .map(path => path.replace(/^\/*/, "/")); + + this.webAccessibleResources = resources.map(res => new MatchGlob(res)); + + + this.policy.active = false; + this.policy = processScript.initExtension(this.serialize(), this); + + // The "startup" Management event sent on the extension instance itself + // is emitted just before the Management "startup" event, + // and it is used to run code that needs to be executed before + // any of the "startup" listeners. + this.emit("startup", this); + Management.emit("startup", this); + + await this.runManifest(this.manifest); + + Management.emit("ready", this); + this.emit("ready"); + TelemetryStopwatch.finish("WEBEXT_EXTENSION_STARTUP_MS", this); + } catch (e) { + dump(`Extension error: ${e.message} ${e.filename || e.fileName}:${e.lineNumber} :: ${e.stack || new Error().stack}\n`); + Cu.reportError(e); + + if (this.policy) { + this.policy.active = false; + } + + this.cleanupGeneratedFile(); + + throw e; + } + + this.startupPromise = null; + } + + cleanupGeneratedFile() { + if (!this.cleanupFile) { + return; + } + + let file = this.cleanupFile; + this.cleanupFile = null; + + Services.obs.removeObserver(this, "xpcom-shutdown"); + + return this.broadcast("Extension:FlushJarCache", {path: file.path}).then(() => { + // We can't delete this file until everyone using it has + // closed it (because Windows is dumb). So we wait for all the + // child processes (including the parent) to flush their JAR + // caches. These caches may keep the file open. + file.remove(false); + }).catch(Cu.reportError); + } + + async shutdown(reason) { + try { + if (this.startupPromise) { + await this.startupPromise; + } + } catch (e) { + Cu.reportError(e); + } + + this.shutdownReason = reason; + this.hasShutdown = true; + + if (!this.policy) { + return; + } + + if (this.cleanupFile || + ["ADDON_INSTALL", "ADDON_UNINSTALL", "ADDON_UPGRADE", "ADDON_DOWNGRADE"].includes(reason)) { + StartupCache.clearAddonData(this.id); + } + + let data = Services.ppmm.initialProcessData; + data["Extension:Extensions"] = data["Extension:Extensions"].filter(e => e.id !== this.id); + + Services.ppmm.removeMessageListener(this.MESSAGE_EMIT_EVENT, this); + + if (!this.manifest) { + this.policy.active = false; + + return this.cleanupGeneratedFile(); + } + + GlobalManager.uninit(this); + + for (let obj of this.onShutdown) { + obj.close(); + } + + for (let api of this.apis) { + api.destroy(); + } + + ParentAPIManager.shutdownExtension(this.id); + + Management.emit("shutdown", this); + this.emit("shutdown"); + + await this.broadcast("Extension:Shutdown", {id: this.id}); + + MessageChannel.abortResponses({extensionId: this.id}); + + this.policy.active = false; + + return this.cleanupGeneratedFile(); + } + + observe(subject, topic, data) { + if (topic === "xpcom-shutdown") { + this.cleanupGeneratedFile(); + } + } + + hasPermission(perm, includeOptional = false) { + let manifest_ = "manifest:"; + if (perm.startsWith(manifest_)) { + return this.manifest[perm.substr(manifest_.length)] != null; + } + + if (this.permissions.has(perm)) { + return true; + } + + if (includeOptional && this.manifest.optional_permissions.includes(perm)) { + return true; + } + + return false; + } + + get name() { + return this.manifest.name; + } + + get optionalOrigins() { + if (this._optionalOrigins == null) { + let origins = this.manifest.optional_permissions.filter(perm => classifyPermission(perm).origin); + this._optionalOrigins = new MatchPatternSet(origins, {ignorePath: true}); + } + return this._optionalOrigins; + } +}; diff --git a/toolkit/components/extensions/ExtensionAPI.jsm b/toolkit/components/extensions/ExtensionAPI.jsm new file mode 100644 index 0000000000..d246bd7a3b --- /dev/null +++ b/toolkit/components/extensions/ExtensionAPI.jsm @@ -0,0 +1,113 @@ +/* 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/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["ExtensionAPI", "ExtensionAPIs"]; + +/* exported ExtensionAPIs */ + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ConsoleAPI", + "resource://gre/modules/Console.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter", + "resource://gre/modules/EventEmitter.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Schemas", + "resource://gre/modules/Schemas.jsm"); + +const global = this; + +class ExtensionAPI { + constructor(extension) { + this.extension = extension; + + extension.once("shutdown", () => { + if (this.onShutdown) { + this.onShutdown(extension.shutdownReason); + } + this.extension = null; + }); + } + + destroy() { + } + + onManifestEntry(entry) { + } + + getAPI(context) { + throw new Error("Not Implemented"); + } +} + +var ExtensionAPIs = { + apis: new Map(), + + load(apiName) { + let api = this.apis.get(apiName); + + if (api.loadPromise) { + return api.loadPromise; + } + + let {script, schema} = api; + + let addonId = `${apiName}@experiments.addons.mozilla.org`; + api.sandbox = Cu.Sandbox(global, { + wantXrays: false, + sandboxName: script, + addonId, + metadata: {addonID: addonId}, + }); + + api.sandbox.ExtensionAPI = ExtensionAPI; + + // Create a console getter which lazily provide a ConsoleAPI instance. + XPCOMUtils.defineLazyGetter(api.sandbox, "console", () => { + return new ConsoleAPI({prefix: addonId}); + }); + + Services.scriptloader.loadSubScript(script, api.sandbox, "UTF-8"); + + api.loadPromise = Schemas.load(schema).then(() => { + let API = Cu.evalInSandbox("API", api.sandbox); + API.prototype.namespace = apiName; + return API; + }); + + return api.loadPromise; + }, + + unload(apiName) { + let api = this.apis.get(apiName); + + let {schema} = api; + + Schemas.unload(schema); + Cu.nukeSandbox(api.sandbox); + + api.sandbox = null; + api.loadPromise = null; + }, + + register(namespace, schema, script) { + if (this.apis.has(namespace)) { + throw new Error(`API namespace already exists: ${namespace}`); + } + + this.apis.set(namespace, {schema, script}); + }, + + unregister(namespace) { + if (!this.apis.has(namespace)) { + throw new Error(`API namespace does not exist: ${namespace}`); + } + + this.apis.delete(namespace); + }, +}; diff --git a/toolkit/components/extensions/ExtensionChild.jsm b/toolkit/components/extensions/ExtensionChild.jsm new file mode 100644 index 0000000000..772dac5798 --- /dev/null +++ b/toolkit/components/extensions/ExtensionChild.jsm @@ -0,0 +1,924 @@ +/* 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/. */ + +"use strict"; + +/* exported ExtensionChild */ + +this.EXPORTED_SYMBOLS = ["ExtensionChild"]; + +/* + * This file handles addon logic that is independent of the chrome process. + * When addons run out-of-process, this is the main entry point. + * Its primary function is managing addon globals. + * + * Don't put contentscript logic here, use ExtensionContent.jsm instead. + */ + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionContent", + "resource://gre/modules/ExtensionContent.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel", + "resource://gre/modules/MessageChannel.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NativeApp", + "resource://gre/modules/NativeMessaging.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils", + "resource://gre/modules/PromiseUtils.jsm"); + +Cu.import("resource://gre/modules/ExtensionCommon.jsm"); +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); + +const { + DefaultMap, + EventEmitter, + LimitedSet, + defineLazyGetter, + getMessageManager, + getUniqueId, +} = ExtensionUtils; + +const { + LocalAPIImplementation, + LocaleData, + NoCloneSpreadArgs, + SchemaAPIInterface, + SingletonEventManager, +} = ExtensionCommon; + +const isContentProcess = Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT; + +// Copy an API object from |source| into the scope |dest|. +function injectAPI(source, dest) { + for (let prop in source) { + // Skip names prefixed with '_'. + if (prop[0] == "_") { + continue; + } + + let desc = Object.getOwnPropertyDescriptor(source, prop); + if (typeof(desc.value) == "function") { + Cu.exportFunction(desc.value, dest, {defineAs: prop}); + } else if (typeof(desc.value) == "object") { + let obj = Cu.createObjectIn(dest, {defineAs: prop}); + injectAPI(desc.value, obj); + } else { + Object.defineProperty(dest, prop, desc); + } + } +} + +/** + * Abstraction for a Port object in the extension API. + * + * @param {BaseContext} context The context that owns this port. + * @param {nsIMessageSender} senderMM The message manager to send messages to. + * @param {Array} receiverMMs Message managers to + * listen on. + * @param {string} name Arbitrary port name as defined by the addon. + * @param {string} id An ID that uniquely identifies this port's channel. + * @param {object} sender The `port.sender` property. + * @param {object} recipient The recipient of messages sent from this port. + */ +class Port { + constructor(context, senderMM, receiverMMs, name, id, sender, recipient) { + this.context = context; + this.senderMM = senderMM; + this.receiverMMs = receiverMMs; + this.name = name; + this.id = id; + this.sender = sender; + this.recipient = recipient; + this.disconnected = false; + this.disconnectListeners = new Set(); + this.unregisterMessageFuncs = new Set(); + + // Common options for onMessage and onDisconnect. + this.handlerBase = { + messageFilterStrict: {portId: id}, + + filterMessage: (sender, recipient) => { + return sender.contextId !== this.context.contextId; + }, + }; + + this.disconnectHandler = Object.assign({ + receiveMessage: ({data}) => this.disconnectByOtherEnd(data), + }, this.handlerBase); + + MessageChannel.addListener(this.receiverMMs, "Extension:Port:Disconnect", this.disconnectHandler); + + this.context.callOnClose(this); + } + + api() { + let portObj = Cu.createObjectIn(this.context.cloneScope); + + let portError = null; + let publicAPI = { + name: this.name, + + disconnect: () => { + this.disconnect(); + }, + + postMessage: json => { + this.postMessage(json); + }, + + onDisconnect: new SingletonEventManager(this.context, "Port.onDisconnect", fire => { + return this.registerOnDisconnect(holder => { + let error = holder.deserialize(this.context.cloneScope); + portError = error && this.context.normalizeError(error); + fire.asyncWithoutClone(portObj); + }); + }).api(), + + onMessage: new SingletonEventManager(this.context, "Port.onMessage", fire => { + return this.registerOnMessage(holder => { + let msg = holder.deserialize(this.context.cloneScope); + fire.asyncWithoutClone(msg, portObj); + }); + }).api(), + + get error() { + return portError; + }, + }; + + if (this.sender) { + publicAPI.sender = this.sender; + } + + injectAPI(publicAPI, portObj); + return portObj; + } + + postMessage(json) { + if (this.disconnected) { + throw new this.context.cloneScope.Error("Attempt to postMessage on disconnected port"); + } + + this._sendMessage("Extension:Port:PostMessage", json); + } + + /** + * Register a callback that is called when the port is disconnected by the + * *other* end. The callback is automatically unregistered when the port or + * context is closed. + * + * @param {function} callback Called when the other end disconnects the port. + * If the disconnect is caused by an error, the first parameter is an + * object with a "message" string property that describes the cause. + * @returns {function} Function to unregister the listener. + */ + registerOnDisconnect(callback) { + let listener = error => { + if (this.context.active && !this.disconnected) { + callback(error); + } + }; + this.disconnectListeners.add(listener); + return () => { + this.disconnectListeners.delete(listener); + }; + } + + /** + * Register a callback that is called when a message is received. The callback + * is automatically unregistered when the port or context is closed. + * + * @param {function} callback Called when a message is received. + * @returns {function} Function to unregister the listener. + */ + registerOnMessage(callback) { + let handler = Object.assign({ + receiveMessage: ({data}) => { + if (this.context.active && !this.disconnected) { + callback(data); + } + }, + }, this.handlerBase); + + let unregister = () => { + this.unregisterMessageFuncs.delete(unregister); + MessageChannel.removeListener(this.receiverMMs, "Extension:Port:PostMessage", handler); + }; + MessageChannel.addListener(this.receiverMMs, "Extension:Port:PostMessage", handler); + this.unregisterMessageFuncs.add(unregister); + return unregister; + } + + _sendMessage(message, data) { + let options = { + recipient: Object.assign({}, this.recipient, {portId: this.id}), + responseType: MessageChannel.RESPONSE_NONE, + }; + + let holder = new StructuredCloneHolder(data); + + return this.context.sendMessage(this.senderMM, message, holder, options); + } + + handleDisconnection() { + MessageChannel.removeListener(this.receiverMMs, "Extension:Port:Disconnect", this.disconnectHandler); + for (let unregister of this.unregisterMessageFuncs) { + unregister(); + } + this.context.forgetOnClose(this); + this.disconnected = true; + } + + /** + * Disconnect the port from the other end (which may not even exist). + * + * @param {Error|{message: string}} [error] The reason for disconnecting, + * if it is an abnormal disconnect. + */ + disconnectByOtherEnd(error = null) { + if (this.disconnected) { + return; + } + + for (let listener of this.disconnectListeners) { + listener(error); + } + + this.handleDisconnection(); + } + + /** + * Disconnect the port from this end. + * + * @param {Error|{message: string}} [error] The reason for disconnecting, + * if it is an abnormal disconnect. + */ + disconnect(error = null) { + if (this.disconnected) { + // disconnect() may be called without side effects even after the port is + // closed - https://developer.chrome.com/extensions/runtime#type-Port + return; + } + this.handleDisconnection(); + if (error) { + error = {message: this.context.normalizeError(error).message}; + } + this._sendMessage("Extension:Port:Disconnect", error); + } + + close() { + this.disconnect(); + } +} + +class NativePort extends Port { + postMessage(data) { + data = NativeApp.encodeMessage(this.context, data); + + return super.postMessage(data); + } +} + +/** + * Each extension context gets its own Messenger object. It handles the + * basics of sendMessage, onMessage, connect and onConnect. + * + * @param {BaseContext} context The context to which this Messenger is tied. + * @param {Array} messageManagers + * The message managers used to receive messages (e.g. onMessage/onConnect + * requests). + * @param {object} sender Describes this sender to the recipient. This object + * is extended further by BaseContext's sendMessage method and appears as + * the `sender` object to `onConnect` and `onMessage`. + * Do not set the `extensionId`, `contextId` or `tab` properties. The former + * two are added by BaseContext's sendMessage, while `sender.tab` is set by + * the ProxyMessenger in the main process. + * @param {object} filter A recipient filter to apply to incoming messages from + * the broker. Messages are only handled by this Messenger if all key-value + * pairs match the `recipient` as specified by the sender of the message. + * In other words, this filter defines the required fields of `recipient`. + * @param {object} [optionalFilter] An additional filter to apply to incoming + * messages. Unlike `filter`, the keys from `optionalFilter` are allowed to + * be omitted from `recipient`. Only keys that are present in both + * `optionalFilter` and `recipient` are applied to filter incoming messages. + */ +class Messenger { + constructor(context, messageManagers, sender, filter, optionalFilter) { + this.context = context; + this.messageManagers = messageManagers; + this.sender = sender; + this.filter = filter; + this.optionalFilter = optionalFilter; + } + + _sendMessage(messageManager, message, data, recipient) { + let options = { + recipient, + sender: this.sender, + responseType: MessageChannel.RESPONSE_FIRST, + }; + + return this.context.sendMessage(messageManager, message, data, options); + } + + sendMessage(messageManager, msg, recipient, responseCallback) { + let holder = new StructuredCloneHolder(msg); + + let promise = this._sendMessage(messageManager, "Extension:Message", holder, recipient) + .catch(error => { + if (error.result == MessageChannel.RESULT_NO_HANDLER) { + return Promise.reject({message: "Could not establish connection. Receiving end does not exist."}); + } else if (error.result != MessageChannel.RESULT_NO_RESPONSE) { + return Promise.reject({message: error.message}); + } + }); + + return this.context.wrapPromise(promise, responseCallback); + } + + sendNativeMessage(messageManager, msg, recipient, responseCallback) { + msg = NativeApp.encodeMessage(this.context, msg); + return this.sendMessage(messageManager, msg, recipient, responseCallback); + } + + _onMessage(name, filter) { + return new SingletonEventManager(this.context, name, fire => { + let listener = { + messageFilterPermissive: this.optionalFilter, + messageFilterStrict: this.filter, + + filterMessage: (sender, recipient) => { + // Ignore the message if it was sent by this Messenger. + return (sender.contextId !== this.context.contextId && + filter(sender, recipient)); + }, + + receiveMessage: ({target, data: holder, sender, recipient}) => { + if (!this.context.active) { + return; + } + + let sendResponse; + let response = undefined; + let promise = new Promise(resolve => { + sendResponse = value => { + resolve(value); + response = promise; + }; + }); + + let message = holder.deserialize(this.context.cloneScope); + sender = Cu.cloneInto(sender, this.context.cloneScope); + sendResponse = Cu.exportFunction(sendResponse, this.context.cloneScope); + + // Note: We intentionally do not use runSafe here so that any + // errors are propagated to the message sender. + let result = fire.raw(message, sender, sendResponse); + if (result instanceof this.context.cloneScope.Promise) { + return result; + } else if (result === true) { + return promise; + } + return response; + }, + }; + + MessageChannel.addListener(this.messageManagers, "Extension:Message", listener); + return () => { + MessageChannel.removeListener(this.messageManagers, "Extension:Message", listener); + }; + }).api(); + } + + onMessage(name) { + return this._onMessage(name, sender => sender.id === this.sender.id); + } + + onMessageExternal(name) { + return this._onMessage(name, sender => sender.id !== this.sender.id); + } + + _connect(messageManager, port, recipient) { + let msg = { + name: port.name, + portId: port.id, + }; + + this._sendMessage(messageManager, "Extension:Connect", msg, recipient).catch(error => { + if (error.result === MessageChannel.RESULT_NO_HANDLER) { + error = {message: "Could not establish connection. Receiving end does not exist."}; + } else if (error.result === MessageChannel.RESULT_DISCONNECTED) { + error = null; + } + port.disconnectByOtherEnd(new StructuredCloneHolder(error)); + }); + + return port.api(); + } + + connect(messageManager, name, recipient) { + let portId = getUniqueId(); + + let port = new Port(this.context, messageManager, this.messageManagers, name, portId, null, recipient); + + return this._connect(messageManager, port, recipient); + } + + connectNative(messageManager, name, recipient) { + let portId = getUniqueId(); + + let port = new NativePort(this.context, messageManager, this.messageManagers, name, portId, null, recipient); + + return this._connect(messageManager, port, recipient); + } + + _onConnect(name, filter) { + return new SingletonEventManager(this.context, name, fire => { + let listener = { + messageFilterPermissive: this.optionalFilter, + messageFilterStrict: this.filter, + + filterMessage: (sender, recipient) => { + // Ignore the port if it was created by this Messenger. + return (sender.contextId !== this.context.contextId && + filter(sender, recipient)); + }, + + receiveMessage: ({target, data: message, sender}) => { + let {name, portId} = message; + let mm = getMessageManager(target); + let recipient = Object.assign({}, sender); + if (recipient.tab) { + recipient.tabId = recipient.tab.id; + delete recipient.tab; + } + let port = new Port(this.context, mm, this.messageManagers, name, portId, sender, recipient); + fire.asyncWithoutClone(port.api()); + return true; + }, + }; + + MessageChannel.addListener(this.messageManagers, "Extension:Connect", listener); + return () => { + MessageChannel.removeListener(this.messageManagers, "Extension:Connect", listener); + }; + }).api(); + } + + onConnect(name) { + return this._onConnect(name, sender => sender.id === this.sender.id); + } + + onConnectExternal(name) { + return this._onConnect(name, sender => sender.id !== this.sender.id); + } +} + +// For test use only. +var ExtensionManager = { + extensions: new Map(), +}; + +// Represents a browser extension in the content process. +class BrowserExtensionContent extends EventEmitter { + constructor(data) { + super(); + + this.data = data; + this.id = data.id; + this.uuid = data.uuid; + this.instanceId = data.instanceId; + + this.MESSAGE_EMIT_EVENT = `Extension:EmitEvent:${this.instanceId}`; + Services.cpmm.addMessageListener(this.MESSAGE_EMIT_EVENT, this); + + defineLazyGetter(this, "scripts", () => { + return data.content_scripts.map(scriptData => new ExtensionContent.Script(this, scriptData)); + }); + + this.webAccessibleResources = data.webAccessibleResources.map(res => new MatchGlob(res)); + this.whiteListedHosts = new MatchPatternSet(data.whiteListedHosts, {ignorePath: true}); + this.permissions = data.permissions; + this.optionalPermissions = data.optionalPermissions; + this.principal = data.principal; + + this.localeData = new LocaleData(data.localeData); + + this.manifest = data.manifest; + this.baseURI = Services.io.newURI(data.baseURL); + + // Only used in addon processes. + this.views = new Set(); + + // Only used for devtools views. + this.devtoolsViews = new Set(); + + /* eslint-disable mozilla/balanced-listeners */ + this.on("add-permissions", (ignoreEvent, permissions) => { + if (permissions.permissions.length > 0) { + for (let perm of permissions.permissions) { + this.permissions.add(perm); + } + } + + if (permissions.origins.length > 0) { + let patterns = this.whiteListedHosts.patterns.map(host => host.pattern); + + this.whiteListedHosts = new MatchPatternSet([...patterns, ...permissions.origins], + {ignorePath: true}); + } + + if (this.policy) { + this.policy.permissions = Array.from(this.permissions); + this.policy.allowedOrigins = this.whiteListedHosts; + } + }); + + this.on("remove-permissions", (ignoreEvent, permissions) => { + if (permissions.permissions.length > 0) { + for (let perm of permissions.permissions) { + this.permissions.delete(perm); + } + } + + if (permissions.origins.length > 0) { + let origins = permissions.origins.map( + origin => new MatchPattern(origin, {ignorePath: true}).pattern); + + this.whiteListedHosts = new MatchPatternSet( + this.whiteListedHosts.patterns + .filter(host => !origins.includes(host.pattern))); + } + + if (this.policy) { + this.policy.permissions = Array.from(this.permissions); + this.policy.allowedOrigins = this.whiteListedHosts; + } + }); + /* eslint-enable mozilla/balanced-listeners */ + + ExtensionManager.extensions.set(this.id, this); + } + + shutdown() { + ExtensionManager.extensions.delete(this.id); + ExtensionContent.shutdownExtension(this); + Services.cpmm.removeMessageListener(this.MESSAGE_EMIT_EVENT, this); + if (isContentProcess) { + MessageChannel.abortResponses({extensionId: this.id}); + } + } + + getContext(window) { + return ExtensionContent.getContext(this, window); + } + + emit(event, ...args) { + Services.cpmm.sendAsyncMessage(this.MESSAGE_EMIT_EVENT, {event, args}); + + super.emit(event, ...args); + } + + receiveMessage({name, data}) { + if (name === this.MESSAGE_EMIT_EVENT) { + super.emit(data.event, ...data.args); + } + } + + localizeMessage(...args) { + return this.localeData.localizeMessage(...args); + } + + localize(...args) { + return this.localeData.localize(...args); + } + + hasPermission(perm) { + let match = /^manifest:(.*)/.exec(perm); + if (match) { + return this.manifest[match[1]] != null; + } + return this.permissions.has(perm); + } +} + +/** + * An object that runs an remote implementation of an API. + */ +class ProxyAPIImplementation extends SchemaAPIInterface { + /** + * @param {string} namespace The full path to the namespace that contains the + * `name` member. This may contain dots, e.g. "storage.local". + * @param {string} name The name of the method or property. + * @param {ChildAPIManager} childApiManager The owner of this implementation. + */ + constructor(namespace, name, childApiManager) { + super(); + this.path = `${namespace}.${name}`; + this.childApiManager = childApiManager; + } + + revoke() { + let map = this.childApiManager.listeners.get(this.path); + for (let listener of map.keys()) { + this.removeListener(listener); + } + + this.path = null; + this.childApiManager = null; + } + + callFunctionNoReturn(args) { + this.childApiManager.callParentFunctionNoReturn(this.path, args); + } + + callAsyncFunction(args, callback) { + return this.childApiManager.callParentAsyncFunction(this.path, args, callback); + } + + addListener(listener, args) { + let map = this.childApiManager.listeners.get(this.path); + + if (map.listeners.has(listener)) { + // TODO: Called with different args? + return; + } + + let id = getUniqueId(); + + map.ids.set(id, listener); + map.listeners.set(listener, id); + + this.childApiManager.messageManager.sendAsyncMessage("API:AddListener", { + childId: this.childApiManager.id, + listenerId: id, + path: this.path, + args, + }); + } + + removeListener(listener) { + let map = this.childApiManager.listeners.get(this.path); + + if (!map.listeners.has(listener)) { + return; + } + + let id = map.listeners.get(listener); + map.listeners.delete(listener); + map.ids.delete(id); + map.removedIds.add(id); + + this.childApiManager.messageManager.sendAsyncMessage("API:RemoveListener", { + childId: this.childApiManager.id, + listenerId: id, + path: this.path, + }); + } + + hasListener(listener) { + let map = this.childApiManager.listeners.get(this.path); + return map.listeners.has(listener); + } +} + +// We create one instance of this class for every extension context that +// needs to use remote APIs. It uses the message manager to communicate +// with the ParentAPIManager singleton in ExtensionParent.jsm. It +// handles asynchronous function calls as well as event listeners. +class ChildAPIManager { + constructor(context, messageManager, localAPICan, contextData) { + this.context = context; + this.messageManager = messageManager; + this.url = contextData.url; + + // The root namespace of all locally implemented APIs. If an extension calls + // an API that does not exist in this object, then the implementation is + // delegated to the ParentAPIManager. + this.localApis = localAPICan.root; + this.apiCan = localAPICan; + + this.id = `${context.extension.id}.${context.contextId}`; + + MessageChannel.addListener(messageManager, "API:RunListener", this); + messageManager.addMessageListener("API:CallResult", this); + + this.messageFilterStrict = {childId: this.id}; + + this.listeners = new DefaultMap(() => ({ + ids: new Map(), + listeners: new Map(), + removedIds: new LimitedSet(10), + })); + + // Map[callId -> Deferred] + this.callPromises = new Map(); + + let params = { + childId: this.id, + extensionId: context.extension.id, + principal: context.principal, + }; + Object.assign(params, contextData); + + this.messageManager.sendAsyncMessage("API:CreateProxyContext", params); + + this.permissionsChangedCallbacks = new Set(); + this.updatePermissions = null; + if (this.context.extension.optionalPermissions.length > 0) { + this.updatePermissions = () => { + for (let callback of this.permissionsChangedCallbacks) { + try { + callback(); + } catch (err) { + Cu.reportError(err); + } + } + }; + this.context.extension.on("add-permissions", this.updatePermissions); + this.context.extension.on("remove-permissions", this.updatePermissions); + } + } + + receiveMessage({name, messageName, data}) { + if (data.childId != this.id) { + return; + } + + switch (name || messageName) { + case "API:RunListener": + let map = this.listeners.get(data.path); + let listener = map.ids.get(data.listenerId); + + if (listener) { + let args = data.args.deserialize(this.context.cloneScope); + + return this.context.runSafeWithoutClone(listener, ...args); + } + if (!map.removedIds.has(data.listenerId)) { + Services.console.logStringMessage( + `Unknown listener at childId=${data.childId} path=${data.path} listenerId=${data.listenerId}\n`); + } + break; + + case "API:CallResult": + let deferred = this.callPromises.get(data.callId); + if ("error" in data) { + deferred.reject(data.error); + } else { + let result = data.result.deserialize(this.context.cloneScope); + + deferred.resolve(new NoCloneSpreadArgs(result)); + } + this.callPromises.delete(data.callId); + break; + } + } + + /** + * Call a function in the parent process and ignores its return value. + * + * @param {string} path The full name of the method, e.g. "tabs.create". + * @param {Array} args The parameters for the function. + */ + callParentFunctionNoReturn(path, args) { + this.messageManager.sendAsyncMessage("API:Call", { + childId: this.id, + path, + args, + }); + } + + /** + * Calls a function in the parent process and returns its result + * asynchronously. + * + * @param {string} path The full name of the method, e.g. "tabs.create". + * @param {Array} args The parameters for the function. + * @param {function(*)} [callback] The callback to be called when the function + * completes. + * @returns {Promise|undefined} Must be void if `callback` is set, and a + * promise otherwise. The promise is resolved when the function completes. + */ + callParentAsyncFunction(path, args, callback) { + let callId = getUniqueId(); + let deferred = PromiseUtils.defer(); + this.callPromises.set(callId, deferred); + + this.messageManager.sendAsyncMessage("API:Call", { + childId: this.id, + callId, + path, + args, + }); + + return this.context.wrapPromise(deferred.promise, callback); + } + + /** + * Create a proxy for an event in the parent process. The returned event + * object shares its internal state with other instances. For instance, if + * `removeListener` is used on a listener that was added on another object + * through `addListener`, then the event is unregistered. + * + * @param {string} path The full name of the event, e.g. "tabs.onCreated". + * @returns {object} An object with the addListener, removeListener and + * hasListener methods. See SchemaAPIInterface for documentation. + */ + getParentEvent(path) { + path = path.split("."); + + let name = path.pop(); + let namespace = path.join("."); + + let impl = new ProxyAPIImplementation(namespace, name, this); + return { + addListener: (listener, ...args) => impl.addListener(listener, args), + removeListener: (listener) => impl.removeListener(listener), + hasListener: (listener) => impl.hasListener(listener), + }; + } + + close() { + this.messageManager.sendAsyncMessage("API:CloseProxyContext", {childId: this.id}); + if (this.updatePermissions) { + this.context.extension.off("add-permissions", this.updatePermissions); + this.context.extension.off("remove-permissions", this.updatePermissions); + } + } + + get cloneScope() { + return this.context.cloneScope; + } + + get principal() { + return this.context.principal; + } + + shouldInject(namespace, name, allowedContexts) { + // Do not generate content script APIs, unless explicitly allowed. + if (this.context.envType === "content_child" && + !allowedContexts.includes("content")) { + return false; + } + if (allowedContexts.includes("addon_parent_only")) { + return false; + } + + // Do not generate devtools APIs, unless explicitly allowed. + if (this.context.envType === "devtools_child" && + !allowedContexts.includes("devtools")) { + return false; + } + + // Do not generate devtools APIs, unless explicitly allowed. + if (this.context.envType !== "devtools_child" && + allowedContexts.includes("devtools_only")) { + return false; + } + + return true; + } + + getImplementation(namespace, name) { + this.apiCan.findAPIPath(`${namespace}.${name}`); + let obj = this.apiCan.findAPIPath(namespace); + + if (obj && name in obj) { + return new LocalAPIImplementation(obj, name, this.context); + } + + return this.getFallbackImplementation(namespace, name); + } + + getFallbackImplementation(namespace, name) { + // No local API found, defer implementation to the parent. + return new ProxyAPIImplementation(namespace, name, this); + } + + hasPermission(permission) { + return this.context.extension.hasPermission(permission); + } + + isPermissionRevokable(permission) { + return this.context.extension.optionalPermissions.includes(permission); + } + + setPermissionsChangedCallback(callback) { + this.permissionsChangedCallbacks.add(callback); + } +} + +var ExtensionChild = { + BrowserExtensionContent, + ChildAPIManager, + Messenger, + Port, +}; diff --git a/toolkit/components/extensions/ExtensionChildDevToolsUtils.jsm b/toolkit/components/extensions/ExtensionChildDevToolsUtils.jsm new file mode 100644 index 0000000000..9aebaccf95 --- /dev/null +++ b/toolkit/components/extensions/ExtensionChildDevToolsUtils.jsm @@ -0,0 +1,116 @@ +/* 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/. */ + +/** + * @fileOverview + * This module contains utilities for interacting with DevTools + * from the child process. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["ExtensionChildDevToolsUtils"]; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter", + "resource://gre/modules/EventEmitter.jsm"); + +// Create a variable to hold the cached ThemeChangeObserver which does not +// get created until a devtools context has been created. +let themeChangeObserver; + +/** + * An observer that watches for changes to the devtools theme and provides + * that information to the devtools.panels.themeName API property, as well as + * emits events for the devtools.panels.onThemeChanged event. It also caches + * the current value of devtools.themeName. + */ +class ThemeChangeObserver extends EventEmitter { + constructor(themeName, onDestroyed) { + super(); + this.themeName = themeName; + this.onDestroyed = onDestroyed; + this.contexts = new Set(); + + Services.cpmm.addMessageListener("Extension:DevToolsThemeChanged", this); + } + + addContext(context) { + if (this.contexts.has(context)) { + throw new Error( + "addContext on the ThemeChangeObserver was called more than once" + + " for the context."); + } + + context.callOnClose({ + close: () => this.onContextClosed(context), + }); + + this.contexts.add(context); + } + + onContextClosed(context) { + this.contexts.delete(context); + + if (this.contexts.size === 0) { + this.destroy(); + } + } + + onThemeChanged(themeName) { + // Update the cached themeName and emit an event for the API. + this.themeName = themeName; + this.emit("themeChanged", themeName); + } + + receiveMessage({name, data}) { + if (name === "Extension:DevToolsThemeChanged") { + this.onThemeChanged(data.themeName); + } + } + + destroy() { + Services.cpmm.removeMessageListener("Extension:DevToolsThemeChanged", this); + this.onDestroyed(); + this.onDestroyed = null; + this.contexts.clear(); + this.contexts = null; + } +} + +this.ExtensionChildDevToolsUtils = { + /** + * Creates an cached instance of the ThemeChangeObserver class and + * initializes it with the current themeName. This cached instance is + * destroyed when all of the contexts added to it are closed. + * + * @param {string} themeName The name of the current devtools theme. + * @param {DevToolsContextChild} context The newly created devtools page context. + */ + initThemeChangeObserver(themeName, context) { + if (!themeChangeObserver) { + themeChangeObserver = new ThemeChangeObserver( + themeName, + function() { themeChangeObserver = null; } + ); + } + themeChangeObserver.addContext(context); + }, + + /** + * Returns the cached instance of ThemeChangeObserver. + * + * @returns {ThemeChangeObserver} The cached instance of ThemeChangeObserver. + */ + getThemeChangeObserver() { + if (!themeChangeObserver) { + throw new Error("A ThemeChangeObserver must be created before being retrieved."); + } + return themeChangeObserver; + }, +}; diff --git a/toolkit/components/extensions/ExtensionCommon.jsm b/toolkit/components/extensions/ExtensionCommon.jsm new file mode 100644 index 0000000000..bcf81ebb7c --- /dev/null +++ b/toolkit/components/extensions/ExtensionCommon.jsm @@ -0,0 +1,1520 @@ +/* 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/. */ +"use strict"; + +/** + * This module contains utilities and base classes for logic which is + * common between the parent and child process, and in particular + * between ExtensionParent.jsm and ExtensionChild.jsm. + */ + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +/* exported ExtensionCommon */ + +this.EXPORTED_SYMBOLS = ["ExtensionCommon"]; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Locale", + "resource://gre/modules/Locale.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel", + "resource://gre/modules/MessageChannel.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Preferences", + "resource://gre/modules/Preferences.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Schemas", + "resource://gre/modules/Schemas.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "styleSheetService", + "@mozilla.org/content/style-sheet-service;1", + "nsIStyleSheetService"); + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); + +var { + DefaultMap, + DefaultWeakMap, + EventEmitter, + ExtensionError, + defineLazyGetter, + getConsole, + getInnerWindowID, + getUniqueId, + runSafeSync, + runSafeSyncWithoutClone, + instanceOf, +} = ExtensionUtils; + +XPCOMUtils.defineLazyGetter(this, "console", getConsole); + +var ExtensionCommon; + +/** + * A sentinel class to indicate that an array of values should be + * treated as an array when used as a promise resolution value, but as a + * spread expression (...args) when passed to a callback. + */ +class SpreadArgs extends Array { + constructor(args) { + super(); + this.push(...args); + } +} + +/** + * Like SpreadArgs, but also indicates that the array values already + * belong to the target compartment, and should not be cloned before + * being passed. + * + * The `unwrappedValues` property contains an Array object which belongs + * to the target compartment, and contains the same unwrapped values + * passed the NoCloneSpreadArgs constructor. + */ +class NoCloneSpreadArgs { + constructor(args) { + this.unwrappedValues = args; + } + + [Symbol.iterator]() { + return this.unwrappedValues[Symbol.iterator](); + } +} + +class BaseContext { + constructor(envType, extension) { + this.envType = envType; + this.onClose = new Set(); + this.checkedLastError = false; + this._lastError = null; + this.contextId = getUniqueId(); + this.unloaded = false; + this.extension = extension; + this.jsonSandbox = null; + this.active = true; + this.incognito = null; + this.messageManager = null; + this.docShell = null; + this.contentWindow = null; + this.innerWindowID = 0; + } + + setContentWindow(contentWindow) { + let {document} = contentWindow; + let docShell = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell); + + this.innerWindowID = getInnerWindowID(contentWindow); + this.messageManager = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIContentFrameMessageManager); + + if (this.incognito == null) { + this.incognito = PrivateBrowsingUtils.isContentWindowPrivate(contentWindow); + } + + MessageChannel.setupMessageManagers([this.messageManager]); + + let onPageShow = event => { + if (!event || event.target === document) { + this.docShell = docShell; + this.contentWindow = contentWindow; + this.active = true; + } + }; + let onPageHide = event => { + if (!event || event.target === document) { + // Put this off until the next tick. + Promise.resolve().then(() => { + this.docShell = null; + this.contentWindow = null; + this.active = false; + }); + } + }; + + onPageShow(); + contentWindow.addEventListener("pagehide", onPageHide, true); + contentWindow.addEventListener("pageshow", onPageShow, true); + this.callOnClose({ + close: () => { + onPageHide(); + if (this.active) { + contentWindow.removeEventListener("pagehide", onPageHide, true); + contentWindow.removeEventListener("pageshow", onPageShow, true); + } + }, + }); + } + + get cloneScope() { + throw new Error("Not implemented"); + } + + get principal() { + throw new Error("Not implemented"); + } + + runSafe(...args) { + if (this.unloaded) { + Cu.reportError("context.runSafe called after context unloaded"); + } else if (!this.active) { + Cu.reportError("context.runSafe called while context is inactive"); + } else { + return runSafeSync(this, ...args); + } + } + + runSafeWithoutClone(...args) { + if (this.unloaded) { + Cu.reportError("context.runSafeWithoutClone called after context unloaded"); + } else if (!this.active) { + Cu.reportError("context.runSafeWithoutClone called while context is inactive"); + } else { + return runSafeSyncWithoutClone(...args); + } + } + + checkLoadURL(url, options = {}) { + let ssm = Services.scriptSecurityManager; + + let flags = ssm.STANDARD; + if (!options.allowScript) { + flags |= ssm.DISALLOW_SCRIPT; + } + if (!options.allowInheritsPrincipal) { + flags |= ssm.DISALLOW_INHERIT_PRINCIPAL; + } + if (options.dontReportErrors) { + flags |= ssm.DONT_REPORT_ERRORS; + } + + try { + ssm.checkLoadURIStrWithPrincipal(this.principal, url, flags); + } catch (e) { + return false; + } + return true; + } + + /** + * Safely call JSON.stringify() on an object that comes from an + * extension. + * + * @param {array} args Arguments for JSON.stringify() + * @returns {string} The stringified representation of obj + */ + jsonStringify(...args) { + if (!this.jsonSandbox) { + this.jsonSandbox = Cu.Sandbox(this.principal, { + sameZoneAs: this.cloneScope, + wantXrays: false, + }); + } + + return Cu.waiveXrays(this.jsonSandbox.JSON).stringify(...args); + } + + callOnClose(obj) { + this.onClose.add(obj); + } + + forgetOnClose(obj) { + this.onClose.delete(obj); + } + + /** + * A wrapper around MessageChannel.sendMessage which adds the extension ID + * to the recipient object, and ensures replies are not processed after the + * context has been unloaded. + * + * @param {nsIMessageManager} target + * @param {string} messageName + * @param {object} data + * @param {object} [options] + * @param {object} [options.sender] + * @param {object} [options.recipient] + * + * @returns {Promise} + */ + sendMessage(target, messageName, data, options = {}) { + options.recipient = Object.assign({extensionId: this.extension.id}, options.recipient); + options.sender = options.sender || {}; + + options.sender.extensionId = this.extension.id; + options.sender.contextId = this.contextId; + + return MessageChannel.sendMessage(target, messageName, data, options); + } + + get lastError() { + this.checkedLastError = true; + return this._lastError; + } + + set lastError(val) { + this.checkedLastError = false; + this._lastError = val; + } + + /** + * Normalizes the given error object for use by the target scope. If + * the target is an error object which belongs to that scope, it is + * returned as-is. If it is an ordinary object with a `message` + * property, it is converted into an error belonging to the target + * scope. If it is an Error object which does *not* belong to the + * clone scope, it is reported, and converted to an unexpected + * exception error. + * + * @param {Error|object} error + * @returns {Error} + */ + normalizeError(error) { + if (error instanceof this.cloneScope.Error) { + return error; + } + let message, fileName; + if (instanceOf(error, "Object") || error instanceof ExtensionError || + (typeof error == "object" && this.principal.subsumes(Cu.getObjectPrincipal(error)))) { + message = error.message; + fileName = error.fileName; + } else { + Cu.reportError(error); + } + message = message || "An unexpected error occurred"; + return new this.cloneScope.Error(message, fileName); + } + + /** + * Sets the value of `.lastError` to `error`, calls the given + * callback, and reports an error if the value has not been checked + * when the callback returns. + * + * @param {object} error An object with a `message` property. May + * optionally be an `Error` object belonging to the target scope. + * @param {function} callback The callback to call. + * @returns {*} The return value of callback. + */ + withLastError(error, callback) { + this.lastError = this.normalizeError(error); + try { + return callback(); + } finally { + if (!this.checkedLastError) { + Cu.reportError(`Unchecked lastError value: ${this.lastError}`); + } + this.lastError = null; + } + } + + /** + * Wraps the given promise so it can be safely returned to extension + * code in this context. + * + * If `callback` is provided, however, it is used as a completion + * function for the promise, and no promise is returned. In this case, + * the callback is called when the promise resolves or rejects. In the + * latter case, `lastError` is set to the rejection value, and the + * callback function must check `browser.runtime.lastError` or + * `extension.runtime.lastError` in order to prevent it being reported + * to the console. + * + * @param {Promise} promise The promise with which to wrap the + * callback. May resolve to a `SpreadArgs` instance, in which case + * each element will be used as a separate argument. + * + * Unless the promise object belongs to the cloneScope global, its + * resolution value is cloned into cloneScope prior to calling the + * `callback` function or resolving the wrapped promise. + * + * @param {function} [callback] The callback function to wrap + * + * @returns {Promise|undefined} If callback is null, a promise object + * belonging to the target scope. Otherwise, undefined. + */ + wrapPromise(promise, callback = null) { + let runSafe = this.runSafe.bind(this); + if (promise instanceof this.cloneScope.Promise) { + runSafe = this.runSafeWithoutClone.bind(this); + } + + if (callback) { + promise.then( + args => { + if (this.unloaded) { + dump(`Promise resolved after context unloaded\n`); + } else if (!this.active) { + dump(`Promise resolved while context is inactive\n`); + } else if (args instanceof NoCloneSpreadArgs) { + this.runSafeWithoutClone(callback, ...args.unwrappedValues); + } else if (args instanceof SpreadArgs) { + runSafe(callback, ...args); + } else { + runSafe(callback, args); + } + }, + error => { + this.withLastError(error, () => { + if (this.unloaded) { + dump(`Promise rejected after context unloaded\n`); + } else if (!this.active) { + dump(`Promise rejected while context is inactive\n`); + } else { + this.runSafeWithoutClone(callback); + } + }); + }); + } else { + return new this.cloneScope.Promise((resolve, reject) => { + promise.then( + value => { + if (this.unloaded) { + dump(`Promise resolved after context unloaded\n`); + } else if (!this.active) { + dump(`Promise resolved while context is inactive\n`); + } else if (value instanceof NoCloneSpreadArgs) { + let values = value.unwrappedValues; + this.runSafeWithoutClone(resolve, values.length == 1 ? values[0] : values); + } else if (value instanceof SpreadArgs) { + runSafe(resolve, value.length == 1 ? value[0] : value); + } else { + runSafe(resolve, value); + } + }, + value => { + if (this.unloaded) { + dump(`Promise rejected after context unloaded: ${value && value.message}\n`); + } else if (!this.active) { + dump(`Promise rejected while context is inactive: ${value && value.message}\n`); + } else { + this.runSafeWithoutClone(reject, this.normalizeError(value)); + } + }); + }); + } + } + + unload() { + this.unloaded = true; + + MessageChannel.abortResponses({ + extensionId: this.extension.id, + contextId: this.contextId, + }); + + for (let obj of this.onClose) { + obj.close(); + } + } + + /** + * A simple proxy for unload(), for use with callOnClose(). + */ + close() { + this.unload(); + } +} + +/** + * An object that runs the implementation of a schema API. Instantiations of + * this interfaces are used by Schemas.jsm. + * + * @interface + */ +class SchemaAPIInterface { + /** + * Calls this as a function that returns its return value. + * + * @abstract + * @param {Array} args The parameters for the function. + * @returns {*} The return value of the invoked function. + */ + callFunction(args) { + throw new Error("Not implemented"); + } + + /** + * Calls this as a function and ignores its return value. + * + * @abstract + * @param {Array} args The parameters for the function. + */ + callFunctionNoReturn(args) { + throw new Error("Not implemented"); + } + + /** + * Calls this as a function that completes asynchronously. + * + * @abstract + * @param {Array} args The parameters for the function. + * @param {function(*)} [callback] The callback to be called when the function + * completes. + * @returns {Promise|undefined} Must be void if `callback` is set, and a + * promise otherwise. The promise is resolved when the function completes. + */ + callAsyncFunction(args, callback) { + throw new Error("Not implemented"); + } + + /** + * Retrieves the value of this as a property. + * + * @abstract + * @returns {*} The value of the property. + */ + getProperty() { + throw new Error("Not implemented"); + } + + /** + * Assigns the value to this as property. + * + * @abstract + * @param {string} value The new value of the property. + */ + setProperty(value) { + throw new Error("Not implemented"); + } + + /** + * Registers a `listener` to this as an event. + * + * @abstract + * @param {function} listener The callback to be called when the event fires. + * @param {Array} args Extra parameters for EventManager.addListener. + * @see EventManager.addListener + */ + addListener(listener, args) { + throw new Error("Not implemented"); + } + + /** + * Checks whether `listener` is listening to this as an event. + * + * @abstract + * @param {function} listener The event listener. + * @returns {boolean} Whether `listener` is registered with this as an event. + * @see EventManager.hasListener + */ + hasListener(listener) { + throw new Error("Not implemented"); + } + + /** + * Unregisters `listener` from this as an event. + * + * @abstract + * @param {function} listener The event listener. + * @see EventManager.removeListener + */ + removeListener(listener) { + throw new Error("Not implemented"); + } + + /** + * Revokes the implementation object, and prevents any further method + * calls from having external effects. + * + * @abstract + */ + revoke() { + throw new Error("Not implemented"); + } +} + +/** + * An object that runs a locally implemented API. + */ +class LocalAPIImplementation extends SchemaAPIInterface { + /** + * Constructs an implementation of the `name` method or property of `pathObj`. + * + * @param {object} pathObj The object containing the member with name `name`. + * @param {string} name The name of the implemented member. + * @param {BaseContext} context The context in which the schema is injected. + */ + constructor(pathObj, name, context) { + super(); + this.pathObj = pathObj; + this.name = name; + this.context = context; + } + + revoke() { + if (this.pathObj[this.name][Schemas.REVOKE]) { + this.pathObj[this.name][Schemas.REVOKE](); + } + + this.pathObj = null; + this.name = null; + this.context = null; + } + + callFunction(args) { + return this.pathObj[this.name](...args); + } + + callFunctionNoReturn(args) { + this.pathObj[this.name](...args); + } + + callAsyncFunction(args, callback) { + let promise; + try { + promise = this.pathObj[this.name](...args) || Promise.resolve(); + } catch (e) { + promise = Promise.reject(e); + } + return this.context.wrapPromise(promise, callback); + } + + getProperty() { + return this.pathObj[this.name]; + } + + setProperty(value) { + this.pathObj[this.name] = value; + } + + addListener(listener, args) { + try { + this.pathObj[this.name].addListener.call(null, listener, ...args); + } catch (e) { + throw this.context.normalizeError(e); + } + } + + hasListener(listener) { + return this.pathObj[this.name].hasListener.call(null, listener); + } + + removeListener(listener) { + this.pathObj[this.name].removeListener.call(null, listener); + } +} + +// Recursively copy properties from source to dest. +function deepCopy(dest, source) { + for (let prop in source) { + let desc = Object.getOwnPropertyDescriptor(source, prop); + if (typeof(desc.value) == "object") { + if (!(prop in dest)) { + dest[prop] = {}; + } + deepCopy(dest[prop], source[prop]); + } else { + Object.defineProperty(dest, prop, desc); + } + } +} + +/** + * Manages loading and accessing a set of APIs for a specific extension + * context. + * + * @param {BaseContext} context + * The context to manage APIs for. + * @param {SchemaAPIManager} apiManager + * The API manager holding the APIs to manage. + * @param {object} root + * The root object into which APIs will be injected. + */ +class CanOfAPIs { + constructor(context, apiManager, root) { + this.context = context; + this.scopeName = context.envType; + this.apiManager = apiManager; + this.root = root; + + this.apiPaths = new Map(); + + this.apis = new Map(); + } + + /** + * Synchronously loads and initializes an ExtensionAPI instance. + * + * @param {string} name + * The name of the API to load. + */ + loadAPI(name) { + if (this.apis.has(name)) { + return; + } + + let {extension} = this.context; + + let api = this.apiManager.getAPI(name, extension, this.scopeName); + if (!api) { + return; + } + + this.apis.set(name, api); + + deepCopy(this.root, api.getAPI(this.context)); + } + + /** + * Asynchronously loads and initializes an ExtensionAPI instance. + * + * @param {string} name + * The name of the API to load. + */ + async asyncLoadAPI(name) { + if (this.apis.has(name)) { + return; + } + + let {extension} = this.context; + if (!Schemas.checkPermissions(name, extension)) { + return; + } + + let api = await this.apiManager.asyncGetAPI(name, extension, this.scopeName); + // Check again, because async; + if (this.apis.has(name)) { + return; + } + + this.apis.set(name, api); + + deepCopy(this.root, api.getAPI(this.context)); + } + + /** + * Finds the API at the given path from the root object, and + * synchronously loads the API that implements it if it has not + * already been loaded. + * + * @param {string} path + * The "."-separated path to find. + * @returns {*} + */ + findAPIPath(path) { + if (this.apiPaths.has(path)) { + return this.apiPaths.get(path); + } + + let obj = this.root; + let modules = this.apiManager.modulePaths; + + for (let key of path.split(".")) { + if (!obj) { + return; + } + modules = modules.get(key); + + for (let name of modules.modules) { + if (!this.apis.has(name)) { + this.loadAPI(name); + } + } + + obj = obj[key]; + } + + this.apiPaths.set(path, obj); + return obj; + } + + /** + * Finds the API at the given path from the root object, and + * asynchronously loads the API that implements it if it has not + * already been loaded. + * + * @param {string} path + * The "."-separated path to find. + * @returns {Promise<*>} + */ + async asyncFindAPIPath(path) { + if (this.apiPaths.has(path)) { + return this.apiPaths.get(path); + } + + let obj = this.root; + let modules = this.apiManager.modulePaths; + + for (let key of path.split(".")) { + if (!obj) { + return; + } + modules = modules.get(key); + + for (let name of modules.modules) { + if (!this.apis.has(name)) { + await this.asyncLoadAPI(name); + } + } + + if (typeof obj[key] === "function") { + obj = obj[key].bind(obj); + } else { + obj = obj[key]; + } + } + + this.apiPaths.set(path, obj); + return obj; + } +} + +class DeepMap extends DefaultMap { + constructor() { + super(() => new DeepMap()); + + this.modules = new Set(); + } + + getPath(path) { + return path.reduce((map, key) => map.get(key), this); + } +} + +/** + * @class APIModule + * @abstract + * + * @property {string} url + * The URL of the script which contains the module's + * implementation. This script must define a global property + * matching the modules name, which must be a class constructor + * which inherits from {@link ExtensionAPI}. + * + * @property {string} schema + * The URL of the JSON schema which describes the module's API. + * + * @property {Array} scopes + * The list of scope names into which the API may be loaded. + * + * @property {Array} manifest + * The list of top-level manifest properties which will trigger + * the module to be loaded, and its `onManifestEntry` method to be + * called. + * + * @property {Array} events + * The list events which will trigger the module to be loaded, and + * its appropriate event handler method to be called. Currently + * only accepts "startup". + * + * @property {Array>} paths + * A list of paths from the root API object which, when accessed, + * will cause the API module to be instantiated and injected. + */ + +/** + * This object loads the ext-*.js scripts that define the extension API. + * + * This class instance is shared with the scripts that it loads, so that the + * ext-*.js scripts and the instantiator can communicate with each other. + */ +class SchemaAPIManager extends EventEmitter { + /** + * @param {string} processType + * "main" - The main, one and only chrome browser process. + * "addon" - An addon process. + * "content" - A content process. + * "devtools" - A devtools process. + * "proxy" - A proxy script process. + */ + constructor(processType) { + super(); + this.processType = processType; + this.global = this._createExtGlobal(); + + this.modules = new Map(); + this.modulePaths = new DeepMap(); + this.manifestKeys = new Map(); + this.eventModules = new DefaultMap(() => new Set()); + + this.schemaURLs = new Set(); + + this.apis = new DefaultWeakMap(() => new Map()); + + this._scriptScopes = []; + } + + /** + * Registers a set of ExtensionAPI modules to be lazily loaded and + * managed by this manager. + * + * @param {object} obj + * An object containing property for eacy API module to be + * registered. Each value should be an object implementing the + * APIModule interface. + */ + registerModules(obj) { + for (let [name, details] of Object.entries(obj)) { + details.namespaceName = name; + + if (this.modules.has(name)) { + throw new Error(`Module '${name}' already registered`); + } + this.modules.set(name, details); + + if (details.schema) { + this.schemaURLs.add(details.schema); + } + + for (let event of details.events || []) { + this.eventModules.get(event).add(name); + } + + for (let key of details.manifest || []) { + if (this.manifestKeys.has(key)) { + throw new Error(`Manifest key '${key}' already registered by '${this.manifestKeys.get(key)}'`); + } + + this.manifestKeys.set(key, name); + } + + for (let path of details.paths || []) { + this.modulePaths.getPath(path).modules.add(name); + } + } + } + + /** + * Emits an `onManifestEntry` event for the top-level manifest entry + * on all relevant {@link ExtensionAPI} instances for the given + * extension. + * + * The API modules will be synchronously loaded if they have not been + * loaded already. + * + * @param {Extension} extension + * The extension for which to emit the events. + * @param {string} entry + * The name of the top-level manifest entry. + * + * @returns {*} + */ + emitManifestEntry(extension, entry) { + let apiName = this.manifestKeys.get(entry); + if (apiName) { + let api = this.getAPI(apiName, extension); + return api.onManifestEntry(entry); + } + } + /** + * Emits an `onManifestEntry` event for the top-level manifest entry + * on all relevant {@link ExtensionAPI} instances for the given + * extension. + * + * The API modules will be asynchronously loaded if they have not been + * loaded already. + * + * @param {Extension} extension + * The extension for which to emit the events. + * @param {string} entry + * The name of the top-level manifest entry. + * + * @returns {Promise<*>} + */ + async asyncEmitManifestEntry(extension, entry) { + let apiName = this.manifestKeys.get(entry); + if (apiName) { + let api = await this.asyncGetAPI(apiName, extension); + return api.onManifestEntry(entry); + } + } + + /** + * Returns the {@link ExtensionAPI} instance for the given API module, + * for the given extension, in the given scope, synchronously loading + * and instantiating it if necessary. + * + * @param {string} name + * The name of the API module to load. + * @param {Extension} extension + * The extension for which to load the API. + * @param {string} [scope = null] + * The scope type for which to retrieve the API, or null if not + * being retrieved for a particular scope. + * + * @returns {ExtensionAPI?} + */ + getAPI(name, extension, scope = null) { + if (!this._checkGetAPI(name, extension, scope)) { + return; + } + + let apis = this.apis.get(extension); + if (apis.has(name)) { + return apis.get(name); + } + + let module = this.loadModule(name); + + let api = new module(extension); + apis.set(name, api); + return api; + } + /** + * Returns the {@link ExtensionAPI} instance for the given API module, + * for the given extension, in the given scope, asynchronously loading + * and instantiating it if necessary. + * + * @param {string} name + * The name of the API module to load. + * @param {Extension} extension + * The extension for which to load the API. + * @param {string} [scope = null] + * The scope type for which to retrieve the API, or null if not + * being retrieved for a particular scope. + * + * @returns {Promise?} + */ + async asyncGetAPI(name, extension, scope = null) { + if (!this._checkGetAPI(name, extension, scope)) { + return; + } + + let apis = this.apis.get(extension); + if (apis.has(name)) { + return apis.get(name); + } + + let module = await this.asyncLoadModule(name); + + // Check again, because async. + if (apis.has(name)) { + return apis.get(name); + } + + let api = new module(extension); + apis.set(name, api); + return api; + } + + /** + * Synchronously loads an API module, if not already loaded, and + * returns its ExtensionAPI constructor. + * + * @param {string} name + * The name of the module to load. + * + * @returns {class} + */ + loadModule(name) { + let module = this.modules.get(name); + if (module.loaded) { + return this.global[name]; + } + + this._checkLoadModule(module, name); + + Services.scriptloader.loadSubScript(module.url, this.global, "UTF-8"); + + module.loaded = true; + + return this.global[name]; + } + /** + * aSynchronously loads an API module, if not already loaded, and + * returns its ExtensionAPI constructor. + * + * @param {string} name + * The name of the module to load. + * + * @returns {Promise} + */ + asyncLoadModule(name) { + let module = this.modules.get(name); + if (module.loaded) { + return Promise.resolve(this.global[name]); + } + if (module.asyncLoaded) { + return module.asyncLoaded; + } + + this._checkLoadModule(module, name); + + module.asyncLoaded = ChromeUtils.compileScript(module.url).then(script => { + script.executeInGlobal(this.global); + + module.loaded = true; + + return this.global[name]; + }); + + return module.asyncLoaded; + } + + /** + * Checks whether the given API module may be loaded for the given + * extension, in the given scope. + * + * @param {string} name + * The name of the API module to check. + * @param {Extension} extension + * The extension for which to check the API. + * @param {string} [scope = null] + * The scope type for which to check the API, or null if not + * being checked for a particular scope. + * + * @returns {boolean} + * Whether the module may be loaded. + */ + _checkGetAPI(name, extension, scope = null) { + let module = this.modules.get(name); + + if (!scope) { + return true; + } + + if (!module.scopes.includes(scope)) { + return false; + } + + if (!Schemas.checkPermissions(module.namespaceName, extension)) { + return false; + } + + return true; + } + + _checkLoadModule(module, name) { + if (!module) { + throw new Error(`Module '${name}' does not exist`); + } + if (module.asyncLoaded) { + throw new Error(`Module '${name}' currently being lazily loaded`); + } + if (this.global[name]) { + throw new Error(`Module '${name}' conflicts with existing global property`); + } + } + + + /** + * Create a global object that is used as the shared global for all ext-*.js + * scripts that are loaded via `loadScript`. + * + * @returns {object} A sandbox that is used as the global by `loadScript`. + */ + _createExtGlobal() { + let global = Cu.Sandbox(Services.scriptSecurityManager.getSystemPrincipal(), { + wantXrays: false, + sandboxName: `Namespace of ext-*.js scripts for ${this.processType}`, + }); + + Object.assign(global, {global, Cc, Ci, Cu, Cr, XPCOMUtils, ChromeWorker, ExtensionCommon, MatchPattern, MatchPatternSet, extensions: this}); + + Cu.import("resource://gre/modules/AppConstants.jsm", global); + Cu.import("resource://gre/modules/ExtensionAPI.jsm", global); + + XPCOMUtils.defineLazyGetter(global, "console", getConsole); + + XPCOMUtils.defineLazyModuleGetter(global, "ExtensionUtils", + "resource://gre/modules/ExtensionUtils.jsm"); + XPCOMUtils.defineLazyModuleGetter(global, "XPCOMUtils", + "resource://gre/modules/XPCOMUtils.jsm"); + XPCOMUtils.defineLazyModuleGetter(global, "require", + "resource://devtools/shared/Loader.jsm"); + + return global; + } + + /** + * Load an ext-*.js script. The script runs in its own scope, if it wishes to + * share state with another script it can assign to the `global` variable. If + * it wishes to communicate with this API manager, use `extensions`. + * + * @param {string} scriptUrl The URL of the ext-*.js script. + */ + loadScript(scriptUrl) { + // Create the object in the context of the sandbox so that the script runs + // in the sandbox's context instead of here. + let scope = Cu.createObjectIn(this.global); + + Services.scriptloader.loadSubScript(scriptUrl, scope, "UTF-8"); + + // Save the scope to avoid it being garbage collected. + this._scriptScopes.push(scope); + } + + /** + * Mash together all the APIs from `apis` into `obj`. + * + * @param {BaseContext} context The context for which the API bindings are + * generated. + * @param {Array} apis A list of objects, see `registerSchemaAPI`. + * @param {object} obj The destination of the API. + */ + static generateAPIs(context, apis, obj) { + function hasPermission(perm) { + return context.extension.hasPermission(perm, true); + } + for (let api of apis) { + if (Schemas.checkPermissions(api.namespace, {hasPermission})) { + api = api.getAPI(context); + deepCopy(obj, api); + } + } + } +} + +function LocaleData(data) { + this.defaultLocale = data.defaultLocale; + this.selectedLocale = data.selectedLocale; + this.locales = data.locales || new Map(); + this.warnedMissingKeys = new Set(); + + // Map(locale-name -> Map(message-key -> localized-string)) + // + // Contains a key for each loaded locale, each of which is a + // Map of message keys to their localized strings. + this.messages = data.messages || new Map(); + + if (data.builtinMessages) { + this.messages.set(this.BUILTIN, data.builtinMessages); + } +} + +LocaleData.prototype = { + // Representation of the object to send to content processes. This + // should include anything the content process might need. + serialize() { + return { + defaultLocale: this.defaultLocale, + selectedLocale: this.selectedLocale, + messages: this.messages, + locales: this.locales, + }; + }, + + BUILTIN: "@@BUILTIN_MESSAGES", + + has(locale) { + return this.messages.has(locale); + }, + + // https://developer.chrome.com/extensions/i18n + localizeMessage(message, substitutions = [], options = {}) { + let defaultOptions = { + defaultValue: "", + cloneScope: null, + }; + + let locales = this.availableLocales; + if (options.locale) { + locales = new Set([this.BUILTIN, options.locale, this.defaultLocale] + .filter(locale => this.messages.has(locale))); + } + + options = Object.assign(defaultOptions, options); + + // Message names are case-insensitive, so normalize them to lower-case. + message = message.toLowerCase(); + for (let locale of locales) { + let messages = this.messages.get(locale); + if (messages.has(message)) { + let str = messages.get(message); + + if (!str.includes("$")) { + return str; + } + + if (!Array.isArray(substitutions)) { + substitutions = [substitutions]; + } + + let replacer = (matched, index, dollarSigns) => { + if (index) { + // This is not quite Chrome-compatible. Chrome consumes any number + // of digits following the $, but only accepts 9 substitutions. We + // accept any number of substitutions. + index = parseInt(index, 10) - 1; + return index in substitutions ? substitutions[index] : ""; + } + // For any series of contiguous `$`s, the first is dropped, and + // the rest remain in the output string. + return dollarSigns; + }; + return str.replace(/\$(?:([1-9]\d*)|(\$+))/g, replacer); + } + } + + // Check for certain pre-defined messages. + if (message == "@@ui_locale") { + return this.uiLocale; + } else if (message.startsWith("@@bidi_")) { + let registry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry); + let rtl = registry.isLocaleRTL("global"); + + if (message == "@@bidi_dir") { + return rtl ? "rtl" : "ltr"; + } else if (message == "@@bidi_reversed_dir") { + return rtl ? "ltr" : "rtl"; + } else if (message == "@@bidi_start_edge") { + return rtl ? "right" : "left"; + } else if (message == "@@bidi_end_edge") { + return rtl ? "left" : "right"; + } + } + + if (!this.warnedMissingKeys.has(message)) { + let error = `Unknown localization message ${message}`; + if (options.cloneScope) { + error = new options.cloneScope.Error(error); + } + Cu.reportError(error); + this.warnedMissingKeys.add(message); + } + return options.defaultValue; + }, + + // Localize a string, replacing all |__MSG_(.*)__| tokens with the + // matching string from the current locale, as determined by + // |this.selectedLocale|. + // + // This may not be called before calling either |initLocale| or + // |initAllLocales|. + localize(str, locale = this.selectedLocale) { + if (!str) { + return str; + } + + return str.replace(/__MSG_([A-Za-z0-9@_]+?)__/g, (matched, message) => { + return this.localizeMessage(message, [], {locale, defaultValue: matched}); + }); + }, + + // Validates the contents of a locale JSON file, normalizes the + // messages into a Map of message key -> localized string pairs. + addLocale(locale, messages, extension) { + let result = new Map(); + + // Chrome does not document the semantics of its localization + // system very well. It handles replacements by pre-processing + // messages, replacing |$[a-zA-Z0-9@_]+$| tokens with the value of their + // replacements. Later, it processes the resulting string for + // |$[0-9]| replacements. + // + // Again, it does not document this, but it accepts any number + // of sequential |$|s, and replaces them with that number minus + // 1. It also accepts |$| followed by any number of sequential + // digits, but refuses to process a localized string which + // provides more than 9 substitutions. + if (!instanceOf(messages, "Object")) { + extension.packagingError(`Invalid locale data for ${locale}`); + return result; + } + + for (let key of Object.keys(messages)) { + let msg = messages[key]; + + if (!instanceOf(msg, "Object") || typeof(msg.message) != "string") { + extension.packagingError(`Invalid locale message data for ${locale}, message ${JSON.stringify(key)}`); + continue; + } + + // Substitutions are case-insensitive, so normalize all of their names + // to lower-case. + let placeholders = new Map(); + if (instanceOf(msg.placeholders, "Object")) { + for (let key of Object.keys(msg.placeholders)) { + placeholders.set(key.toLowerCase(), msg.placeholders[key]); + } + } + + let replacer = (match, name) => { + let replacement = placeholders.get(name.toLowerCase()); + if (instanceOf(replacement, "Object") && "content" in replacement) { + return replacement.content; + } + return ""; + }; + + let value = msg.message.replace(/\$([A-Za-z0-9@_]+)\$/g, replacer); + + // Message names are also case-insensitive, so normalize them to lower-case. + result.set(key.toLowerCase(), value); + } + + this.messages.set(locale, result); + return result; + }, + + get acceptLanguages() { + let result = Preferences.get("intl.accept_languages", "", Ci.nsIPrefLocalizedString); + return result.split(/\s*,\s*/g); + }, + + + get uiLocale() { + // Return the browser locale, but convert it to a Chrome-style + // locale code. + return Locale.getLocale().replace(/-/g, "_"); + }, +}; + +defineLazyGetter(LocaleData.prototype, "availableLocales", function() { + return new Set([this.BUILTIN, this.selectedLocale, this.defaultLocale] + .filter(locale => this.messages.has(locale))); +}); + +// This is a generic class for managing event listeners. Example usage: +// +// new SingletonEventManager(context, "api.subAPI", fire => { +// let listener = (...) => { +// // Fire any listeners registered with addListener. +// fire.async(arg1, arg2); +// }; +// // Register the listener. +// SomehowRegisterListener(listener); +// return () => { +// // Return a way to unregister the listener. +// SomehowUnregisterListener(listener); +// }; +// }).api() +// +// The result is an object with addListener, removeListener, and +// hasListener methods. |context| is an add-on scope (either an +// ExtensionContext in the chrome process or ExtensionContext in a +// content process). |name| is for debugging. |register| is a function +// to register the listener. |register| should return an +// unregister function that will unregister the listener. +function SingletonEventManager(context, name, register) { + this.context = context; + this.name = name; + this.register = register; + this.unregister = new Map(); +} + +SingletonEventManager.prototype = { + addListener(callback, ...args) { + if (this.unregister.has(callback)) { + return; + } + + let shouldFire = () => { + if (this.context.unloaded) { + dump(`${this.name} event fired after context unloaded.\n`); + } else if (!this.context.active) { + dump(`${this.name} event fired while context is inactive.\n`); + } else if (this.unregister.has(callback)) { + return true; + } + return false; + }; + + let fire = { + sync: (...args) => { + if (shouldFire()) { + return this.context.runSafe(callback, ...args); + } + }, + async: (...args) => { + return Promise.resolve().then(() => { + if (shouldFire()) { + return this.context.runSafe(callback, ...args); + } + }); + }, + raw: (...args) => { + if (!shouldFire()) { + throw new Error("Called raw() on unloaded/inactive context"); + } + return callback(...args); + }, + asyncWithoutClone: (...args) => { + return Promise.resolve().then(() => { + if (shouldFire()) { + return this.context.runSafeWithoutClone(callback, ...args); + } + }); + }, + }; + + + let unregister = this.register(fire, ...args); + this.unregister.set(callback, unregister); + this.context.callOnClose(this); + }, + + removeListener(callback) { + if (!this.unregister.has(callback)) { + return; + } + + let unregister = this.unregister.get(callback); + this.unregister.delete(callback); + try { + unregister(); + } catch (e) { + Cu.reportError(e); + } + if (this.unregister.size == 0) { + this.context.forgetOnClose(this); + } + }, + + hasListener(callback) { + return this.unregister.has(callback); + }, + + revoke() { + for (let callback of this.unregister.keys()) { + this.removeListener(callback); + } + }, + + close() { + this.revoke(); + }, + + api() { + return { + addListener: (...args) => this.addListener(...args), + removeListener: (...args) => this.removeListener(...args), + hasListener: (...args) => this.hasListener(...args), + [Schemas.REVOKE]: () => this.revoke(), + }; + }, +}; + +// Simple API for event listeners where events never fire. +function ignoreEvent(context, name) { + return { + addListener: function(callback) { + let id = context.extension.id; + let frame = Components.stack.caller; + let msg = `In add-on ${id}, attempting to use listener "${name}", which is unimplemented.`; + let scriptError = Cc["@mozilla.org/scripterror;1"] + .createInstance(Ci.nsIScriptError); + scriptError.init(msg, frame.filename, null, frame.lineNumber, + frame.columnNumber, Ci.nsIScriptError.warningFlag, + "content javascript"); + let consoleService = Cc["@mozilla.org/consoleservice;1"] + .getService(Ci.nsIConsoleService); + consoleService.logMessage(scriptError); + }, + removeListener: function(callback) {}, + hasListener: function(callback) {}, + }; +} + + +const stylesheetMap = new DefaultMap(url => { + let uri = Services.io.newURI(url); + return styleSheetService.preloadSheet(uri, styleSheetService.AGENT_SHEET); +}); + + +ExtensionCommon = { + BaseContext, + CanOfAPIs, + LocalAPIImplementation, + LocaleData, + NoCloneSpreadArgs, + SchemaAPIInterface, + SchemaAPIManager, + SingletonEventManager, + SpreadArgs, + ignoreEvent, + stylesheetMap, +}; diff --git a/toolkit/components/extensions/ExtensionContent.jsm b/toolkit/components/extensions/ExtensionContent.jsm new file mode 100644 index 0000000000..045c9146d4 --- /dev/null +++ b/toolkit/components/extensions/ExtensionContent.jsm @@ -0,0 +1,757 @@ +/* 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/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["ExtensionContent"]; + +/* globals ExtensionContent */ + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "LanguageDetector", + "resource:///modules/translation/LanguageDetector.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel", + "resource://gre/modules/MessageChannel.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Schemas", + "resource://gre/modules/Schemas.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WebNavigationFrames", + "resource://gre/modules/WebNavigationFrames.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "styleSheetService", + "@mozilla.org/content/style-sheet-service;1", + "nsIStyleSheetService"); + +// xpcshell doesn't handle idle callbacks well. +XPCOMUtils.defineLazyGetter(this, "idleTimeout", + () => Services.appinfo.name === "XPCShell" ? 500 : undefined); + +const DocumentEncoder = Components.Constructor( + "@mozilla.org/layout/documentEncoder;1?type=text/plain", + "nsIDocumentEncoder", "init"); + +const Timer = Components.Constructor("@mozilla.org/timer;1", "nsITimer", "initWithCallback"); + +Cu.import("resource://gre/modules/ExtensionChild.jsm"); +Cu.import("resource://gre/modules/ExtensionCommon.jsm"); +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); + +const { + DefaultMap, + DefaultWeakMap, + defineLazyGetter, + getInnerWindowID, + getWinUtils, + promiseDocumentLoaded, + promiseDocumentReady, + runSafeSyncWithoutClone, +} = ExtensionUtils; + +const { + BaseContext, + CanOfAPIs, + SchemaAPIManager, +} = ExtensionCommon; + +const { + BrowserExtensionContent, + ChildAPIManager, + Messenger, +} = ExtensionChild; + +XPCOMUtils.defineLazyGetter(this, "console", ExtensionUtils.getConsole); + + +var DocumentManager; + +const CATEGORY_EXTENSION_SCRIPTS_CONTENT = "webextension-scripts-content"; + +var apiManager = new class extends SchemaAPIManager { + constructor() { + super("content"); + this.initialized = false; + } + + lazyInit() { + if (!this.initialized) { + this.initialized = true; + for (let [/* name */, value] of XPCOMUtils.enumerateCategoryEntries(CATEGORY_EXTENSION_SCRIPTS_CONTENT)) { + this.loadScript(value); + } + } + } +}(); + +const SCRIPT_EXPIRY_TIMEOUT_MS = 5 * 60 * 1000; +const SCRIPT_CLEAR_TIMEOUT_MS = 5 * 1000; + +const CSS_EXPIRY_TIMEOUT_MS = 30 * 60 * 1000; + +const scriptCaches = new WeakSet(); +const sheetCacheDocuments = new DefaultWeakMap(() => new WeakSet()); + +class CacheMap extends DefaultMap { + constructor(timeout, getter) { + super(getter); + + this.expiryTimeout = timeout; + + scriptCaches.add(this); + } + + get(url) { + let promise = super.get(url); + + promise.lastUsed = Date.now(); + if (promise.timer) { + promise.timer.cancel(); + } + promise.timer = Timer(this.delete.bind(this, url), + this.expiryTimeout, + Ci.nsITimer.TYPE_ONE_SHOT); + + return promise; + } + + delete(url) { + if (this.has(url)) { + super.get(url).timer.cancel(); + } + + super.delete(url); + } + + clear(timeout = SCRIPT_CLEAR_TIMEOUT_MS) { + let now = Date.now(); + for (let [url, promise] of this.entries()) { + if (now - promise.lastUsed >= timeout) { + this.delete(url); + } + } + } +} + +class ScriptCache extends CacheMap { + constructor(options) { + super(SCRIPT_EXPIRY_TIMEOUT_MS, + url => ChromeUtils.compileScript(url, options)); + } +} + +class CSSCache extends CacheMap { + constructor(sheetType) { + super(CSS_EXPIRY_TIMEOUT_MS, url => { + let uri = Services.io.newURI(url); + return styleSheetService.preloadSheetAsync(uri, sheetType).then(sheet => { + return {url, sheet}; + }); + }); + } + + addDocument(url, document) { + sheetCacheDocuments.get(this.get(url)).add(document); + } + + deleteDocument(url, document) { + sheetCacheDocuments.get(this.get(url)).delete(document); + } + + delete(url) { + if (this.has(url)) { + let promise = this.get(url); + + // Never remove a sheet from the cache if it's still being used by a + // document. Rule processors can be shared between documents with the + // same preloaded sheet, so we only lose by removing them while they're + // still in use. + let docs = ChromeUtils.nondeterministicGetWeakSetKeys(sheetCacheDocuments.get(promise)); + if (docs.length) { + return; + } + } + + super.delete(url); + } +} + +defineLazyGetter(BrowserExtensionContent.prototype, "staticScripts", () => { + return new ScriptCache({hasReturnValue: false}); +}); + +defineLazyGetter(BrowserExtensionContent.prototype, "dynamicScripts", () => { + return new ScriptCache({hasReturnValue: true}); +}); + +defineLazyGetter(BrowserExtensionContent.prototype, "userCSS", () => { + return new CSSCache(Ci.nsIStyleSheetService.USER_SHEET); +}); + +defineLazyGetter(BrowserExtensionContent.prototype, "authorCSS", () => { + return new CSSCache(Ci.nsIStyleSheetService.AUTHOR_SHEET); +}); + +// Represents a content script. +class Script { + constructor(extension, matcher) { + this.extension = extension; + this.matcher = matcher; + + this.runAt = this.matcher.runAt; + this.js = this.matcher.jsPaths; + this.css = this.matcher.cssPaths; + this.removeCSS = this.matcher.removeCSS; + this.cssOrigin = this.matcher.cssOrigin; + + this.cssCache = extension[this.cssOrigin === "user" ? "userCSS" + : "authorCSS"]; + this.scriptCache = extension[matcher.wantReturnValue ? "dynamicScripts" + : "staticScripts"]; + + if (matcher.wantReturnValue) { + this.compileScripts(); + this.loadCSS(); + } + + this.requiresCleanup = !this.removeCss && (this.css.length > 0 || matcher.cssCode); + } + + compileScripts() { + return this.js.map(url => this.scriptCache.get(url)); + } + + loadCSS() { + return this.cssURLs.map(url => this.cssCache.get(url)); + } + + preload() { + this.loadCSS(); + this.compileScripts(); + } + + cleanup(window) { + if (!this.removeCss && this.cssURLs.length) { + let winUtils = getWinUtils(window); + + let type = this.cssOrigin === "user" ? winUtils.USER_SHEET : winUtils.AUTHOR_SHEET; + for (let url of this.cssURLs) { + this.cssCache.deleteDocument(url, window.document); + runSafeSyncWithoutClone(winUtils.removeSheetUsingURIString, url, type); + } + + // Clear any sheets that were kept alive past their timeout as + // a result of living in this document. + this.cssCache.clear(CSS_EXPIRY_TIMEOUT_MS); + } + } + + matchesWindow(window) { + return this.matcher.matchesWindow(window); + } + + async injectInto(window) { + let context = this.extension.getContext(window); + + if (this.runAt === "document_end") { + await promiseDocumentReady(window.document); + } else if (this.runAt === "document_idle") { + await promiseDocumentLoaded(window.document); + } + + return this.inject(context); + } + + /** + * Tries to inject this script into the given window and sandbox, if + * there are pending operations for the window's current load state. + * + * @param {BaseContext} context + * The content script context into which to inject the scripts. + * @returns {Promise} + * Resolves to the last value in the evaluated script, when + * execution is complete. + */ + async inject(context) { + DocumentManager.lazyInit(); + if (this.requiresCleanup) { + context.addScript(this); + } + + let cssPromise; + if (this.cssURLs.length) { + let window = context.contentWindow; + let winUtils = getWinUtils(window); + + let type = this.cssOrigin === "user" ? winUtils.USER_SHEET : winUtils.AUTHOR_SHEET; + + if (this.removeCSS) { + for (let url of this.cssURLs) { + this.cssCache.deleteDocument(url, window.document); + + runSafeSyncWithoutClone(winUtils.removeSheetUsingURIString, url, type); + } + } else { + cssPromise = Promise.all(this.loadCSS()).then(sheets => { + let window = context.contentWindow; + if (!window) { + return; + } + + for (let {url, sheet} of sheets) { + this.cssCache.addDocument(url, window.document); + + runSafeSyncWithoutClone(winUtils.addSheet, sheet, type); + } + }); + } + } + + let scriptsPromise = Promise.all(this.compileScripts()); + + // If we're supposed to inject at the start of the document load, + // and we haven't already missed that point, block further parsing + // until the scripts have been loaded. + let {document} = context.contentWindow; + if (this.runAt === "document_start" && document.readyState !== "complete") { + document.blockParsing(scriptsPromise); + } + + let scripts = await scriptsPromise; + let result; + + if (this.runAt === "document_idle") { + await new Promise(resolve => + context.contentWindow.requestIdleCallback(resolve, + {timeout: idleTimeout})); + } + + // The evaluations below may throw, in which case the promise will be + // automatically rejected. + for (let script of scripts) { + result = script.executeInGlobal(context.cloneScope); + } + + if (this.matcher.jsCode) { + result = Cu.evalInSandbox(this.matcher.jsCode, context.cloneScope, "latest"); + } + + await cssPromise; + return result; + } +} + +defineLazyGetter(Script.prototype, "cssURLs", function() { + // We can handle CSS urls (css) and CSS code (cssCode). + let urls = this.css.slice(); + + if (this.matcher.cssCode) { + urls.push("data:text/css;charset=utf-8," + encodeURIComponent(this.matcher.cssCode)); + } + + return urls; +}); + +/** + * An execution context for semi-privileged extension content scripts. + * + * This is the child side of the ContentScriptContextParent class + * defined in ExtensionParent.jsm. + */ +class ContentScriptContextChild extends BaseContext { + constructor(extension, contentWindow) { + super("content_child", extension); + + this.setContentWindow(contentWindow); + + let frameId = WebNavigationFrames.getFrameId(contentWindow); + this.frameId = frameId; + + this.scripts = []; + + let contentPrincipal = contentWindow.document.nodePrincipal; + let ssm = Services.scriptSecurityManager; + + // Copy origin attributes from the content window origin attributes to + // preserve the user context id. + let attrs = contentPrincipal.originAttributes; + let extensionPrincipal = ssm.createCodebasePrincipal(this.extension.baseURI, attrs); + + this.isExtensionPage = contentPrincipal.equals(extensionPrincipal); + + let principal; + if (ssm.isSystemPrincipal(contentPrincipal)) { + // Make sure we don't hand out the system principal by accident. + // also make sure that the null principal has the right origin attributes + principal = ssm.createNullPrincipal(attrs); + } else if (this.isExtensionPage) { + principal = contentPrincipal; + } else { + principal = [contentPrincipal, extensionPrincipal]; + } + + if (this.isExtensionPage) { + // This is an iframe with content script API enabled and its principal + // should be the contentWindow itself. We create a sandbox with the + // contentWindow as principal and with X-rays disabled because it + // enables us to create the APIs object in this sandbox object and then + // copying it into the iframe's window. See bug 1214658. + this.sandbox = Cu.Sandbox(contentWindow, { + sandboxPrototype: contentWindow, + sameZoneAs: contentWindow, + wantXrays: false, + isWebExtensionContentScript: true, + }); + } else { + // This metadata is required by the Developer Tools, in order for + // the content script to be associated with both the extension and + // the tab holding the content page. + let metadata = { + "inner-window-id": this.innerWindowID, + addonId: extensionPrincipal.addonId, + }; + + this.sandbox = Cu.Sandbox(principal, { + metadata, + sandboxPrototype: contentWindow, + sameZoneAs: contentWindow, + wantXrays: true, + isWebExtensionContentScript: true, + wantExportHelpers: true, + wantGlobalProperties: ["XMLHttpRequest", "fetch"], + originAttributes: attrs, + }); + + Cu.evalInSandbox(` + window.JSON = JSON; + window.XMLHttpRequest = XMLHttpRequest; + window.fetch = fetch; + `, this.sandbox); + } + + Object.defineProperty(this, "principal", { + value: Cu.getObjectPrincipal(this.sandbox), + enumerable: true, + configurable: true, + }); + + this.url = contentWindow.location.href; + + defineLazyGetter(this, "chromeObj", () => { + let chromeObj = Cu.createObjectIn(this.sandbox); + + Schemas.inject(chromeObj, this.childManager); + return chromeObj; + }); + + Schemas.exportLazyGetter(this.sandbox, "browser", () => this.chromeObj); + Schemas.exportLazyGetter(this.sandbox, "chrome", () => this.chromeObj); + } + + injectAPI() { + if (!this.isExtensionPage) { + throw new Error("Cannot inject extension API into non-extension window"); + } + + // This is an iframe with content script API enabled (bug 1214658) + Schemas.exportLazyGetter(this.contentWindow, + "browser", () => this.chromeObj); + Schemas.exportLazyGetter(this.contentWindow, + "chrome", () => this.chromeObj); + } + + get cloneScope() { + return this.sandbox; + } + + addScript(script) { + if (script.requiresCleanup) { + this.scripts.push(script); + } + } + + close() { + super.unload(); + + if (this.contentWindow) { + for (let script of this.scripts) { + script.cleanup(this.contentWindow); + } + + // Overwrite the content script APIs with an empty object if the APIs objects are still + // defined in the content window (bug 1214658). + if (this.isExtensionPage) { + Cu.createObjectIn(this.contentWindow, {defineAs: "browser"}); + Cu.createObjectIn(this.contentWindow, {defineAs: "chrome"}); + } + } + Cu.nukeSandbox(this.sandbox); + this.sandbox = null; + } +} + +defineLazyGetter(ContentScriptContextChild.prototype, "messenger", function() { + // The |sender| parameter is passed directly to the extension. + let sender = {id: this.extension.id, frameId: this.frameId, url: this.url}; + let filter = {extensionId: this.extension.id}; + let optionalFilter = {frameId: this.frameId}; + + return new Messenger(this, [this.messageManager], sender, filter, optionalFilter); +}); + +defineLazyGetter(ContentScriptContextChild.prototype, "childManager", function() { + apiManager.lazyInit(); + + let localApis = {}; + let can = new CanOfAPIs(this, apiManager, localApis); + + let childManager = new ChildAPIManager(this, this.messageManager, can, { + envType: "content_parent", + url: this.url, + }); + + this.callOnClose(childManager); + + return childManager; +}); + +// Responsible for creating ExtensionContexts and injecting content +// scripts into them when new documents are created. +DocumentManager = { + // Map[windowId -> Map[ExtensionChild -> ContentScriptContextChild]] + contexts: new Map(), + + initialized: false, + + lazyInit() { + if (this.initialized) { + return; + } + this.initialized = true; + + Services.obs.addObserver(this, "inner-window-destroyed"); + Services.obs.addObserver(this, "memory-pressure"); + }, + + uninit() { + Services.obs.removeObserver(this, "inner-window-destroyed"); + Services.obs.removeObserver(this, "memory-pressure"); + }, + + observers: { + "inner-window-destroyed"(subject, topic, data) { + let windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + + MessageChannel.abortResponses({innerWindowID: windowId}); + + // Close any existent content-script context for the destroyed window. + if (this.contexts.has(windowId)) { + let extensions = this.contexts.get(windowId); + for (let context of extensions.values()) { + context.close(); + } + + this.contexts.delete(windowId); + } + }, + "memory-pressure"(subject, topic, data) { + let timeout = data === "heap-minimize" ? 0 : undefined; + + for (let cache of ChromeUtils.nondeterministicGetWeakSetKeys(scriptCaches)) { + cache.clear(timeout); + } + }, + }, + + observe(subject, topic, data) { + this.observers[topic].call(this, subject, topic, data); + }, + + shutdownExtension(extension) { + for (let extensions of this.contexts.values()) { + let context = extensions.get(extension); + if (context) { + context.close(); + extensions.delete(extension); + } + } + }, + + getContexts(window) { + let winId = getInnerWindowID(window); + + let extensions = this.contexts.get(winId); + if (!extensions) { + extensions = new Map(); + this.contexts.set(winId, extensions); + } + + return extensions; + }, + + // For test use only. + getContext(extensionId, window) { + for (let [extension, context] of this.getContexts(window)) { + if (extension.id === extensionId) { + return context; + } + } + }, + + getContentScriptGlobals(window) { + let extensions = this.contexts.get(getInnerWindowID(window)); + + if (extensions) { + return Array.from(extensions.values(), ctx => ctx.sandbox); + } + + return []; + }, + + initExtensionContext(extension, window) { + extension.getContext(window).injectAPI(); + }, +}; + +this.ExtensionContent = { + BrowserExtensionContent, + Script, + + shutdownExtension(extension) { + DocumentManager.shutdownExtension(extension); + }, + + // This helper is exported to be integrated in the devtools RDP actors, + // that can use it to retrieve the existent WebExtensions ContentScripts + // of a target window and be able to show the ContentScripts source in the + // DevTools Debugger panel. + getContentScriptGlobals(window) { + return DocumentManager.getContentScriptGlobals(window); + }, + + initExtensionContext(extension, window) { + DocumentManager.initExtensionContext(extension, window); + }, + + getContext(extension, window) { + let extensions = DocumentManager.getContexts(window); + + let context = extensions.get(extension); + if (!context) { + context = new ContentScriptContextChild(extension, window); + extensions.set(extension, context); + } + return context; + }, + + handleExtensionCapture(global, width, height, options) { + let win = global.content; + + const XHTML_NS = "http://www.w3.org/1999/xhtml"; + let canvas = win.document.createElementNS(XHTML_NS, "canvas"); + canvas.width = width; + canvas.height = 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/${options.format}`, options.quality / 100); + }, + + handleDetectLanguage(global, target) { + let doc = target.content.document; + + return promiseDocumentReady(doc).then(() => { + let elem = doc.documentElement; + + let language = (elem.getAttribute("xml:lang") || elem.getAttribute("lang") || + doc.contentLanguage || null); + + // We only want the last element of the TLD here. + // Only country codes have any effect on the results, but other + // values cause no harm. + let tld = doc.location.hostname.match(/[a-z]*$/)[0]; + + // The CLD2 library used by the language detector is capable of + // analyzing raw HTML. Unfortunately, that takes much more memory, + // and since it's hosted by emscripten, and therefore can't shrink + // its heap after it's grown, it has a performance cost. + // So we send plain text instead. + let encoder = new DocumentEncoder(doc, "text/plain", Ci.nsIDocumentEncoder.SkipInvisibleContent); + let text = encoder.encodeToStringWithMaxLength(60 * 1024); + + let encoding = doc.characterSet; + + return LanguageDetector.detectLanguage({language, tld, text, encoding}) + .then(result => result.language === "un" ? "und" : result.language); + }); + }, + + // Used to executeScript, insertCSS and removeCSS. + async handleExtensionExecute(global, target, options, script) { + let executeInWin = (window) => { + if (script.matchesWindow(window)) { + return script.injectInto(window); + } + return null; + }; + + let promises = Array.from(this.enumerateWindows(global.docShell), executeInWin) + .filter(promise => promise); + + if (!promises.length) { + if (options.frame_id) { + return Promise.reject({message: `Frame not found, or missing host permission`}); + } + + let frames = options.all_frames ? ", and any iframes" : ""; + return Promise.reject({message: `Missing host permission for the tab${frames}`}); + } + if (!options.all_frames && promises.length > 1) { + return Promise.reject({message: `Internal error: Script matched multiple windows`}); + } + + let result = await Promise.all(promises); + + 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) { + const {js} = options; + const fileName = js.length ? js[js.length - 1] : ""; + const message = `Script '${fileName}' result is non-structured-clonable data`; + return Promise.reject({message, fileName}); + } + + return result; + }, + + handleWebNavigationGetFrame(global, {frameId}) { + return WebNavigationFrames.getFrame(global.docShell, frameId); + }, + + handleWebNavigationGetAllFrames(global) { + return WebNavigationFrames.getAllFrames(global.docShell); + }, + + // Helpers + + * enumerateWindows(docShell) { + let enum_ = docShell.getDocShellEnumerator(docShell.typeContent, + docShell.ENUMERATE_FORWARDS); + + for (let docShell of XPCOMUtils.IterSimpleEnumerator(enum_, Ci.nsIInterfaceRequestor)) { + yield docShell.getInterface(Ci.nsIDOMWindow); + } + }, +}; diff --git a/toolkit/components/extensions/ExtensionPageChild.jsm b/toolkit/components/extensions/ExtensionPageChild.jsm new file mode 100644 index 0000000000..b1b969a003 --- /dev/null +++ b/toolkit/components/extensions/ExtensionPageChild.jsm @@ -0,0 +1,495 @@ +/* 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/. */ + +"use strict"; + +/* exported ExtensionPageChild */ + +this.EXPORTED_SYMBOLS = ["ExtensionPageChild"]; + +/** + * This file handles privileged extension page logic that runs in the + * child process. + */ + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionChildDevToolsUtils", + "resource://gre/modules/ExtensionChildDevToolsUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Schemas", + "resource://gre/modules/Schemas.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WebNavigationFrames", + "resource://gre/modules/WebNavigationFrames.jsm"); + +const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon"; +const CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS = "webextension-scripts-devtools"; + +Cu.import("resource://gre/modules/ExtensionCommon.jsm"); +Cu.import("resource://gre/modules/ExtensionChild.jsm"); +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); + +const { + defineLazyGetter, + getInnerWindowID, + promiseEvent, +} = ExtensionUtils; + +const { + BaseContext, + CanOfAPIs, + SchemaAPIManager, +} = ExtensionCommon; + +const { + ChildAPIManager, + Messenger, +} = ExtensionChild; + +var ExtensionPageChild; + +var apiManager = new class extends SchemaAPIManager { + constructor() { + super("addon"); + this.initialized = false; + } + + lazyInit() { + if (!this.initialized) { + this.initialized = true; + for (let [/* name */, value] of XPCOMUtils.enumerateCategoryEntries(CATEGORY_EXTENSION_SCRIPTS_ADDON)) { + this.loadScript(value); + } + } + } +}(); + +var devtoolsAPIManager = new class extends SchemaAPIManager { + constructor() { + super("devtools"); + this.initialized = false; + } + + lazyInit() { + if (!this.initialized) { + this.initialized = true; + for (let [/* name */, value] of XPCOMUtils.enumerateCategoryEntries(CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS)) { + this.loadScript(value); + } + } + } +}(); + +class ExtensionBaseContextChild extends BaseContext { + /** + * This ExtensionBaseContextChild represents an addon execution environment + * that is running in an addon or devtools child process. + * + * @param {BrowserExtensionContent} extension This context's owner. + * @param {object} params + * @param {string} params.envType One of "addon_child" or "devtools_child". + * @param {nsIDOMWindow} params.contentWindow The window where the addon runs. + * @param {string} params.viewType One of "background", "popup", "tab", + * "sidebar", "devtools_page" or "devtools_panel". + * @param {number} [params.tabId] This tab's ID, used if viewType is "tab". + */ + constructor(extension, params) { + if (!params.envType) { + throw new Error("Missing envType"); + } + + super(params.envType, extension); + let {viewType, uri, contentWindow, tabId} = params; + this.viewType = viewType; + this.uri = uri || extension.baseURI; + + this.setContentWindow(contentWindow); + + // This is the MessageSender property passed to extension. + let sender = {id: extension.id}; + if (viewType == "tab") { + sender.frameId = WebNavigationFrames.getFrameId(contentWindow); + sender.tabId = tabId; + Object.defineProperty(this, "tabId", + {value: tabId, enumerable: true, configurable: true}); + } + if (uri) { + sender.url = uri.spec; + } + this.sender = sender; + + Schemas.exportLazyGetter(contentWindow, "browser", () => { + let browserObj = Cu.createObjectIn(contentWindow); + Schemas.inject(browserObj, this.childManager); + return browserObj; + }); + + Schemas.exportLazyGetter(contentWindow, "chrome", () => { + let chromeApiWrapper = Object.create(this.childManager); + chromeApiWrapper.isChromeCompat = true; + + let chromeObj = Cu.createObjectIn(contentWindow); + Schemas.inject(chromeObj, chromeApiWrapper); + return chromeObj; + }); + } + + get cloneScope() { + return this.contentWindow; + } + + get principal() { + return this.contentWindow.document.nodePrincipal; + } + + get windowId() { + if (["tab", "popup", "sidebar"].includes(this.viewType)) { + let globalView = ExtensionPageChild.contentGlobals.get(this.messageManager); + return globalView ? globalView.windowId : -1; + } + return -1; + } + + get tabId() { + // Will be overwritten in the constructor if necessary. + return -1; + } + + // Called when the extension shuts down. + shutdown() { + this.unload(); + } + + // This method is called when an extension page navigates away or + // its tab is closed. + unload() { + // Note that without this guard, we end up running unload code + // multiple times for tab pages closed by the "page-unload" handlers + // triggered below. + if (this.unloaded) { + return; + } + + if (this.contentWindow) { + this.contentWindow.close(); + } + + super.unload(); + } +} + +defineLazyGetter(ExtensionBaseContextChild.prototype, "messenger", function() { + let filter = {extensionId: this.extension.id}; + let optionalFilter = {}; + // Addon-generated messages (not necessarily from the same process as the + // addon itself) are sent to the main process, which forwards them via the + // parent process message manager. Specific replies can be sent to the frame + // message manager. + return new Messenger(this, [Services.cpmm, this.messageManager], this.sender, + filter, optionalFilter); +}); + +class ExtensionPageContextChild extends ExtensionBaseContextChild { + /** + * This ExtensionPageContextChild represents a privileged addon + * execution environment that has full access to the WebExtensions + * APIs (provided that the correct permissions have been requested). + * + * This is the child side of the ExtensionPageContextParent class + * defined in ExtensionParent.jsm. + * + * @param {BrowserExtensionContent} extension This context's owner. + * @param {object} params + * @param {nsIDOMWindow} params.contentWindow The window where the addon runs. + * @param {string} params.viewType One of "background", "popup", "sidebar" or "tab". + * "background", "sidebar" and "tab" are used by `browser.extension.getViews`. + * "popup" is only used internally to identify page action and browser + * action popups and options_ui pages. + * @param {number} [params.tabId] This tab's ID, used if viewType is "tab". + */ + constructor(extension, params) { + super(extension, Object.assign(params, {envType: "addon_child"})); + + this.extension.views.add(this); + } + + unload() { + super.unload(); + this.extension.views.delete(this); + } +} + +defineLazyGetter(ExtensionPageContextChild.prototype, "childManager", function() { + apiManager.lazyInit(); + + let localApis = {}; + let can = new CanOfAPIs(this, apiManager, localApis); + + let childManager = new ChildAPIManager(this, this.messageManager, can, { + envType: "addon_parent", + viewType: this.viewType, + url: this.uri.spec, + incognito: this.incognito, + }); + + this.callOnClose(childManager); + + if (this.viewType == "background") { + apiManager.global.initializeBackgroundPage(this.contentWindow); + } + + return childManager; +}); + +class DevToolsContextChild extends ExtensionBaseContextChild { + /** + * This DevToolsContextChild represents a devtools-related addon execution + * environment that has access to the devtools API namespace and to the same subset + * of APIs available in a content script execution environment. + * + * @param {BrowserExtensionContent} extension This context's owner. + * @param {object} params + * @param {nsIDOMWindow} params.contentWindow The window where the addon runs. + * @param {string} params.viewType One of "devtools_page" or "devtools_panel". + * @param {object} [params.devtoolsToolboxInfo] This devtools toolbox's information, + * used if viewType is "devtools_page" or "devtools_panel". + */ + constructor(extension, params) { + super(extension, Object.assign(params, {envType: "devtools_child"})); + + this.devtoolsToolboxInfo = params.devtoolsToolboxInfo; + ExtensionChildDevToolsUtils.initThemeChangeObserver( + params.devtoolsToolboxInfo.themeName, this); + + this.extension.devtoolsViews.add(this); + } + + unload() { + super.unload(); + this.extension.devtoolsViews.delete(this); + } +} + +defineLazyGetter(DevToolsContextChild.prototype, "childManager", function() { + devtoolsAPIManager.lazyInit(); + + let localApis = {}; + let can = new CanOfAPIs(this, devtoolsAPIManager, localApis); + + let childManager = new ChildAPIManager(this, this.messageManager, can, { + envType: "devtools_parent", + viewType: this.viewType, + url: this.uri.spec, + incognito: this.incognito, + }); + + this.callOnClose(childManager); + + return childManager; +}); + +// All subframes in a tab, background page, popup, etc. have the same view type. +// This class keeps track of such global state. +// Note that this is created even for non-extension tabs because at present we +// do not have a way to distinguish regular tabs from extension tabs at the +// initialization of a frame script. +class ContentGlobal { + /** + * @param {nsIContentFrameMessageManager} global The frame script's global. + */ + constructor(global) { + this.global = global; + // Unless specified otherwise assume that the extension page is in a tab, + // because the majority of all class instances are going to be a tab. Any + // special views (background page, extension popup) will immediately send an + // Extension:InitExtensionView message to change the viewType. + this.viewType = "tab"; + this.tabId = -1; + this.windowId = -1; + this.initialized = false; + + this.global.addMessageListener("Extension:InitExtensionView", this); + this.global.addMessageListener("Extension:SetTabAndWindowId", this); + } + + uninit() { + this.global.removeMessageListener("Extension:InitExtensionView", this); + this.global.removeMessageListener("Extension:SetTabAndWindowId", this); + } + + ensureInitialized() { + if (!this.initialized) { + // Request tab and window ID in case "Extension:InitExtensionView" is not + // sent (e.g. when `viewType` is "tab"). + let reply = this.global.sendSyncMessage("Extension:GetTabAndWindowId"); + this.handleSetTabAndWindowId(reply[0] || {}); + } + return this; + } + + receiveMessage({name, data}) { + switch (name) { + case "Extension:InitExtensionView": + // The view type is initialized once and then fixed. + this.global.removeMessageListener("Extension:InitExtensionView", this); + this.viewType = data.viewType; + + // Force external links to open in tabs. + if (["popup", "sidebar"].includes(this.viewType)) { + this.global.docShell.isAppTab = true; + } + + if (data.devtoolsToolboxInfo) { + this.devtoolsToolboxInfo = data.devtoolsToolboxInfo; + } + + promiseEvent(this.global, "DOMContentLoaded", true).then(() => { + let windowId = getInnerWindowID(this.global.content); + let context = ExtensionPageChild.extensionContexts.get(windowId); + + this.global.sendAsyncMessage("Extension:ExtensionViewLoaded", + {childId: context && context.childManager.id}); + }); + + /* FALLTHROUGH */ + case "Extension:SetTabAndWindowId": + this.handleSetTabAndWindowId(data); + break; + } + } + + handleSetTabAndWindowId(data) { + let {tabId, windowId} = data; + + if (tabId) { + // Tab IDs are not expected to change. + if (this.tabId !== -1 && tabId !== this.tabId) { + throw new Error("Attempted to change a tabId after it was set"); + } + this.tabId = tabId; + } + + if (windowId !== undefined) { + // Window IDs may change if a tab is moved to a different location. + // Note: This is the ID of the browser window for the extension API. + // Do not confuse it with the innerWindowID of DOMWindows! + this.windowId = windowId; + } + this.initialized = true; + } +} + +ExtensionPageChild = { + // Map + contentGlobals: new Map(), + + // Map + extensionContexts: new Map(), + + initialized: false, + + _init() { + if (this.initialized) { + return; + } + this.initialized = true; + + Services.obs.addObserver(this, "inner-window-destroyed"); // eslint-ignore-line mozilla/balanced-listeners + }, + + init(global) { + if (!WebExtensionPolicy.isExtensionProcess) { + throw new Error("Cannot init extension page global in current process"); + } + + if (!this.contentGlobals.has(global)) { + this.contentGlobals.set(global, new ContentGlobal(global)); + } + }, + + uninit(global) { + if (this.contentGlobals.has(global)) { + this.contentGlobals.get(global).uninit(); + this.contentGlobals.delete(global); + } + }, + + observe(subject, topic, data) { + if (topic === "inner-window-destroyed") { + let windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + + this.destroyExtensionContext(windowId); + } + }, + + /** + * Create a privileged context at document-element-inserted. + * + * @param {BrowserExtensionContent} extension + * The extension for which the context should be created. + * @param {nsIDOMWindow} contentWindow The global of the page. + */ + initExtensionContext(extension, contentWindow) { + this._init(); + + if (!WebExtensionPolicy.isExtensionProcess) { + throw new Error("Cannot create an extension page context in current process"); + } + + let windowId = getInnerWindowID(contentWindow); + let context = this.extensionContexts.get(windowId); + if (context) { + if (context.extension !== extension) { + throw new Error("A different extension context already exists for this frame"); + } + throw new Error("An extension context was already initialized for this frame"); + } + + let mm = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIContentFrameMessageManager); + + let {viewType, tabId, devtoolsToolboxInfo} = this.contentGlobals.get(mm).ensureInitialized(); + + let uri = contentWindow.document.documentURIObject; + + if (devtoolsToolboxInfo) { + context = new DevToolsContextChild(extension, { + viewType, contentWindow, uri, tabId, devtoolsToolboxInfo, + }); + } else { + context = new ExtensionPageContextChild(extension, {viewType, contentWindow, uri, tabId}); + } + + this.extensionContexts.set(windowId, context); + }, + + /** + * Close the ExtensionPageContextChild belonging to the given window, if any. + * + * @param {number} windowId The inner window ID of the destroyed context. + */ + destroyExtensionContext(windowId) { + let context = this.extensionContexts.get(windowId); + if (context) { + context.unload(); + this.extensionContexts.delete(windowId); + } + }, + + shutdownExtension(extensionId) { + for (let [windowId, context] of this.extensionContexts) { + if (context.extension.id == extensionId) { + context.shutdown(); + this.extensionContexts.delete(windowId); + } + } + }, +}; diff --git a/toolkit/components/extensions/ExtensionParent.jsm b/toolkit/components/extensions/ExtensionParent.jsm new file mode 100644 index 0000000000..a3fa6885a6 --- /dev/null +++ b/toolkit/components/extensions/ExtensionParent.jsm @@ -0,0 +1,1432 @@ +/* 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/. */ +"use strict"; + +/** + * This module contains code for managing APIs that need to run in the + * parent process, and handles the parent side of operations that need + * to be proxied from ExtensionChild.jsm. + */ + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +/* exported ExtensionParent */ + +this.EXPORTED_SYMBOLS = ["ExtensionParent"]; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "E10SUtils", + "resource:///modules/E10SUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "IndexedDB", + "resource://gre/modules/IndexedDB.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel", + "resource://gre/modules/MessageChannel.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NativeApp", + "resource://gre/modules/NativeMessaging.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Schemas", + "resource://gre/modules/Schemas.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "gAddonPolicyService", + "@mozilla.org/addons/policy-service;1", + "nsIAddonPolicyService"); + +Cu.import("resource://gre/modules/ExtensionCommon.jsm"); +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); + +var { + BaseContext, + CanOfAPIs, + SchemaAPIManager, + SpreadArgs, +} = ExtensionCommon; + +var { + DefaultWeakMap, + ExtensionError, + MessageManagerProxy, + defineLazyGetter, + promiseDocumentLoaded, + promiseEvent, + promiseObserved, +} = ExtensionUtils; + +const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json"; +const CATEGORY_EXTENSION_SCHEMAS = "webextension-schemas"; +const CATEGORY_EXTENSION_SCRIPTS = "webextension-scripts"; + +const XUL_URL = "data:application/vnd.mozilla.xul+xml;charset=utf-8," + encodeURI( + ` + `); + +let schemaURLs = new Set(); + +schemaURLs.add("chrome://extensions/content/schemas/experiments.json"); + +let GlobalManager; +let ParentAPIManager; +let ProxyMessenger; + +// This object loads the ext-*.js scripts that define the extension API. +let apiManager = new class extends SchemaAPIManager { + constructor() { + super("main"); + this.initialized = null; + + this.on("startup", (event, extension) => { // eslint-disable-line mozilla/balanced-listeners + let promises = []; + for (let apiName of this.eventModules.get("startup")) { + promises.push(this.asyncGetAPI(apiName, extension).then(api => { + api.onStartup(extension.startupReason); + })); + } + + return Promise.all(promises); + }); + } + + // Loads all the ext-*.js scripts currently registered. + lazyInit() { + if (this.initialized) { + return this.initialized; + } + + let scripts = []; + for (let [/* name */, value] of XPCOMUtils.enumerateCategoryEntries(CATEGORY_EXTENSION_SCRIPTS)) { + scripts.push(value); + } + + let promise = Promise.all(scripts.map(url => ChromeUtils.compileScript(url))).then(scripts => { + for (let script of scripts) { + script.executeInGlobal(this.global); + } + + // Load order matters here. The base manifest defines types which are + // extended by other schemas, so needs to be loaded first. + return Schemas.load(BASE_SCHEMA).then(() => { + let promises = []; + for (let [/* name */, url] of XPCOMUtils.enumerateCategoryEntries(CATEGORY_EXTENSION_SCHEMAS)) { + promises.push(Schemas.load(url)); + } + for (let url of this.schemaURLs) { + promises.push(Schemas.load(url)); + } + for (let url of schemaURLs) { + promises.push(Schemas.load(url)); + } + return Promise.all(promises); + }); + }); + + /* eslint-disable mozilla/balanced-listeners */ + Services.mm.addMessageListener("Extension:GetTabAndWindowId", this); + /* eslint-enable mozilla/balanced-listeners */ + + this.initialized = promise; + return this.initialized; + } + + receiveMessage({name, target, sync}) { + if (name === "Extension:GetTabAndWindowId") { + let result = this.global.tabTracker.getBrowserData(target); + + if (result.tabId) { + if (sync) { + return result; + } + target.messageManager.sendAsyncMessage("Extension:SetTabAndWindowId", result); + } + } + } +}(); + +// Subscribes to messages related to the extension messaging API and forwards it +// to the relevant message manager. The "sender" field for the `onMessage` and +// `onConnect` events are updated if needed. +ProxyMessenger = { + _initialized: false, + + init() { + if (this._initialized) { + return; + } + this._initialized = true; + + // Listen on the global frame message manager because content scripts send + // and receive extension messages via their frame. + // Listen on the parent process message manager because `runtime.connect` + // and `runtime.sendMessage` requests must be delivered to all frames in an + // addon process (by the API contract). + // And legacy addons are not associated with a frame, so that is another + // reason for having a parent process manager here. + let messageManagers = [Services.mm, Services.ppmm]; + + MessageChannel.addListener(messageManagers, "Extension:Connect", this); + MessageChannel.addListener(messageManagers, "Extension:Message", this); + MessageChannel.addListener(messageManagers, "Extension:Port:Disconnect", this); + MessageChannel.addListener(messageManagers, "Extension:Port:PostMessage", this); + }, + + receiveMessage({target, messageName, channelId, sender, recipient, data, responseType}) { + if (recipient.toNativeApp) { + let {childId, toNativeApp} = recipient; + if (messageName == "Extension:Message") { + let context = ParentAPIManager.getContextById(childId); + return new NativeApp(context, toNativeApp).sendMessage(data); + } + if (messageName == "Extension:Connect") { + let context = ParentAPIManager.getContextById(childId); + NativeApp.onConnectNative(context, target.messageManager, data.portId, sender, toNativeApp); + return true; + } + // "Extension:Port:Disconnect" and "Extension:Port:PostMessage" for + // native messages are handled by NativeApp. + return; + } + + let extension = GlobalManager.extensionMap.get(sender.extensionId); + let receiverMM = this.getMessageManagerForRecipient(recipient); + if (!extension || !receiverMM) { + return Promise.reject({ + result: MessageChannel.RESULT_NO_HANDLER, + message: "No matching message handler for the given recipient.", + }); + } + + if ((messageName == "Extension:Message" || + messageName == "Extension:Connect") && + apiManager.global.tabGetSender) { + // From ext-tabs.js, undefined on Android. + apiManager.global.tabGetSender(extension, target, sender); + } + return MessageChannel.sendMessage(receiverMM, messageName, data, { + sender, + recipient, + responseType, + }); + }, + + /** + * @param {object} recipient An object that was passed to + * `MessageChannel.sendMessage`. + * @param {Extension} extension + * @returns {object|null} The message manager matching the recipient if found. + */ + getMessageManagerForRecipient(recipient) { + let {tabId} = recipient; + // tabs.sendMessage / tabs.connect + if (tabId) { + // `tabId` being set implies that the tabs API is supported, so we don't + // need to check whether `tabTracker` exists. + let tab = apiManager.global.tabTracker.getTab(tabId, null); + return tab && (tab.linkedBrowser || tab.browser).messageManager; + } + + // runtime.sendMessage / runtime.connect + let extension = GlobalManager.extensionMap.get(recipient.extensionId); + if (extension) { + return extension.parentMessageManager; + } + + return null; + }, +}; + +// Responsible for loading extension APIs into the right globals. +GlobalManager = { + // Map[extension ID -> Extension]. Determines which extension is + // responsible for content under a particular extension ID. + extensionMap: new Map(), + initialized: false, + + init(extension) { + if (this.extensionMap.size == 0) { + ProxyMessenger.init(); + apiManager.on("extension-browser-inserted", this._onExtensionBrowser); + this.initialized = true; + } + + this.extensionMap.set(extension.id, extension); + }, + + uninit(extension) { + this.extensionMap.delete(extension.id); + + if (this.extensionMap.size == 0 && this.initialized) { + apiManager.off("extension-browser-inserted", this._onExtensionBrowser); + this.initialized = false; + } + }, + + _onExtensionBrowser(type, browser, additionalData = {}) { + browser.messageManager.loadFrameScript(`data:, + Components.utils.import("resource://gre/modules/Services.jsm"); + + Services.obs.notifyObservers(this, "tab-content-frameloader-created", ""); + `, false); + + let viewType = browser.getAttribute("webextension-view-type"); + if (viewType) { + let data = {viewType}; + + let {tabTracker} = apiManager.global; + Object.assign(data, tabTracker.getBrowserData(browser), additionalData); + + browser.messageManager.sendAsyncMessage("Extension:InitExtensionView", + data); + } + }, + + getExtension(extensionId) { + return this.extensionMap.get(extensionId); + }, + + injectInObject(context, isChromeCompat, dest) { + SchemaAPIManager.generateAPIs(context, context.extension.apis, dest); + }, +}; + +/** + * The proxied parent side of a context in ExtensionChild.jsm, for the + * parent side of a proxied API. + */ +class ProxyContextParent extends BaseContext { + constructor(envType, extension, params, xulBrowser, principal) { + super(envType, extension); + + this.uri = NetUtil.newURI(params.url); + + this.incognito = params.incognito; + + this.listenerPromises = new Set(); + + // This message manager is used by ParentAPIManager to send messages and to + // close the ProxyContext if the underlying message manager closes. This + // message manager object may change when `xulBrowser` swaps docshells, e.g. + // when a tab is moved to a different window. + this.messageManagerProxy = new MessageManagerProxy(xulBrowser); + + Object.defineProperty(this, "principal", { + value: principal, enumerable: true, configurable: true, + }); + + this.listenerProxies = new Map(); + + apiManager.emit("proxy-context-load", this); + } + + get cloneScope() { + return this.sandbox; + } + + get xulBrowser() { + return this.messageManagerProxy.eventTarget; + } + + get parentMessageManager() { + return this.messageManagerProxy.messageManager; + } + + shutdown() { + this.unload(); + } + + unload() { + if (this.unloaded) { + return; + } + this.messageManagerProxy.dispose(); + super.unload(); + apiManager.emit("proxy-context-unload", this); + } +} + +defineLazyGetter(ProxyContextParent.prototype, "apiCan", function() { + let obj = {}; + let can = new CanOfAPIs(this, apiManager, obj); + GlobalManager.injectInObject(this, false, obj); + return can; +}); + +defineLazyGetter(ProxyContextParent.prototype, "apiObj", function() { + return this.apiCan.root; +}); + +defineLazyGetter(ProxyContextParent.prototype, "sandbox", function() { + return Cu.Sandbox(this.principal); +}); + +/** + * The parent side of proxied API context for extension content script + * running in ExtensionContent.jsm. + */ +class ContentScriptContextParent extends ProxyContextParent { +} + +/** + * The parent side of proxied API context for extension page, such as a + * background script, a tab page, or a popup, running in + * ExtensionChild.jsm. + */ +class ExtensionPageContextParent extends ProxyContextParent { + constructor(envType, extension, params, xulBrowser) { + super(envType, extension, params, xulBrowser, extension.principal); + + this.viewType = params.viewType; + + extension.emit("extension-proxy-context-load", this); + } + + // The window that contains this context. This may change due to moving tabs. + get xulWindow() { + let win = this.xulBrowser.ownerGlobal; + return win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIDocShellTreeItem).rootTreeItem + .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow); + } + + get currentWindow() { + if (this.viewType !== "background") { + return this.xulWindow; + } + } + + get windowId() { + let {currentWindow} = this; + let {windowTracker} = apiManager.global; + + if (currentWindow && windowTracker) { + return windowTracker.getId(currentWindow); + } + } + + get tabId() { + let {tabTracker} = apiManager.global; + let data = tabTracker.getBrowserData(this.xulBrowser); + if (data.tabId >= 0) { + return data.tabId; + } + } + + onBrowserChange(browser) { + super.onBrowserChange(browser); + this.xulBrowser = browser; + } + + shutdown() { + apiManager.emit("page-shutdown", this); + super.shutdown(); + } +} + +/** + * The parent side of proxied API context for devtools extension page, such as a + * devtools pages and panels running in ExtensionChild.jsm. + */ +class DevToolsExtensionPageContextParent extends ExtensionPageContextParent { + set devToolsToolbox(toolbox) { + if (this._devToolsToolbox) { + throw new Error("Cannot set the context DevTools toolbox twice"); + } + + this._devToolsToolbox = toolbox; + + return toolbox; + } + + get devToolsToolbox() { + return this._devToolsToolbox; + } + + set devToolsTarget(contextDevToolsTarget) { + if (this._devToolsTarget) { + throw new Error("Cannot set the context DevTools target twice"); + } + + this._devToolsTarget = contextDevToolsTarget; + + return contextDevToolsTarget; + } + + get devToolsTarget() { + return this._devToolsTarget; + } + + shutdown() { + if (this._devToolsTarget) { + this._devToolsTarget.destroy(); + this._devToolsTarget = null; + } + + this._devToolsToolbox = null; + + super.shutdown(); + } +} + +ParentAPIManager = { + proxyContexts: new Map(), + + init() { + Services.obs.addObserver(this, "message-manager-close"); + + Services.mm.addMessageListener("API:CreateProxyContext", this); + Services.mm.addMessageListener("API:CloseProxyContext", this, true); + Services.mm.addMessageListener("API:Call", this); + Services.mm.addMessageListener("API:AddListener", this); + Services.mm.addMessageListener("API:RemoveListener", this); + }, + + observe(subject, topic, data) { + if (topic === "message-manager-close") { + let mm = subject; + for (let [childId, context] of this.proxyContexts) { + if (context.parentMessageManager === mm) { + this.closeProxyContext(childId); + } + } + + // Reset extension message managers when their child processes shut down. + for (let extension of GlobalManager.extensionMap.values()) { + if (extension.parentMessageManager === mm) { + extension.parentMessageManager = null; + } + } + } + }, + + shutdownExtension(extensionId) { + for (let [childId, context] of this.proxyContexts) { + if (context.extension.id == extensionId) { + context.shutdown(); + this.proxyContexts.delete(childId); + } + } + }, + + receiveMessage({name, data, target}) { + try { + switch (name) { + case "API:CreateProxyContext": + this.createProxyContext(data, target); + break; + + case "API:CloseProxyContext": + this.closeProxyContext(data.childId); + break; + + case "API:Call": + this.call(data, target); + break; + + case "API:AddListener": + this.addListener(data, target); + break; + + case "API:RemoveListener": + this.removeListener(data); + break; + } + } catch (e) { + Cu.reportError(e); + } + }, + + createProxyContext(data, target) { + let {envType, extensionId, childId, principal} = data; + if (this.proxyContexts.has(childId)) { + throw new Error("A WebExtension context with the given ID already exists!"); + } + + let extension = GlobalManager.getExtension(extensionId); + if (!extension) { + throw new Error(`No WebExtension found with ID ${extensionId}`); + } + + let context; + if (envType == "addon_parent" || envType == "devtools_parent") { + let processMessageManager = (target.messageManager.processMessageManager || + Services.ppmm.getChildAt(0)); + + if (!extension.parentMessageManager) { + let expectedRemoteType = extension.remote ? E10SUtils.EXTENSION_REMOTE_TYPE : null; + if (target.remoteType === expectedRemoteType) { + extension.parentMessageManager = processMessageManager; + } + } + + if (processMessageManager !== extension.parentMessageManager) { + throw new Error("Attempt to create privileged extension parent from incorrect child process"); + } + + if (envType == "addon_parent") { + context = new ExtensionPageContextParent(envType, extension, data, target); + } else if (envType == "devtools_parent") { + context = new DevToolsExtensionPageContextParent(envType, extension, data, target); + } + } else if (envType == "content_parent") { + context = new ContentScriptContextParent(envType, extension, data, target, principal); + } else { + throw new Error(`Invalid WebExtension context envType: ${envType}`); + } + this.proxyContexts.set(childId, context); + }, + + closeProxyContext(childId) { + let context = this.proxyContexts.get(childId); + if (context) { + context.unload(); + this.proxyContexts.delete(childId); + } + }, + + async call(data, target) { + let context = this.getContextById(data.childId); + if (context.parentMessageManager !== target.messageManager) { + throw new Error("Got message on unexpected message manager"); + } + + let reply = result => { + if (!context.parentMessageManager) { + Services.console.logStringMessage("Cannot send function call result: other side closed connection " + + `(call data: ${uneval({path: data.path, args: data.args})})`); + return; + } + + context.parentMessageManager.sendAsyncMessage( + "API:CallResult", + Object.assign({ + childId: data.childId, + callId: data.callId, + }, result)); + }; + + try { + let args = Cu.cloneInto(data.args, context.sandbox); + let fun = await context.apiCan.asyncFindAPIPath(data.path); + let result = fun(...args); + + if (data.callId) { + result = result || Promise.resolve(); + + result.then(result => { + result = result instanceof SpreadArgs ? [...result] : [result]; + + let holder = new StructuredCloneHolder(result); + + reply({result: holder}); + }, error => { + error = context.normalizeError(error); + reply({error: {message: error.message, fileName: error.fileName}}); + }); + } + } catch (e) { + if (data.callId) { + let error = context.normalizeError(e); + reply({error: {message: error.message}}); + } else { + Cu.reportError(e); + } + } + }, + + async addListener(data, target) { + let context = this.getContextById(data.childId); + if (context.parentMessageManager !== target.messageManager) { + throw new Error("Got message on unexpected message manager"); + } + + let {childId} = data; + + function listener(...listenerArgs) { + return context.sendMessage( + context.parentMessageManager, + "API:RunListener", + { + childId, + listenerId: data.listenerId, + path: data.path, + args: new StructuredCloneHolder(listenerArgs), + }, + { + recipient: {childId}, + }); + } + + context.listenerProxies.set(data.listenerId, listener); + + let args = Cu.cloneInto(data.args, context.sandbox); + let promise = context.apiCan.asyncFindAPIPath(data.path); + + // Store pending listener additions so we can be sure they're all + // fully initialize before we consider extension startup complete. + if (context.viewType === "background" && context.listenerPromises) { + const {listenerPromises} = context; + listenerPromises.add(promise); + let remove = () => { listenerPromises.delete(promise); }; + promise.then(remove, remove); + } + + let handler = await promise; + handler.addListener(listener, ...args); + }, + + async removeListener(data) { + let context = this.getContextById(data.childId); + let listener = context.listenerProxies.get(data.listenerId); + + let handler = await context.apiCan.asyncFindAPIPath(data.path); + handler.removeListener(listener); + }, + + getContextById(childId) { + let context = this.proxyContexts.get(childId); + if (!context) { + throw new Error("WebExtension context not found!"); + } + return context; + }, +}; + +ParentAPIManager.init(); + +/** + * This utility class is used to create hidden XUL windows, which are used to + * contains the extension pages that are not visible (e.g. the background page and + * the devtools page), and it is also used by the ExtensionDebuggingUtils to + * contains the browser elements that are used by the addon debugger to be able + * to connect to the devtools actors running in the same process of the target + * extension (and be able to stay connected across the addon reloads). + */ +class HiddenXULWindow { + constructor() { + this._windowlessBrowser = null; + this.waitInitialized = this.initWindowlessBrowser(); + } + + shutdown() { + if (this.unloaded) { + throw new Error("Unable to shutdown an unloaded HiddenXULWindow instance"); + } + + this.unloaded = true; + + this.chromeShell = null; + this.waitInitialized = null; + + this._windowlessBrowser.close(); + this._windowlessBrowser = null; + } + + get chromeDocument() { + return this._windowlessBrowser.document; + } + + /** + * Private helper that create a XULDocument in a windowless browser. + * + * @returns {Promise} + * A promise which resolves to the newly created XULDocument. + */ + async initWindowlessBrowser() { + if (this.waitInitialized) { + throw new Error("HiddenXULWindow already initialized"); + } + + // The invisible page is currently wrapped in a XUL window to fix an issue + // with using the canvas API from a background page (See Bug 1274775). + let windowlessBrowser = Services.appShell.createWindowlessBrowser(true); + this._windowlessBrowser = windowlessBrowser; + + // The windowless browser is a thin wrapper around a docShell that keeps + // its related resources alive. It implements nsIWebNavigation and + // forwards its methods to the underlying docShell, but cannot act as a + // docShell itself. Calling `getInterface(nsIDocShell)` gives us the + // underlying docShell, and `QueryInterface(nsIWebNavigation)` gives us + // access to the webNav methods that are already available on the + // windowless browser, but contrary to appearances, they are not the same + // object. + this.chromeShell = this._windowlessBrowser + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIWebNavigation); + + if (PrivateBrowsingUtils.permanentPrivateBrowsing) { + let attrs = this.chromeShell.getOriginAttributes(); + attrs.privateBrowsingId = 1; + this.chromeShell.setOriginAttributes(attrs); + } + + let system = Services.scriptSecurityManager.getSystemPrincipal(); + this.chromeShell.createAboutBlankContentViewer(system); + this.chromeShell.useGlobalHistory = false; + this.chromeShell.loadURI(XUL_URL, 0, null, null, null); + + await promiseObserved("chrome-document-global-created", + win => win.document == this.chromeShell.document); + return promiseDocumentLoaded(windowlessBrowser.document); + } + + /** + * Creates the browser XUL element that will contain the WebExtension Page. + * + * @param {Object} xulAttributes + * An object that contains the xul attributes to set of the newly + * created browser XUL element. + * + * @returns {Promise} + * A Promise which resolves to the newly created browser XUL element. + */ + async createBrowserElement(xulAttributes) { + if (!xulAttributes || Object.keys(xulAttributes).length === 0) { + throw new Error("missing mandatory xulAttributes parameter"); + } + + await this.waitInitialized; + + const chromeDoc = this.chromeDocument; + + const browser = chromeDoc.createElement("browser"); + browser.setAttribute("type", "content"); + browser.setAttribute("disableglobalhistory", "true"); + + for (const [name, value] of Object.entries(xulAttributes)) { + if (value != null) { + browser.setAttribute(name, value); + } + } + + let awaitFrameLoader = Promise.resolve(); + + if (browser.getAttribute("remote") === "true") { + awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated"); + } + + chromeDoc.documentElement.appendChild(browser); + await awaitFrameLoader; + + return browser; + } +} + + +/** + * This is a base class used by the ext-backgroundPage and ext-devtools API implementations + * to inherits the shared boilerplate code needed to create a parent document for the hidden + * extension pages (e.g. the background page, the devtools page) in the BackgroundPage and + * DevToolsPage classes. + * + * @param {Extension} extension + * The Extension which owns the hidden extension page created (used to decide + * if the hidden extension page parent doc is going to be a windowlessBrowser or + * a visible XUL window). + * @param {string} viewType + * The viewType of the WebExtension page that is going to be loaded + * in the created browser element (e.g. "background" or "devtools_page"). + */ +class HiddenExtensionPage extends HiddenXULWindow { + constructor(extension, viewType) { + if (!extension || !viewType) { + throw new Error("extension and viewType parameters are mandatory"); + } + + super(); + this.extension = extension; + this.viewType = viewType; + this.browser = null; + } + + /** + * Destroy the created parent document. + */ + shutdown() { + if (this.unloaded) { + throw new Error("Unable to shutdown an unloaded HiddenExtensionPage instance"); + } + + if (this.browser) { + this.browser.remove(); + this.browser = null; + } + + super.shutdown(); + } + + /** + * Creates the browser XUL element that will contain the WebExtension Page. + * + * @returns {Promise} + * A Promise which resolves to the newly created browser XUL element. + */ + async createBrowserElement() { + if (this.browser) { + throw new Error("createBrowserElement called twice"); + } + + this.browser = await super.createBrowserElement({ + "webextension-view-type": this.viewType, + "remote": this.extension.remote ? "true" : null, + "remoteType": this.extension.remote ? + E10SUtils.EXTENSION_REMOTE_TYPE : null, + }); + + return this.browser; + } +} + +/** + * This object provides utility functions needed by the devtools actors to + * be able to connect and debug an extension (which can run in the main or in + * a child extension process). + */ +const DebugUtils = { + // A lazily created hidden XUL window, which contains the browser elements + // which are used to connect the webextension patent actor to the extension process. + hiddenXULWindow: null, + + // Map> + debugBrowserPromises: new Map(), + // DefaultWeakMap, Set> + debugActors: new DefaultWeakMap(() => new Set()), + + _extensionUpdatedWatcher: null, + watchExtensionUpdated() { + if (!this._extensionUpdatedWatcher) { + // Watch the updated extension objects. + this._extensionUpdatedWatcher = async (evt, extension) => { + const browserPromise = this.debugBrowserPromises.get(extension.id); + if (browserPromise) { + const browser = await browserPromise; + if (browser.isRemoteBrowser !== extension.remote && + this.debugBrowserPromises.get(extension.id) === browserPromise) { + // If the cached browser element is not anymore of the same + // remote type of the extension, remove it. + this.debugBrowserPromises.delete(extension.id); + browser.remove(); + } + } + }; + + apiManager.on("ready", this._extensionUpdatedWatcher); + } + }, + + unwatchExtensionUpdated() { + if (this._extensionUpdatedWatcher) { + apiManager.off("ready", this._extensionUpdatedWatcher); + delete this._extensionUpdatedWatcher; + } + }, + + + /** + * Retrieve a XUL browser element which has been configured to be able to connect + * the devtools actor with the process where the extension is running. + * + * @param {WebExtensionParentActor} webExtensionParentActor + * The devtools actor that is retrieving the browser element. + * + * @returns {Promise} + * A promise which resolves to the configured browser XUL element. + */ + async getExtensionProcessBrowser(webExtensionParentActor) { + const extensionId = webExtensionParentActor.addonId; + const extension = GlobalManager.getExtension(extensionId); + if (!extension) { + throw new Error(`Extension not found: ${extensionId}`); + } + + const createBrowser = () => { + if (!this.hiddenXULWindow) { + this.hiddenXULWindow = new HiddenXULWindow(); + this.watchExtensionUpdated(); + } + + return this.hiddenXULWindow.createBrowserElement({ + "webextension-addon-debug-target": extensionId, + "remote": extension.remote ? "true" : null, + "remoteType": extension.remote ? + E10SUtils.EXTENSION_REMOTE_TYPE : null, + }); + }; + + let browserPromise = this.debugBrowserPromises.get(extensionId); + + // Create a new promise if there is no cached one in the map. + if (!browserPromise) { + browserPromise = createBrowser(); + this.debugBrowserPromises.set(extensionId, browserPromise); + browserPromise.catch(() => { + this.debugBrowserPromises.delete(extensionId); + }); + } + + this.debugActors.get(browserPromise).add(webExtensionParentActor); + + return browserPromise; + }, + + + /** + * Given the devtools actor that has retrieved an addon debug browser element, + * it destroys the XUL browser element, and it also destroy the hidden XUL window + * if it is not currently needed. + * + * @param {WebExtensionParentActor} webExtensionParentActor + * The devtools actor that has retrieved an addon debug browser element. + */ + async releaseExtensionProcessBrowser(webExtensionParentActor) { + const extensionId = webExtensionParentActor.addonId; + const browserPromise = this.debugBrowserPromises.get(extensionId); + + if (browserPromise) { + const actorsSet = this.debugActors.get(browserPromise); + actorsSet.delete(webExtensionParentActor); + if (actorsSet.size === 0) { + this.debugActors.delete(browserPromise); + this.debugBrowserPromises.delete(extensionId); + await browserPromise.then((browser) => browser.remove()); + } + } + + if (this.debugBrowserPromises.size === 0 && this.hiddenXULWindow) { + this.hiddenXULWindow.shutdown(); + this.hiddenXULWindow = null; + this.unwatchExtensionUpdated(); + } + }, +}; + + +function promiseExtensionViewLoaded(browser) { + return new Promise(resolve => { + browser.messageManager.addMessageListener("Extension:ExtensionViewLoaded", function onLoad({data}) { + browser.messageManager.removeMessageListener("Extension:ExtensionViewLoaded", onLoad); + resolve(data.childId && ParentAPIManager.getContextById(data.childId)); + }); + }); +} + +/** + * This helper is used to subscribe a listener (e.g. in the ext-devtools API implementation) + * to be called for every ExtensionProxyContext created for an extension page given + * its related extension, viewType and browser element (both the top level context and any context + * created for the extension urls running into its iframe descendants). + * + * @param {object} params.extension + * The Extension on which we are going to listen for the newly created ExtensionProxyContext. + * @param {string} params.viewType + * The viewType of the WebExtension page that we are watching (e.g. "background" or + * "devtools_page"). + * @param {XULElement} params.browser + * The browser element of the WebExtension page that we are watching. + * @param {function} onExtensionProxyContextLoaded + * The callback that is called when a new context has been loaded (as `callback(context)`); + * + * @returns {function} + * Unsubscribe the listener. + */ +function watchExtensionProxyContextLoad({extension, viewType, browser}, onExtensionProxyContextLoaded) { + if (typeof onExtensionProxyContextLoaded !== "function") { + throw new Error("Missing onExtensionProxyContextLoaded handler"); + } + + const listener = (event, context) => { + if (context.viewType == viewType && context.xulBrowser == browser) { + onExtensionProxyContextLoaded(context); + } + }; + + extension.on("extension-proxy-context-load", listener); + + return () => { + extension.off("extension-proxy-context-load", listener); + }; +} + +// Used to cache the list of WebExtensionManifest properties defined in the BASE_SCHEMA. +let gBaseManifestProperties = null; + +/** + * Function to obtain the extension name from a moz-extension URI without exposing GlobalManager. + * + * @param {Object} uri The URI for the extension to look up. + * @returns {string} the name of the extension. + */ +function extensionNameFromURI(uri) { + let id = null; + try { + id = gAddonPolicyService.extensionURIToAddonId(uri); + } catch (ex) { + if (ex.name != "NS_ERROR_XPC_BAD_CONVERT_JS") { + Cu.reportError("Extension cannot be found in AddonPolicyService."); + } + } + return GlobalManager.getExtension(id).name; +} + +const INTEGER = /^[1-9]\d*$/; + +// Manages icon details for toolbar buttons in the |pageAction| and +// |browserAction| APIs. +let IconDetails = { + // WeakMap Map object>> + iconCache: new DefaultWeakMap(() => new Map()), + + // Normalizes the various acceptable input formats into an object + // with icon size as key and icon URL as value. + // + // If a context is specified (function is called from an extension): + // Throws an error if an invalid icon size was provided or the + // extension is not allowed to load the specified resources. + // + // If no context is specified, instead of throwing an error, this + // function simply logs a warning message. + normalize(details, extension, context = null) { + if (!details.imageData && typeof details.path === "string") { + let icons = this.iconCache.get(extension); + + let baseURI = context ? context.uri : extension.baseURI; + let url = baseURI.resolve(details.path); + + let icon = icons.get(url); + if (!icon) { + icon = this._normalize(details, extension, context); + icons.set(url, icon); + } + return icon; + } + + return this._normalize(details, extension, context); + }, + + _normalize(details, extension, context = null) { + let result = {}; + + try { + if (details.imageData) { + let imageData = details.imageData; + + if (typeof imageData == "string") { + imageData = {"19": imageData}; + } + + for (let size of Object.keys(imageData)) { + if (!INTEGER.test(size)) { + throw new ExtensionError(`Invalid icon size ${size}, must be an integer`); + } + result[size] = imageData[size]; + } + } + + if (details.path) { + let path = details.path; + if (typeof path != "object") { + path = {"19": path}; + } + + let baseURI = context ? context.uri : extension.baseURI; + + for (let size of Object.keys(path)) { + if (!INTEGER.test(size)) { + throw new ExtensionError(`Invalid icon size ${size}, must be an integer`); + } + + let url = baseURI.resolve(path[size]); + + // The Chrome documentation specifies these parameters as + // relative paths. We currently accept absolute URLs as well, + // which means we need to check that the extension is allowed + // to load them. This will throw an error if it's not allowed. + try { + Services.scriptSecurityManager.checkLoadURIStrWithPrincipal( + extension.principal, url, + Services.scriptSecurityManager.DISALLOW_SCRIPT); + } catch (e) { + throw new ExtensionError(`Illegal URL ${url}`); + } + + result[size] = url; + } + } + } catch (e) { + // Function is called from extension code, delegate error. + if (context) { + throw e; + } + // If there's no context, it's because we're handling this + // as a manifest directive. Log a warning rather than + // raising an error. + extension.manifestError(`Invalid icon data: ${e}`); + } + + return result; + }, + + // Returns the appropriate icon URL for the given icons object and the + // screen resolution of the given window. + getPreferredIcon(icons, extension = null, size = 16) { + const DEFAULT = "chrome://browser/content/extension.svg"; + + let bestSize = null; + if (icons[size]) { + bestSize = size; + } else if (icons[2 * size]) { + bestSize = 2 * size; + } else { + let sizes = Object.keys(icons) + .map(key => parseInt(key, 10)) + .sort((a, b) => a - b); + + bestSize = sizes.find(candidate => candidate > size) || sizes.pop(); + } + + if (bestSize) { + return {size: bestSize, icon: icons[bestSize]}; + } + + return {size, icon: DEFAULT}; + }, + + convertImageURLToDataURL(imageURL, contentWindow, browserWindow, size = 18) { + return new Promise((resolve, reject) => { + let image = new contentWindow.Image(); + image.onload = function() { + let canvas = contentWindow.document.createElement("canvas"); + let ctx = canvas.getContext("2d"); + let dSize = size * browserWindow.devicePixelRatio; + + // Scales the image while maintaing width to height ratio. + // If the width and height differ, the image is centered using the + // smaller of the two dimensions. + let dWidth, dHeight, dx, dy; + if (this.width > this.height) { + dWidth = dSize; + dHeight = image.height * (dSize / image.width); + dx = 0; + dy = (dSize - dHeight) / 2; + } else { + dWidth = image.width * (dSize / image.height); + dHeight = dSize; + dx = (dSize - dWidth) / 2; + dy = 0; + } + + canvas.width = dSize; + canvas.height = dSize; + ctx.drawImage(this, 0, 0, this.width, this.height, dx, dy, dWidth, dHeight); + resolve(canvas.toDataURL("image/png")); + }; + image.onerror = reject; + image.src = imageURL; + }); + }, + + // These URLs should already be properly escaped, but make doubly sure CSS + // string escape characters are escaped here, since they could lead to a + // sandbox break. + escapeUrl(url) { + return url.replace(/[\\\s"]/g, encodeURIComponent); + }, +}; + +let StartupCache = { + DB_NAME: "ExtensionStartupCache", + + SCHEMA_VERSION: 4, + + STORE_NAMES: Object.freeze(["locales", "manifests", "schemas"]), + + dbPromise: null, + + initDB(db) { + for (let name of StartupCache.STORE_NAMES) { + try { + db.deleteObjectStore(name); + } catch (e) { + // Don't worry if the store doesn't already exist. + } + db.createObjectStore(name, {keyPath: "key"}); + } + }, + + clearAddonData(id) { + let range = IDBKeyRange.bound([id], [id, "\uFFFF"]); + + return Promise.all([ + this.locales.delete(range), + this.manifests.delete(range), + ]).catch(e => { + // Ignore the error. It happens when we try to flush the add-on + // data after the AddonManager has flushed the entire startup cache. + this.dbPromise = this.reallyOpen(true).catch(e => {}); + }); + }, + + async reallyOpen(invalidate = false) { + if (this.dbPromise) { + let db = await this.dbPromise; + db.close(); + } + + if (invalidate) { + IndexedDB.deleteDatabase(this.DB_NAME, {storage: "persistent"}); + } + + return IndexedDB.open(this.DB_NAME, + {storage: "persistent", version: this.SCHEMA_VERSION}, + db => this.initDB(db)); + }, + + async open() { + if (!this.dbPromise) { + this.dbPromise = this.reallyOpen(); + } + + return this.dbPromise; + }, + + observe(subject, topic, data) { + if (topic === "startupcache-invalidate") { + this.dbPromise = this.reallyOpen(true).catch(e => {}); + } + }, +}; + +Services.obs.addObserver(StartupCache, "startupcache-invalidate"); + +class CacheStore { + constructor(storeName) { + this.storeName = storeName; + } + + async get(key, createFunc) { + let db; + let result; + try { + db = await StartupCache.open(); + + result = await db.objectStore(this.storeName) + .get(key); + } catch (e) { + Cu.reportError(e); + + return createFunc(key); + } + + if (result === undefined) { + let value = await createFunc(key); + result = {key, value}; + + try { + db.objectStore(this.storeName, "readwrite") + .put(result); + } catch (e) { + Cu.reportError(e); + } + } + + return result && result.value; + } + + async getAll() { + let result = new Map(); + try { + let db = await StartupCache.open(); + + let results = await db.objectStore(this.storeName) + .getAll(); + for (let {key, value} of results) { + result.set(key, value); + } + } catch (e) { + Cu.reportError(e); + } + + return result; + } + + async delete(key) { + let db = await StartupCache.open(); + + return db.objectStore(this.storeName, "readwrite").delete(key); + } +} + +for (let name of StartupCache.STORE_NAMES) { + StartupCache[name] = new CacheStore(name); +} + +var ExtensionParent = { + extensionNameFromURI, + GlobalManager, + HiddenExtensionPage, + IconDetails, + ParentAPIManager, + StartupCache, + WebExtensionPolicy, + apiManager, + get baseManifestProperties() { + if (gBaseManifestProperties) { + return gBaseManifestProperties; + } + + let types = Schemas.schemaJSON.get(BASE_SCHEMA)[0].types; + let manifest = types.find(type => type.id === "WebExtensionManifest"); + if (!manifest) { + throw new Error("Unable to find base manifest properties"); + } + + gBaseManifestProperties = Object.getOwnPropertyNames(manifest.properties); + return gBaseManifestProperties; + }, + promiseExtensionViewLoaded, + watchExtensionProxyContextLoad, + DebugUtils, +}; + +XPCOMUtils.defineLazyGetter(ExtensionParent, "PlatformInfo", () => { + return Object.freeze({ + os: (function() { + let os = AppConstants.platform; + if (os == "macosx") { + os = "mac"; + } + return os; + })(), + arch: (function() { + let abi = Services.appinfo.XPCOMABI; + let [arch] = abi.split("-"); + if (arch == "x86") { + arch = "x86-32"; + } else if (arch == "x86_64") { + arch = "x86-64"; + } + return arch; + })(), + }); +}); + +/** + * Retreives the browser_style stylesheets needed for extension popups and sidebars. + * @returns {Array} an array of stylesheets needed for the current platform. + */ +XPCOMUtils.defineLazyGetter(ExtensionParent, "extensionStylesheets", () => { + let stylesheets = ["chrome://browser/content/extension.css"]; + + if (AppConstants.platform === "macosx") { + stylesheets.push("chrome://browser/content/extension-mac.css"); + } + + return stylesheets; +}); diff --git a/toolkit/components/extensions/ExtensionPermissions.jsm b/toolkit/components/extensions/ExtensionPermissions.jsm new file mode 100644 index 0000000000..cedba72c77 --- /dev/null +++ b/toolkit/components/extensions/ExtensionPermissions.jsm @@ -0,0 +1,128 @@ +"use strict"; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "JSONFile", + "resource://gre/modules/JSONFile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); + +this.EXPORTED_SYMBOLS = ["ExtensionPermissions"]; + +const FILE_NAME = "extension-preferences.json"; + +let prefs; +let _initPromise; +function lazyInit() { + if (!_initPromise) { + prefs = new JSONFile({path: OS.Path.join(OS.Constants.Path.profileDir, FILE_NAME)}); + + _initPromise = prefs.load(); + } + return _initPromise; +} + +function emptyPermissions() { + return {permissions: [], origins: []}; +} + +this.ExtensionPermissions = { + async get(extension) { + await lazyInit(); + + let perms = emptyPermissions(); + if (prefs.data[extension.id]) { + Object.assign(perms, prefs.data[extension.id]); + } + return perms; + }, + + // Add new permissions for the given extension. `permissions` is + // in the format that is passed to browser.permissions.request(). + async add(extension, perms) { + await lazyInit(); + + if (!prefs.data[extension.id]) { + prefs.data[extension.id] = emptyPermissions(); + } + let {permissions, origins} = prefs.data[extension.id]; + + let added = emptyPermissions(); + + for (let perm of perms.permissions) { + if (!permissions.includes(perm)) { + added.permissions.push(perm); + permissions.push(perm); + } + } + + for (let origin of perms.origins) { + origin = new MatchPattern(origin, {ignorePath: true}).pattern; + if (!origins.includes(origin)) { + added.origins.push(origin); + origins.push(origin); + } + } + + if (added.permissions.length > 0 || added.origins.length > 0) { + prefs.saveSoon(); + extension.emit("add-permissions", added); + } + }, + + // Revoke permissions from the given extension. `permissions` is + // in the format that is passed to browser.permissions.remove(). + async remove(extension, perms) { + await lazyInit(); + + if (!prefs.data[extension.id]) { + return; + } + let {permissions, origins} = prefs.data[extension.id]; + + let removed = emptyPermissions(); + + for (let perm of perms.permissions) { + let i = permissions.indexOf(perm); + if (i >= 0) { + removed.permissions.push(perm); + permissions.splice(i, 1); + } + } + + for (let origin of perms.origins) { + origin = new MatchPattern(origin, {ignorePath: true}).pattern; + + let i = origins.indexOf(origin); + if (i >= 0) { + removed.origins.push(origin); + origins.splice(i, 1); + } + } + + if (removed.permissions.length > 0 || removed.origins.length > 0) { + prefs.saveSoon(); + extension.emit("remove-permissions", removed); + } + }, + + async removeAll(extension) { + await lazyInit(); + delete prefs.data[extension.id]; + prefs.saveSoon(); + }, + + // This is meant for tests only + async _uninit() { + if (!_initPromise) { + return; + } + + await _initPromise; + await prefs.finalize(); + prefs = null; + _initPromise = null; + }, +}; diff --git a/toolkit/components/extensions/ExtensionPolicyService.cpp b/toolkit/components/extensions/ExtensionPolicyService.cpp new file mode 100644 index 0000000000..2236408572 --- /dev/null +++ b/toolkit/components/extensions/ExtensionPolicyService.cpp @@ -0,0 +1,417 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#include "mozilla/ExtensionPolicyService.h" +#include "mozilla/extensions/WebExtensionContentScript.h" +#include "mozilla/extensions/WebExtensionPolicy.h" + +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/Preferences.h" +#include "mozilla/Services.h" +#include "mozilla/Unused.h" +#include "mozilla/dom/ContentChild.h" +#include "mozilla/dom/ContentParent.h" +#include "mozIExtensionProcessScript.h" +#include "nsEscape.h" +#include "nsGkAtoms.h" +#include "nsIChannel.h" +#include "nsIContentPolicy.h" +#include "nsIDOMDocument.h" +#include "nsIDocument.h" +#include "nsILoadInfo.h" +#include "nsNetUtil.h" +#include "nsPIDOMWindow.h" +#include "nsXULAppAPI.h" + +namespace mozilla { + +using namespace extensions; + +#define DEFAULT_BASE_CSP \ + "script-src 'self' https://* moz-extension: blob: filesystem: 'unsafe-eval' 'unsafe-inline'; " \ + "object-src 'self' https://* moz-extension: blob: filesystem:;" + +#define DEFAULT_DEFAULT_CSP \ + "script-src 'self'; object-src 'self';" + + +#define OBS_TOPIC_PRELOAD_SCRIPT "web-extension-preload-content-script" +#define OBS_TOPIC_LOAD_SCRIPT "web-extension-load-content-script" + + +static mozIExtensionProcessScript& +ProcessScript() +{ + static nsCOMPtr sProcessScript; + + if (MOZ_UNLIKELY(!sProcessScript)) { + sProcessScript = do_GetService("@mozilla.org/webextensions/extension-process-script;1"); + MOZ_RELEASE_ASSERT(sProcessScript); + ClearOnShutdown(&sProcessScript); + } + return *sProcessScript; +} + +/***************************************************************************** + * ExtensionPolicyService + *****************************************************************************/ + +/* static */ bool ExtensionPolicyService::sRemoteExtensions; + +/* static */ ExtensionPolicyService& +ExtensionPolicyService::GetSingleton() +{ + static RefPtr sExtensionPolicyService; + + if (MOZ_UNLIKELY(!sExtensionPolicyService)) { + sExtensionPolicyService = new ExtensionPolicyService(); + ClearOnShutdown(&sExtensionPolicyService); + } + return *sExtensionPolicyService.get(); +} + +ExtensionPolicyService::ExtensionPolicyService() +{ + mObs = services::GetObserverService(); + MOZ_RELEASE_ASSERT(mObs); + + Preferences::AddBoolVarCache(&sRemoteExtensions, "extensions.webextensions.remote", false); + + RegisterObservers(); +} + + +bool +ExtensionPolicyService::IsExtensionProcess() const +{ + if (sRemoteExtensions && XRE_IsContentProcess()) { + nsAutoCString processName; + dom::ContentChild::GetSingleton()->GetProcessName(processName); + return processName.EqualsLiteral("webextension"); + } + return XRE_IsParentProcess(); +} + + +WebExtensionPolicy* +ExtensionPolicyService::GetByURL(const URLInfo& aURL) +{ + if (aURL.Scheme()->Equals(NS_LITERAL_STRING("moz-extension"))) { + return GetByHost(aURL.Host()); + } + return nullptr; +} + +void +ExtensionPolicyService::GetAll(nsTArray>& aResult) +{ + for (auto iter = mExtensions.Iter(); !iter.Done(); iter.Next()) { + aResult.AppendElement(iter.Data()); + } +} + +bool +ExtensionPolicyService::RegisterExtension(WebExtensionPolicy& aPolicy) +{ + bool ok = (!GetByID(aPolicy.Id()) && + !GetByHost(aPolicy.MozExtensionHostname())); + MOZ_ASSERT(ok); + + if (!ok) { + return false; + } + + mExtensions.Put(aPolicy.Id(), &aPolicy); + mExtensionHosts.Put(aPolicy.MozExtensionHostname(), &aPolicy); + return true; +} + +bool +ExtensionPolicyService::UnregisterExtension(WebExtensionPolicy& aPolicy) +{ + bool ok = (GetByID(aPolicy.Id()) == &aPolicy && + GetByHost(aPolicy.MozExtensionHostname()) == &aPolicy); + MOZ_ASSERT(ok); + + if (!ok) { + return false; + } + + mExtensions.Remove(aPolicy.Id()); + mExtensionHosts.Remove(aPolicy.MozExtensionHostname()); + return true; +} + + +void +ExtensionPolicyService::BaseCSP(nsAString& aBaseCSP) const +{ + nsresult rv; + + rv = Preferences::GetString("extensions.webextensions.base-content-security-policy", &aBaseCSP); + if (NS_FAILED(rv)) { + aBaseCSP.AssignLiteral(DEFAULT_BASE_CSP); + } +} + +void +ExtensionPolicyService::DefaultCSP(nsAString& aDefaultCSP) const +{ + nsresult rv; + + rv = Preferences::GetString("extensions.webextensions.default-content-security-policy", &aDefaultCSP); + if (NS_FAILED(rv)) { + aDefaultCSP.AssignLiteral(DEFAULT_DEFAULT_CSP); + } +} + + +/***************************************************************************** + * Content script management + *****************************************************************************/ + +void +ExtensionPolicyService::RegisterObservers() +{ + mObs->AddObserver(this, "content-document-global-created", false); + mObs->AddObserver(this, "document-element-inserted", false); + if (XRE_IsContentProcess()) { + mObs->AddObserver(this, "http-on-opening-request", false); + } +} + +void +ExtensionPolicyService::UnregisterObservers() +{ + mObs->RemoveObserver(this, "content-document-global-created"); + mObs->RemoveObserver(this, "document-element-inserted"); + if (XRE_IsContentProcess()) { + mObs->RemoveObserver(this, "http-on-opening-request"); + } +} + +nsresult +ExtensionPolicyService::Observe(nsISupports* aSubject, const char* aTopic, const char16_t* aData) +{ + if (!strcmp(aTopic, "content-document-global-created")) { + nsCOMPtr win = do_QueryInterface(aSubject); + if (win) { + CheckWindow(win); + } + } else if (!strcmp(aTopic, "document-element-inserted")) { + nsCOMPtr doc = do_QueryInterface(aSubject); + if (doc) { + CheckDocument(doc); + } + } else if (!strcmp(aTopic, "http-on-opening-request")) { + nsCOMPtr chan = do_QueryInterface(aSubject); + if (chan) { + CheckRequest(chan); + } + } + return NS_OK; +} + +// Checks a request for matching content scripts, and begins pre-loading them +// if necessary. +void +ExtensionPolicyService::CheckRequest(nsIChannel* aChannel) +{ + nsCOMPtr loadInfo = aChannel->GetLoadInfo(); + if (!loadInfo) { + return; + } + + auto loadType = loadInfo->GetExternalContentPolicyType(); + if (loadType != nsIContentPolicy::TYPE_DOCUMENT && + loadType != nsIContentPolicy::TYPE_SUBDOCUMENT) { + return; + } + + nsCOMPtr uri; + if (NS_FAILED(aChannel->GetURI(getter_AddRefs(uri)))) { + return; + } + + CheckContentScripts({uri.get(), loadInfo}, true); +} + +// Checks a document, just after the document element has been inserted, for +// matching content scripts or extension principals, and loads them if +// necessary. +void +ExtensionPolicyService::CheckDocument(nsIDocument* aDocument) +{ + nsCOMPtr win = aDocument->GetWindow(); + if (win) { + if (win->GetDocumentURI()) { + CheckContentScripts(win.get(), false); + } + + nsIPrincipal* principal = aDocument->NodePrincipal(); + + nsAutoString addonId; + Unused << principal->GetAddonId(addonId); + + RefPtr policy = GetByID(addonId); + if (policy) { + nsCOMPtr doc = do_QueryInterface(aDocument); + ProcessScript().InitExtensionDocument(policy, doc); + } + } +} + +// Checks for loads of about:blank into new window globals, and loads any +// matching content scripts. about:blank loads do not trigger document element +// inserted events, so they're the only load type that are special cased this +// way. +void +ExtensionPolicyService::CheckWindow(nsPIDOMWindowOuter* aWindow) +{ + // We only care about non-initial document loads here. The initial + // about:blank document will usually be re-used to load another document. + nsCOMPtr doc = aWindow->GetExtantDoc(); + if (!doc || doc->IsInitialDocument()) { + return; + } + + nsCOMPtr aboutBlank; + NS_ENSURE_SUCCESS_VOID(NS_NewURI(getter_AddRefs(aboutBlank), + "about:blank")); + + nsCOMPtr uri = doc->GetDocumentURI(); + bool equal; + if (!uri || NS_FAILED(uri->EqualsExceptRef(aboutBlank, &equal)) || !equal) { + return; + } + + CheckContentScripts(aWindow, false); +} + +void +ExtensionPolicyService::CheckContentScripts(const DocInfo& aDocInfo, bool aIsPreload) +{ + for (auto iter = mExtensions.Iter(); !iter.Done(); iter.Next()) { + RefPtr policy = iter.Data(); + + for (auto& script : policy->ContentScripts()) { + if (script->Matches(aDocInfo)) { + if (aIsPreload) { + ProcessScript().PreloadContentScript(script); + } else { + ProcessScript().LoadContentScript(script, aDocInfo.GetWindow()); + } + } + } + } +} + + +/***************************************************************************** + * nsIAddonPolicyService + *****************************************************************************/ + +nsresult +ExtensionPolicyService::GetBaseCSP(nsAString& aBaseCSP) +{ + BaseCSP(aBaseCSP); + return NS_OK; +} + +nsresult +ExtensionPolicyService::GetDefaultCSP(nsAString& aDefaultCSP) +{ + DefaultCSP(aDefaultCSP); + return NS_OK; +} + +nsresult +ExtensionPolicyService::GetAddonCSP(const nsAString& aAddonId, + nsAString& aResult) +{ + if (WebExtensionPolicy* policy = GetByID(aAddonId)) { + policy->GetContentSecurityPolicy(aResult); + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult +ExtensionPolicyService::GetGeneratedBackgroundPageUrl(const nsACString& aHostname, + nsACString& aResult) +{ + if (WebExtensionPolicy* policy = GetByHost(aHostname)) { + nsAutoCString url("data:text/html,"); + + nsCString html = policy->BackgroundPageHTML(); + nsAutoCString escaped; + + url.Append(NS_EscapeURL(html, esc_Minimal, escaped)); + + aResult = url; + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult +ExtensionPolicyService::AddonHasPermission(const nsAString& aAddonId, + const nsAString& aPerm, + bool* aResult) +{ + if (WebExtensionPolicy* policy = GetByID(aAddonId)) { + *aResult = policy->HasPermission(aPerm); + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult +ExtensionPolicyService::AddonMayLoadURI(const nsAString& aAddonId, + nsIURI* aURI, + bool* aResult) +{ + if (WebExtensionPolicy* policy = GetByID(aAddonId)) { + *aResult = policy->CanAccessURI(aURI); + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult +ExtensionPolicyService::ExtensionURILoadableByAnyone(nsIURI* aURI, bool* aResult) +{ + URLInfo url(aURI); + if (WebExtensionPolicy* policy = GetByURL(url)) { + *aResult = policy->IsPathWebAccessible(url.FilePath()); + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult +ExtensionPolicyService::ExtensionURIToAddonId(nsIURI* aURI, nsAString& aResult) +{ + if (WebExtensionPolicy* policy = GetByURL(aURI)) { + policy->GetId(aResult); + } else { + aResult.SetIsVoid(true); + } + return NS_OK; +} + + +NS_IMPL_CYCLE_COLLECTION(ExtensionPolicyService, mExtensions, mExtensionHosts) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionPolicyService) + NS_INTERFACE_MAP_ENTRY(nsIAddonPolicyService) + NS_INTERFACE_MAP_ENTRY(nsIObserver) + NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIAddonPolicyService) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionPolicyService) +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionPolicyService) + +} // namespace mozilla diff --git a/toolkit/components/extensions/ExtensionPolicyService.h b/toolkit/components/extensions/ExtensionPolicyService.h new file mode 100644 index 0000000000..ca1326f258 --- /dev/null +++ b/toolkit/components/extensions/ExtensionPolicyService.h @@ -0,0 +1,105 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#ifndef mozilla_ExtensionPolicyService_h +#define mozilla_ExtensionPolicyService_h + +#include "mozilla/extensions/WebExtensionPolicy.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsHashKeys.h" +#include "nsIAddonPolicyService.h" +#include "nsIAtom.h" +#include "nsIObserver.h" +#include "nsIObserverService.h" +#include "nsISupports.h" +#include "nsPointerHashKeys.h" +#include "nsRefPtrHashtable.h" + +class nsIChannel; +class nsIObserverService; +class nsIDocument; +class nsIPIDOMWindowOuter; + +namespace mozilla { +namespace extensions { + class DocInfo; +} + +using extensions::DocInfo; +using extensions::WebExtensionPolicy; + +class ExtensionPolicyService final : public nsIAddonPolicyService + , public nsIObserver +{ +public: + NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(ExtensionPolicyService, + nsIAddonPolicyService) + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_NSIADDONPOLICYSERVICE + NS_DECL_NSIOBSERVER + + static ExtensionPolicyService& GetSingleton(); + + static already_AddRefed GetInstance() + { + return do_AddRef(&GetSingleton()); + } + + WebExtensionPolicy* + GetByID(const nsIAtom* aAddonId) + { + return mExtensions.GetWeak(aAddonId); + } + + WebExtensionPolicy* GetByID(const nsAString& aAddonId) + { + nsCOMPtr atom = NS_AtomizeMainThread(aAddonId); + return GetByID(atom); + } + + WebExtensionPolicy* GetByURL(const extensions::URLInfo& aURL); + + WebExtensionPolicy* GetByHost(const nsACString& aHost) const + { + return mExtensionHosts.GetWeak(aHost); + } + + void GetAll(nsTArray>& aResult); + + bool RegisterExtension(WebExtensionPolicy& aPolicy); + bool UnregisterExtension(WebExtensionPolicy& aPolicy); + + void BaseCSP(nsAString& aDefaultCSP) const; + void DefaultCSP(nsAString& aDefaultCSP) const; + + bool IsExtensionProcess() const; + +protected: + virtual ~ExtensionPolicyService() = default; + +private: + ExtensionPolicyService(); + + void RegisterObservers(); + void UnregisterObservers(); + + void CheckRequest(nsIChannel* aChannel); + void CheckDocument(nsIDocument* aDocument); + void CheckWindow(nsPIDOMWindowOuter* aWindow); + + void CheckContentScripts(const DocInfo& aDocInfo, bool aIsPreload); + + nsRefPtrHashtable, WebExtensionPolicy> mExtensions; + nsRefPtrHashtable mExtensionHosts; + + nsCOMPtr mObs; + + static bool sRemoteExtensions; +}; + +} // namespace mozilla + +#endif // mozilla_ExtensionPolicyService_h diff --git a/toolkit/components/extensions/ExtensionPreferencesManager.jsm b/toolkit/components/extensions/ExtensionPreferencesManager.jsm new file mode 100644 index 0000000000..46035b45d8 --- /dev/null +++ b/toolkit/components/extensions/ExtensionPreferencesManager.jsm @@ -0,0 +1,284 @@ +/* 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/. */ + +/** + * @fileOverview + * This module is used for managing preferences from WebExtension APIs. + * It takes care of the precedence chain and decides whether a preference + * needs to be updated when a change is requested by an API. + * + * It deals with preferences via settings objects, which are objects with + * the following properties: + * + * prefNames: An array of strings, each of which is a preference on + * which the setting depends. + * setCallback: A function that returns an object containing properties and + * values that correspond to the prefs to be set. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["ExtensionPreferencesManager"]; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; +const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {}); + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSettingsStore", + "resource://gre/modules/ExtensionSettingsStore.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Preferences", + "resource://gre/modules/Preferences.jsm"); + +XPCOMUtils.defineLazyGetter(this, "defaultPreferences", function() { + return new Preferences({defaultBranch: true}); +}); + +/* eslint-disable mozilla/balanced-listeners */ +Management.on("shutdown", (type, extension) => { + switch (extension.shutdownReason) { + case "ADDON_DISABLE": + case "ADDON_DOWNGRADE": + case "ADDON_UPGRADE": + this.ExtensionPreferencesManager.disableAll(extension); + break; + + case "ADDON_UNINSTALL": + this.ExtensionPreferencesManager.removeAll(extension); + break; + } +}); + +Management.on("startup", (type, extension) => { + if (["ADDON_ENABLE", "ADDON_UPGRADE", "ADDON_DOWNGRADE"].includes(extension.startupReason)) { + this.ExtensionPreferencesManager.enableAll(extension); + } +}); +/* eslint-enable mozilla/balanced-listeners */ + +const STORE_TYPE = "prefs"; + +// Definitions of settings, each of which correspond to a different API. +let settingsMap = new Map(); + +/** + * This function is passed into the ExtensionSettingsStore to determine the + * initial value of the setting. It reads an array of preference names from + * the this scope, which gets bound to a settings object. + * + * @returns {Object} + * An object with one property per preference, which holds the current + * value of that preference. + */ +function initialValueCallback() { + let initialValue = {}; + for (let pref of this.prefNames) { + initialValue[pref] = Preferences.get(pref); + } + return initialValue; +} + +/** + * Takes an item returned by the ExtensionSettingsStore and conditionally sets + * preferences based on the item's contents. + * + * @param {string} name + * The name of the setting being processed. + * @param {Object|null} item + * Either null, or an object with a value property which indicates the + * value stored for the setting in the settings store. + + * @returns {Promise} + * Resolves to true if the preferences were changed and to false if + * the preferences were not changed. +*/ +async function processItem(name, item) { + if (item) { + let prefs = item.initialValue || await settingsMap.get(name).setCallback(item.value); + for (let pref in prefs) { + if (prefs[pref] === undefined) { + Preferences.reset(pref); + } else { + Preferences.set(pref, prefs[pref]); + } + } + return true; + } + return false; +} + +this.ExtensionPreferencesManager = { + /** + * Adds a setting to the settingsMap. This is how an API tells the + * preferences manager what its setting object is. The preferences + * manager needs to know this when settings need to be removed + * automatically. + * + * @param {string} name The unique id of the setting. + * @param {Object} setting + * A setting object that should have properties for + * prefNames, getCallback and setCallback. + */ + addSetting(name, setting) { + settingsMap.set(name, setting); + }, + + /** + * Gets the default value for a preference. + * + * @param {string} prefName The name of the preference. + * + * @returns {string|number|boolean} The default value of the preference. + */ + getDefaultValue(prefName) { + return defaultPreferences.get(prefName); + }, + + /** + * Indicates that an extension would like to change the value of a previously + * defined setting. + * + * @param {Extension} extension + * The extension for which a setting is being set. + * @param {string} name + * The unique id of the setting. + * @param {any} value + * The value to be stored in the settings store for this + * group of preferences. + * + * @returns {Promise} + * Resolves to true if the preferences were changed and to false if + * the preferences were not changed. + */ + async setSetting(extension, name, value) { + let setting = settingsMap.get(name); + let item = await ExtensionSettingsStore.addSetting( + extension, STORE_TYPE, name, value, initialValueCallback.bind(setting)); + return await processItem(name, item); + }, + + /** + * Indicates that this extension wants to temporarily cede control over the + * given setting. + * + * @param {Extension} extension + * The extension for which a preference setting is being removed. + * @param {string} name + * The unique id of the setting. + * + * @returns {Promise} + * Resolves to true if the preferences were changed and to false if + * the preferences were not changed. + */ + async disableSetting(extension, name) { + let item = await ExtensionSettingsStore.disable( + extension, STORE_TYPE, name); + return await processItem(name, item); + }, + + /** + * Enable a setting that has been disabled. + * + * @param {Extension} extension + * The extension for which a setting is being enabled. + * @param {string} name + * The unique id of the setting. + * + * @returns {Promise} + * Resolves to true if the preferences were changed and to false if + * the preferences were not changed. + */ + async enableSetting(extension, name) { + let item = await ExtensionSettingsStore.enable(extension, STORE_TYPE, name); + return await processItem(name, item); + }, + + /** + * Indicates that this extension no longer wants to set the given setting. + * + * @param {Extension} extension + * The extension for which a preference setting is being removed. + * @param {string} name + * The unique id of the setting. + * + * @returns {Promise} + * Resolves to true if the preferences were changed and to false if + * the preferences were not changed. + */ + async removeSetting(extension, name) { + let item = await ExtensionSettingsStore.removeSetting( + extension, STORE_TYPE, name); + return await processItem(name, item); + }, + + /** + * Disables all previously set settings for an extension. This can be called when + * an extension is being disabled, for example. + * + * @param {Extension} extension + * The extension for which all settings are being unset. + */ + async disableAll(extension) { + let settings = await ExtensionSettingsStore.getAllForExtension(extension, STORE_TYPE); + let disablePromises = []; + for (let name of settings) { + disablePromises.push(this.disableSetting(extension, name)); + } + await Promise.all(disablePromises); + }, + + /** + * Enables all disabled settings for an extension. This can be called when + * an extension has finsihed updating or is being re-enabled, for example. + * + * @param {Extension} extension + * The extension for which all settings are being enabled. + */ + async enableAll(extension) { + let settings = await ExtensionSettingsStore.getAllForExtension(extension, STORE_TYPE); + let enablePromises = []; + for (let name of settings) { + enablePromises.push(this.enableSetting(extension, name)); + } + await Promise.all(enablePromises); + }, + + /** + * Removes all previously set settings for an extension. This can be called when + * an extension is being uninstalled, for example. + * + * @param {Extension} extension + * The extension for which all settings are being unset. + */ + async removeAll(extension) { + let settings = await ExtensionSettingsStore.getAllForExtension(extension, STORE_TYPE); + let removePromises = []; + for (let name of settings) { + removePromises.push(this.removeSetting(extension, name)); + } + await Promise.all(removePromises); + }, + + /** + * Return the levelOfControl for a setting / extension combo. + * This queries the levelOfControl from the ExtensionSettingsStore and also + * takes into account whether any of the setting's preferences are locked. + * + * @param {Extension} extension + * The extension for which levelOfControl is being requested. + * @param {string} name + * The unique id of the setting. + * + * @returns {Promise} + * Resolves to the level of control of the extension over the setting. + */ + async getLevelOfControl(extension, name) { + for (let prefName of settingsMap.get(name).prefNames) { + if (Preferences.locked(prefName)) { + return "not_controllable"; + } + } + return await ExtensionSettingsStore.getLevelOfControl(extension, STORE_TYPE, name); + }, +}; diff --git a/toolkit/components/extensions/ExtensionSettingsStore.jsm b/toolkit/components/extensions/ExtensionSettingsStore.jsm new file mode 100644 index 0000000000..dc373b3ee1 --- /dev/null +++ b/toolkit/components/extensions/ExtensionSettingsStore.jsm @@ -0,0 +1,394 @@ +/* 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/. */ + +/** + * @fileOverview + * This module is used for storing changes to settings that are + * requested by extensions, and for finding out what the current value + * of a setting should be, based on the precedence chain. + * + * When multiple extensions request to make a change to a particular + * setting, the most recently installed extension will be given + * precedence. + * + * This precedence chain of settings is stored in JSON format, + * without indentation, using UTF-8 encoding. + * With indentation applied, the file would look like this: + * + * { + * type: { // The type of settings being stored in this object, i.e., prefs. + * key: { // The unique key for the setting. + * initialValue, // The initial value of the setting. + * precedenceList: [ + * { + * id, // The id of the extension requesting the setting. + * installDate, // The install date of the extension. + * value, // The value of the setting requested by the extension. + * enabled // Whether the setting is currently enabled. + * } + * ], + * }, + * key: { + * // ... + * } + * } + * } + * + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["ExtensionSettingsStore"]; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "JSONFile", + "resource://gre/modules/JSONFile.jsm"); + +const JSON_FILE_NAME = "extension-settings.json"; +const STORE_PATH = OS.Path.join(Services.dirsvc.get("ProfD", Ci.nsIFile).path, JSON_FILE_NAME); + +let _store; + +// Get the internal settings store, which is persisted in a JSON file. +function getStore(type) { + if (!_store) { + let initStore = new JSONFile({ + path: STORE_PATH, + }); + initStore.ensureDataReady(); + _store = initStore; + } + + // Ensure a property exists for the given type. + if (!_store.data[type]) { + _store.data[type] = {}; + } + + return _store; +} + +// Return an object with properties for key and value|initialValue, or null +// if no setting has been stored for that key. +async function getTopItem(type, key) { + let store = getStore(type); + + let keyInfo = store.data[type][key]; + if (!keyInfo) { + return null; + } + + // Find the highest precedence, enabled setting. + for (let item of keyInfo.precedenceList) { + if (item.enabled) { + return {key, value: item.value}; + } + } + + // Nothing found in the precedenceList, return the initialValue. + return {key, initialValue: keyInfo.initialValue}; +} + +// Comparator used when sorting the precedence list. +function precedenceComparator(a, b) { + if (a.enabled && !b.enabled) { + return -1; + } + if (b.enabled && !a.enabled) { + return 1; + } + return b.installDate - a.installDate; +} + +/** + * Helper method that alters a setting, either by changing its enabled status + * or by removing it. + * + * @param {Extension} extension + * The extension for which a setting is being removed/disabled. + * @param {string} type + * The type of setting to be altered. + * @param {string} key + * A string that uniquely identifies the setting. + * @param {string} action + * The action to perform on the setting. + * Will be one of remove|enable|disable. + * + * @returns {object | null} + * Either an object with properties for key and value, which + * corresponds to the current top precedent setting, or null if + * the current top precedent setting has not changed. + */ +async function alterSetting(extension, type, key, action) { + let returnItem; + let store = getStore(type); + + let keyInfo = store.data[type][key]; + if (!keyInfo) { + if (action === "remove") { + return null; + } + throw new Error( + `Cannot alter the setting for ${type}:${key} as it does not exist.`); + } + + let id = extension.id; + let foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id); + + if (foundIndex === -1) { + if (action === "remove") { + return null; + } + throw new Error( + `Cannot alter the setting for ${type}:${key} as it does not exist.`); + } + + switch (action) { + case "remove": + keyInfo.precedenceList.splice(foundIndex, 1); + break; + + case "enable": + keyInfo.precedenceList[foundIndex].enabled = true; + keyInfo.precedenceList.sort(precedenceComparator); + foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id); + break; + + case "disable": + keyInfo.precedenceList[foundIndex].enabled = false; + keyInfo.precedenceList.sort(precedenceComparator); + break; + + default: + throw new Error(`${action} is not a valid action for alterSetting.`); + } + + if (foundIndex === 0) { + returnItem = await getTopItem(type, key); + } + + if (action === "remove" && keyInfo.precedenceList.length === 0) { + delete store.data[type][key]; + } + + store.saveSoon(); + + return returnItem; +} + +this.ExtensionSettingsStore = { + /** + * Adds a setting to the store, possibly returning the current top precedent + * setting. + * + * @param {Extension} extension + * The extension for which a setting is being added. + * @param {string} type + * The type of setting to be stored. + * @param {string} key + * A string that uniquely identifies the setting. + * @param {string} value + * The value to be stored in the setting. + * @param {function} initialValueCallback + * An function to be called to determine the initial value for the + * setting. This will be passed the value in the callbackArgument + * argument. + * @param {any} callbackArgument + * The value to be passed into the initialValueCallback. It defaults to + * the value of the key argument. + * + * @returns {object | null} Either an object with properties for key and + * value, which corresponds to the item that was + * just added, or null if the item that was just + * added does not need to be set because it is not + * at the top of the precedence list. + */ + async addSetting(extension, type, key, value, initialValueCallback, callbackArgument = key) { + if (typeof initialValueCallback != "function") { + throw new Error("initialValueCallback must be a function."); + } + + let id = extension.id; + let store = getStore(type); + + if (!store.data[type][key]) { + // The setting for this key does not exist. Set the initial value. + let initialValue = await initialValueCallback(callbackArgument); + store.data[type][key] = { + initialValue, + precedenceList: [], + }; + } + let keyInfo = store.data[type][key]; + // Check for this item in the precedenceList. + let foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id); + if (foundIndex === -1) { + // No item for this extension, so add a new one. + let addon = await AddonManager.getAddonByID(id); + keyInfo.precedenceList.push({id, installDate: addon.installDate, value, enabled: true}); + } else { + // Item already exists or this extension, so update it. + keyInfo.precedenceList[foundIndex].value = value; + } + + // Sort the list. + keyInfo.precedenceList.sort(precedenceComparator); + + store.saveSoon(); + + // Check whether this is currently the top item. + if (keyInfo.precedenceList[0].id == id) { + return {key, value}; + } + return null; + }, + + /** + * Removes a setting from the store, possibly returning the current top + * precedent setting. + * + * @param {Extension} extension + * The extension for which a setting is being removed. + * @param {string} type + * The type of setting to be removed. + * @param {string} key + * A string that uniquely identifies the setting. + * + * @returns {object | null} + * Either an object with properties for key and value, which + * corresponds to the current top precedent setting, or null if + * the current top precedent setting has not changed. + */ + async removeSetting(extension, type, key) { + return await alterSetting(extension, type, key, "remove"); + }, + + /** + * Enables a setting in the store, possibly returning the current top + * precedent setting. + * + * @param {Extension} extension + * The extension for which a setting is being enabled. + * @param {string} type + * The type of setting to be enabled. + * @param {string} key + * A string that uniquely identifies the setting. + * + * @returns {object | null} + * Either an object with properties for key and value, which + * corresponds to the current top precedent setting, or null if + * the current top precedent setting has not changed. + */ + async enable(extension, type, key) { + return await alterSetting(extension, type, key, "enable"); + }, + + /** + * Disables a setting in the store, possibly returning the current top + * precedent setting. + * + * @param {Extension} extension + * The extension for which a setting is being disabled. + * @param {string} type + * The type of setting to be disabled. + * @param {string} key + * A string that uniquely identifies the setting. + * + * @returns {object | null} + * Either an object with properties for key and value, which + * corresponds to the current top precedent setting, or null if + * the current top precedent setting has not changed. + */ + async disable(extension, type, key) { + return await alterSetting(extension, type, key, "disable"); + }, + + /** + * Retrieves all settings from the store for a given extension. + * + * @param {Extension} extension The extension for which a settings are being retrieved. + * @param {string} type The type of setting to be returned. + * + * @returns {array} A list of settings which have been stored for the extension. + */ + async getAllForExtension(extension, type) { + let store = getStore(type); + + let keysObj = store.data[type]; + let items = []; + for (let key in keysObj) { + if (keysObj[key].precedenceList.find(item => item.id == extension.id)) { + items.push(key); + } + } + return items; + }, + + /** + * Retrieves a setting from the store, returning the current top precedent + * setting for the key. + * + * @param {string} type The type of setting to be returned. + * @param {string} key A string that uniquely identifies the setting. + * + * @returns {object} An object with properties for key and value. + */ + async getSetting(type, key) { + return await getTopItem(type, key); + }, + + /** + * Return the levelOfControl for a key / extension combo. + * levelOfControl is required by Google's ChromeSetting prototype which + * in turn is used by the privacy API among others. + * + * It informs a caller of the state of a setting with respect to the current + * extension, and can be one of the following values: + * + * controlled_by_other_extensions: controlled by extensions with higher precedence + * controllable_by_this_extension: can be controlled by this extension + * controlled_by_this_extension: controlled by this extension + * + * @param {Extension} extension + * The extension for which levelOfControl is being requested. + * @param {string} type + * The type of setting to be returned. For example `pref`. + * @param {string} key + * A string that uniquely identifies the setting, for example, a + * preference name. + * + * @returns {string} + * The level of control of the extension over the key. + */ + async getLevelOfControl(extension, type, key) { + let store = getStore(type); + + let keyInfo = store.data[type][key]; + if (!keyInfo || !keyInfo.precedenceList.length) { + return "controllable_by_this_extension"; + } + + let id = extension.id; + let enabledItems = keyInfo.precedenceList.filter(item => item.enabled); + if (!enabledItems.length) { + return "controllable_by_this_extension"; + } + + let topItem = enabledItems[0]; + if (topItem.id == id) { + return "controlled_by_this_extension"; + } + + let addon = await AddonManager.getAddonByID(id); + return topItem.installDate > addon.installDate ? + "controlled_by_other_extensions" : + "controllable_by_this_extension"; + }, +}; diff --git a/toolkit/components/extensions/ExtensionStorage.jsm b/toolkit/components/extensions/ExtensionStorage.jsm new file mode 100644 index 0000000000..439a1af792 --- /dev/null +++ b/toolkit/components/extensions/ExtensionStorage.jsm @@ -0,0 +1,254 @@ +/* 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/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["ExtensionStorage"]; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown", + "resource://gre/modules/AsyncShutdown.jsm"); + +/** + * Helper function used to sanitize the objects that have to be saved in the ExtensionStorage. + * + * @param {BaseContext} context + * The current extension context. + * @param {string} key + * The key of the current JSON property. + * @param {any} value + * The value of the current JSON property. + * + * @returns {any} + * The sanitized value of the property. + */ +function jsonReplacer(context, key, value) { + switch (typeof(value)) { + // Serialize primitive types as-is. + case "string": + case "number": + case "boolean": + return value; + + case "object": + if (value === null) { + return value; + } + + switch (Cu.getClassName(value, true)) { + // Serialize arrays and ordinary objects as-is. + case "Array": + case "Object": + return value; + + // Serialize Date objects and regular expressions as their + // string representations. + case "Date": + case "RegExp": + return String(value); + } + break; + } + + if (!key) { + // If this is the root object, and we can't serialize it, serialize + // the value to an empty object. + return new context.cloneScope.Object(); + } + + // Everything else, omit entirely. + return undefined; +} + +this.ExtensionStorage = { + cache: new Map(), + listeners: new Map(), + + /** + * Sanitizes the given value, and returns a JSON-compatible + * representation of it, based on the privileges of the given global. + * + * @param {value} value + * The value to sanitize. + * @param {Context} context + * The extension context in which to sanitize the value + * @returns {value} + * The sanitized value. + */ + sanitize(value, context) { + let json = context.jsonStringify(value, jsonReplacer.bind(null, context)); + return JSON.parse(json); + }, + + getExtensionDir(extensionId) { + return OS.Path.join(this.extensionDir, extensionId); + }, + + getStorageFile(extensionId) { + return OS.Path.join(this.extensionDir, extensionId, "storage.js"); + }, + + read(extensionId) { + if (this.cache.has(extensionId)) { + return this.cache.get(extensionId); + } + + let path = this.getStorageFile(extensionId); + let decoder = new TextDecoder(); + let promise = OS.File.read(path); + promise = promise.then(array => { + return JSON.parse(decoder.decode(array)); + }).catch((error) => { + if (!error.becauseNoSuchFile) { + Cu.reportError("Unable to parse JSON data for extension storage."); + } + return {}; + }); + this.cache.set(extensionId, promise); + return promise; + }, + + write(extensionId) { + let promise = this.read(extensionId).then(extData => { + let encoder = new TextEncoder(); + let array = encoder.encode(JSON.stringify(extData)); + let path = this.getStorageFile(extensionId); + OS.File.makeDir(this.getExtensionDir(extensionId), { + ignoreExisting: true, + from: OS.Constants.Path.profileDir, + }); + let promise = OS.File.writeAtomic(path, array); + return promise; + }).catch(() => { + // Make sure this promise is never rejected. + Cu.reportError("Unable to write JSON data for extension storage."); + }); + + AsyncShutdown.profileBeforeChange.addBlocker( + "ExtensionStorage: Finish writing extension data", + promise); + + return promise.then(() => { + AsyncShutdown.profileBeforeChange.removeBlocker(promise); + }); + }, + + set(extensionId, items) { + return this.read(extensionId).then(extData => { + let changes = {}; + for (let prop in items) { + let item = items[prop]; + changes[prop] = {oldValue: extData[prop], newValue: item}; + extData[prop] = item; + } + + this.notifyListeners(extensionId, changes); + + return this.write(extensionId); + }); + }, + + remove(extensionId, items) { + return this.read(extensionId).then(extData => { + let changes = {}; + for (let prop of [].concat(items)) { + changes[prop] = {oldValue: extData[prop]}; + delete extData[prop]; + } + + this.notifyListeners(extensionId, changes); + + return this.write(extensionId); + }); + }, + + clear(extensionId) { + return this.read(extensionId).then(extData => { + let changes = {}; + for (let prop of Object.keys(extData)) { + changes[prop] = {oldValue: extData[prop]}; + delete extData[prop]; + } + + this.notifyListeners(extensionId, changes); + + return this.write(extensionId); + }); + }, + + get(extensionId, keys) { + return this.read(extensionId).then(extData => { + let result = {}; + if (keys === null) { + Object.assign(result, extData); + } else if (typeof(keys) == "object" && !Array.isArray(keys)) { + for (let prop in keys) { + if (prop in extData) { + result[prop] = extData[prop]; + } else { + result[prop] = keys[prop]; + } + } + } else { + for (let prop of [].concat(keys)) { + if (prop in extData) { + result[prop] = extData[prop]; + } + } + } + + return result; + }); + }, + + addOnChangedListener(extensionId, listener) { + let listeners = this.listeners.get(extensionId) || new Set(); + listeners.add(listener); + this.listeners.set(extensionId, listeners); + }, + + removeOnChangedListener(extensionId, listener) { + let listeners = this.listeners.get(extensionId); + listeners.delete(listener); + }, + + notifyListeners(extensionId, changes) { + let listeners = this.listeners.get(extensionId); + if (listeners) { + for (let listener of listeners) { + listener(changes); + } + } + }, + + init() { + if (Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_DEFAULT) { + return; + } + Services.obs.addObserver(this, "extension-invalidate-storage-cache"); + Services.obs.addObserver(this, "xpcom-shutdown"); + }, + + observe(subject, topic, data) { + if (topic == "xpcom-shutdown") { + Services.obs.removeObserver(this, "extension-invalidate-storage-cache"); + Services.obs.removeObserver(this, "xpcom-shutdown"); + } else if (topic == "extension-invalidate-storage-cache") { + this.cache.clear(); + } + }, +}; + +XPCOMUtils.defineLazyGetter(ExtensionStorage, "extensionDir", + () => OS.Path.join(OS.Constants.Path.profileDir, "browser-extension-data")); + +ExtensionStorage.init(); diff --git a/toolkit/components/extensions/ExtensionStorageSync.jsm b/toolkit/components/extensions/ExtensionStorageSync.jsm new file mode 100644 index 0000000000..2fcbf5f81c --- /dev/null +++ b/toolkit/components/extensions/ExtensionStorageSync.jsm @@ -0,0 +1,1269 @@ +/* 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/. */ + +// TODO: +// * find out how the Chrome implementation deals with conflicts + +"use strict"; + +/* exported extensionIdToCollectionId */ + +this.EXPORTED_SYMBOLS = ["ExtensionStorageSync", "extensionStorageSync"]; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; +const global = this; + +Cu.importGlobalProperties(["atob", "btoa"]); + +Cu.import("resource://gre/modules/AppConstants.jsm"); +const KINTO_PROD_SERVER_URL = "https://webextensions.settings.services.mozilla.com/v1"; +const KINTO_DEFAULT_SERVER_URL = KINTO_PROD_SERVER_URL; + +const STORAGE_SYNC_ENABLED_PREF = "webextensions.storage.sync.enabled"; +const STORAGE_SYNC_SERVER_URL_PREF = "webextensions.storage.sync.serverURL"; +const STORAGE_SYNC_SCOPE = "sync:addon_storage"; +const STORAGE_SYNC_CRYPTO_COLLECTION_NAME = "storage-sync-crypto"; +const STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID = "keys"; +const STORAGE_SYNC_CRYPTO_SALT_LENGTH_BYTES = 32; +const FXA_OAUTH_OPTIONS = { + scope: STORAGE_SYNC_SCOPE, +}; +const HISTOGRAM_GET_OPS_SIZE = "STORAGE_SYNC_GET_OPS_SIZE"; +const HISTOGRAM_SET_OPS_SIZE = "STORAGE_SYNC_SET_OPS_SIZE"; +const HISTOGRAM_REMOVE_OPS = "STORAGE_SYNC_REMOVE_OPS"; +const SCALAR_EXTENSIONS_USING = "storage.sync.api.usage.extensions_using"; +const SCALAR_ITEMS_STORED = "storage.sync.api.usage.items_stored"; +const SCALAR_STORAGE_CONSUMED = "storage.sync.api.usage.storage_consumed"; +// Default is 5sec, which seems a bit aggressive on the open internet +const KINTO_REQUEST_TIMEOUT = 30000; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); + + +XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown", + "resource://gre/modules/AsyncShutdown.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "BulkKeyBundle", + "resource://services-sync/keys.js"); +XPCOMUtils.defineLazyModuleGetter(this, "CollectionKeyManager", + "resource://services-sync/record.js"); +XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils", + "resource://services-common/utils.js"); +XPCOMUtils.defineLazyModuleGetter(this, "CryptoUtils", + "resource://services-crypto/utils.js"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorage", + "resource://gre/modules/ExtensionStorage.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts", + "resource://gre/modules/FxAccounts.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "KintoHttpClient", + "resource://services-common/kinto-http-client.js"); +XPCOMUtils.defineLazyModuleGetter(this, "Kinto", + "resource://services-common/kinto-offline-client.js"); +XPCOMUtils.defineLazyModuleGetter(this, "FirefoxAdapter", + "resource://services-common/kinto-storage-adapter.js"); +XPCOMUtils.defineLazyModuleGetter(this, "Log", + "resource://gre/modules/Log.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Observers", + "resource://services-common/observers.js"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Sqlite", + "resource://gre/modules/Sqlite.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Utils", + "resource://services-sync/util.js"); +XPCOMUtils.defineLazyPreferenceGetter(this, "prefPermitsStorageSync", + STORAGE_SYNC_ENABLED_PREF, true); +XPCOMUtils.defineLazyPreferenceGetter(this, "prefStorageSyncServerURL", + STORAGE_SYNC_SERVER_URL_PREF, + KINTO_DEFAULT_SERVER_URL); +XPCOMUtils.defineLazyGetter(this, "WeaveCrypto", function() { + let {WeaveCrypto} = Cu.import("resource://services-crypto/WeaveCrypto.js", {}); + return new WeaveCrypto(); +}); + +const { + runSafeSyncWithoutClone, +} = ExtensionUtils; + +// Map of Extensions to Set to track contexts that are still +// "live" and use storage.sync. +const extensionContexts = new Map(); +// Borrow logger from Sync. +const log = Log.repository.getLogger("Sync.Engine.Extension-Storage"); + +// A global that is fxAccounts, or null if (as on android) fxAccounts +// isn't available. +let _fxaService = null; +if (AppConstants.platform != "android") { + _fxaService = fxAccounts; +} + +class ServerKeyringDeleted extends Error { + constructor() { + super("server keyring appears to have disappeared; we were called to decrypt null"); + } +} + +/** + * Check for FXA and throw an exception if we don't have access. + * + * @param {Object} fxAccounts The reference we were hoping to use to + * access FxA + * @param {string} action The thing we were doing when we decided to + * see if we had access to FxA + */ +function throwIfNoFxA(fxAccounts, action) { + if (!fxAccounts) { + throw new Error(`${action} is impossible because FXAccounts is not available; are you on Android?`); + } +} + +// Global ExtensionStorageSync instance that extensions and Fx Sync use. +// On Android, because there's no FXAccounts instance, any syncing +// operations will fail. +this.extensionStorageSync = null; + +/** + * Utility function to enforce an order of fields when computing an HMAC. + * + * @param {KeyBundle} keyBundle The key bundle to use to compute the HMAC + * @param {string} id The record ID to use when computing the HMAC + * @param {string} IV The IV to use when computing the HMAC + * @param {string} ciphertext The ciphertext over which to compute the HMAC + * @returns {string} The computed HMAC + */ +function ciphertextHMAC(keyBundle, id, IV, ciphertext) { + const hasher = keyBundle.sha256HMACHasher; + return Utils.bytesAsHex(Utils.digestUTF8(id + IV + ciphertext, hasher)); +} + +/** + * Get the current user's hashed kB. + * + * @param {FXAccounts} fxaService The service to use to get the + * current user. + * @returns {string} sha256 of the user's kB as a hex string + */ +const getKBHash = async function(fxaService) { + const signedInUser = await fxaService.getSignedInUser(); + if (!signedInUser) { + throw new Error("User isn't signed in!"); + } + + if (!signedInUser.kB) { + throw new Error("User doesn't have kB??"); + } + + let kBbytes = CommonUtils.hexToBytes(signedInUser.kB); + let hasher = Cc["@mozilla.org/security/hash;1"] + .createInstance(Ci.nsICryptoHash); + hasher.init(hasher.SHA256); + return CommonUtils.bytesAsHex(CryptoUtils.digestBytes(signedInUser.uid + kBbytes, hasher)); +}; + +/** + * A "remote transformer" that the Kinto library will use to + * encrypt/decrypt records when syncing. + * + * This is an "abstract base class". Subclass this and override + * getKeys() to use it. + */ +class EncryptionRemoteTransformer { + async encode(record) { + const keyBundle = await this.getKeys(); + if (record.ciphertext) { + throw new Error("Attempt to reencrypt??"); + } + let id = await this.getEncodedRecordId(record); + if (!id) { + throw new Error("Record ID is missing or invalid"); + } + + let IV = WeaveCrypto.generateRandomIV(); + let ciphertext = WeaveCrypto.encrypt(JSON.stringify(record), + keyBundle.encryptionKeyB64, IV); + let hmac = ciphertextHMAC(keyBundle, id, IV, ciphertext); + const encryptedResult = {ciphertext, IV, hmac, id}; + + // Copy over the _status field, so that we handle concurrency + // headers (If-Match, If-None-Match) correctly. + // DON'T copy over "deleted" status, because then we'd leak + // plaintext deletes. + encryptedResult._status = record._status == "deleted" ? "updated" : record._status; + if (record.hasOwnProperty("last_modified")) { + encryptedResult.last_modified = record.last_modified; + } + + return encryptedResult; + } + + async decode(record) { + if (!record.ciphertext) { + // This can happen for tombstones if a record is deleted. + if (record.deleted) { + return record; + } + throw new Error("No ciphertext: nothing to decrypt?"); + } + const keyBundle = await this.getKeys(); + // Authenticate the encrypted blob with the expected HMAC + let computedHMAC = ciphertextHMAC(keyBundle, record.id, record.IV, record.ciphertext); + + if (computedHMAC != record.hmac) { + Utils.throwHMACMismatch(record.hmac, computedHMAC); + } + + // Handle invalid data here. Elsewhere we assume that cleartext is an object. + let cleartext = WeaveCrypto.decrypt(record.ciphertext, + keyBundle.encryptionKeyB64, record.IV); + let jsonResult = JSON.parse(cleartext); + if (!jsonResult || typeof jsonResult !== "object") { + throw new Error("Decryption failed: result is <" + jsonResult + ">, not an object."); + } + + if (record.hasOwnProperty("last_modified")) { + jsonResult.last_modified = record.last_modified; + } + + // _status: deleted records were deleted on a client, but + // uploaded as an encrypted blob so we don't leak deletions. + // If we get such a record, flag it as deleted. + if (jsonResult._status == "deleted") { + jsonResult.deleted = true; + } + + return jsonResult; + } + + /** + * Retrieve keys to use during encryption. + * + * Returns a Promise. + */ + getKeys() { + throw new Error("override getKeys in a subclass"); + } + + /** + * Compute the record ID to use for the encoded version of the + * record. + * + * The default version just re-uses the record's ID. + * + * @param {Object} record The record being encoded. + * @returns {Promise} The ID to use. + */ + getEncodedRecordId(record) { + return Promise.resolve(record.id); + } +} +global.EncryptionRemoteTransformer = EncryptionRemoteTransformer; + +/** + * An EncryptionRemoteTransformer that provides a keybundle derived + * from the user's kB, suitable for encrypting a keyring. + */ +class KeyRingEncryptionRemoteTransformer extends EncryptionRemoteTransformer { + constructor(fxaService) { + super(); + this._fxaService = fxaService; + } + + getKeys() { + throwIfNoFxA(this._fxaService, "encrypting chrome.storage.sync records"); + const self = this; + return (async function() { + const user = await self._fxaService.getSignedInUser(); + // FIXME: we should permit this if the user is self-hosting + // their storage + if (!user) { + throw new Error("user isn't signed in to FxA; can't sync"); + } + + if (!user.kB) { + throw new Error("user doesn't have kB"); + } + + let kB = Utils.hexToBytes(user.kB); + + let keyMaterial = CryptoUtils.hkdf(kB, undefined, + "identity.mozilla.com/picl/v1/chrome.storage.sync", 2 * 32); + let bundle = new BulkKeyBundle(); + // [encryptionKey, hmacKey] + bundle.keyPair = [keyMaterial.slice(0, 32), keyMaterial.slice(32, 64)]; + return bundle; + })(); + } + // Pass through the kbHash field from the unencrypted record. If + // encryption fails, we can use this to try to detect whether we are + // being compromised or if the record here was encoded with a + // different kB. + async encode(record) { + const encoded = await super.encode(record); + encoded.kbHash = record.kbHash; + return encoded; + } + + async decode(record) { + try { + return await super.decode(record); + } catch (e) { + if (Utils.isHMACMismatch(e)) { + const currentKBHash = await getKBHash(this._fxaService); + if (record.kbHash != currentKBHash) { + // Some other client encoded this with a kB that we don't + // have access to. + KeyRingEncryptionRemoteTransformer.throwOutdatedKB(currentKBHash, record.kbHash); + } + } + throw e; + } + } + + // Generator and discriminator for KB-is-outdated exceptions. + static throwOutdatedKB(shouldBe, is) { + throw new Error(`kB hash on record is outdated: should be ${shouldBe}, is ${is}`); + } + + static isOutdatedKB(exc) { + const kbMessage = "kB hash on record is outdated: "; + return exc && exc.message && exc.message.indexOf && + (exc.message.indexOf(kbMessage) == 0); + } +} +global.KeyRingEncryptionRemoteTransformer = KeyRingEncryptionRemoteTransformer; + +/** + * A Promise that centralizes initialization of ExtensionStorageSync. + * + * This centralizes the use of the Sqlite database, to which there is + * only one connection which is shared by all threads. + * + * Fields in the object returned by this Promise: + * + * - connection: a Sqlite connection. Meant for internal use only. + * - kinto: a KintoBase object, suitable for using in Firefox. All + * collections in this database will use the same Sqlite connection. + */ +const storageSyncInit = (async function() { + const path = "storage-sync.sqlite"; + const opts = {path, sharedMemoryCache: false}; + const connection = await Sqlite.openConnection(opts); + await FirefoxAdapter._init(connection); + return { + connection, + kinto: new Kinto({ + adapter: FirefoxAdapter, + adapterOptions: {sqliteHandle: connection}, + timeout: KINTO_REQUEST_TIMEOUT, + }), + }; +})(); + +AsyncShutdown.profileBeforeChange.addBlocker( + "ExtensionStorageSync: close Sqlite handle", + async function() { + const ret = await storageSyncInit; + const {connection} = ret; + await connection.close(); + } +); +// Kinto record IDs have two condtions: +// +// - They must contain only ASCII alphanumerics plus - and _. To fix +// this, we encode all non-letters using _C_, where C is the +// percent-encoded character, so space becomes _20_ +// and underscore becomes _5F_. +// +// - They must start with an ASCII letter. To ensure this, we prefix +// all keys with "key-". +function keyToId(key) { + function escapeChar(match) { + return "_" + match.codePointAt(0).toString(16).toUpperCase() + "_"; + } + return "key-" + key.replace(/[^a-zA-Z0-9]/g, escapeChar); +} + +// Convert a Kinto ID back into a chrome.storage key. +// Returns null if a key couldn't be parsed. +function idToKey(id) { + function unescapeNumber(match, group1) { + return String.fromCodePoint(parseInt(group1, 16)); + } + // An escaped ID should match this regex. + // An escaped ID should consist of only letters and numbers, plus + // code points escaped as _[0-9a-f]+_. + const ESCAPED_ID_FORMAT = /^(?:[a-zA-Z0-9]|_[0-9A-F]+_)*$/; + + if (!id.startsWith("key-")) { + return null; + } + const unprefixed = id.slice(4); + // Verify that the ID is the correct format. + if (!ESCAPED_ID_FORMAT.test(unprefixed)) { + return null; + } + return unprefixed.replace(/_([0-9A-F]+)_/g, unescapeNumber); +} + +// An "id schema" used to validate Kinto IDs and generate new ones. +const storageSyncIdSchema = { + // We should never generate IDs; chrome.storage only acts as a + // key-value store, so we should always have a key. + generate() { + throw new Error("cannot generate IDs"); + }, + + // See keyToId and idToKey for more details. + validate(id) { + return idToKey(id) !== null; + }, +}; + +// An "id schema" used for the system collection, which doesn't +// require validation or generation of IDs. +const cryptoCollectionIdSchema = { + generate() { + throw new Error("cannot generate IDs for system collection"); + }, + + validate(id) { + return true; + }, +}; + +/** + * Wrapper around the crypto collection providing some handy utilities. + */ +class CryptoCollection { + constructor(fxaService) { + this._fxaService = fxaService; + } + + async getCollection() { + throwIfNoFxA(this._fxaService, "tried to access cryptoCollection"); + const {kinto} = await storageSyncInit; + return kinto.collection(STORAGE_SYNC_CRYPTO_COLLECTION_NAME, { + idSchema: cryptoCollectionIdSchema, + remoteTransformers: [new KeyRingEncryptionRemoteTransformer(this._fxaService)], + }); + } + + /** + * Generate a new salt for use in hashing extension and record + * IDs. + * + * @returns {string} A base64-encoded string of the salt + */ + getNewSalt() { + return btoa(CryptoUtils.generateRandomBytes(STORAGE_SYNC_CRYPTO_SALT_LENGTH_BYTES)); + } + + /** + * Retrieve the keyring record from the crypto collection. + * + * You can use this if you want to check metadata on the keyring + * record rather than use the keyring itself. + * + * The keyring record, if present, should have the structure: + * + * - kbHash: a hash of the user's kB. When this changes, we will + * try to sync the collection. + * - uuid: a record identifier. This will only change when we wipe + * the collection (due to kB getting reset). + * - keys: a "WBO" form of a CollectionKeyManager. + * - salts: a normal JS Object with keys being collection IDs and + * values being base64-encoded salts to use when hashing IDs + * for that collection. + * @returns {Promise} + */ + async getKeyRingRecord() { + const collection = await this.getCollection(); + const cryptoKeyRecord = await collection.getAny(STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID); + + let data = cryptoKeyRecord.data; + if (!data) { + // This is a new keyring. Invent an ID for this record. If this + // changes, it means a client replaced the keyring, so we need to + // reupload everything. + const uuidgen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator); + const uuid = uuidgen.generateUUID().toString(); + data = {uuid, id: STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID}; + } + return data; + } + + async getSalts() { + const cryptoKeyRecord = await this.getKeyRingRecord(); + return cryptoKeyRecord && cryptoKeyRecord.salts; + } + + /** + * Used for testing with a known salt. + * + * @param {string} extensionId The extension ID for which to set a + * salt. + * @param {string} salt The salt to use for this extension, as a + * base64-encoded salt. + */ + async _setSalt(extensionId, salt) { + const cryptoKeyRecord = await this.getKeyRingRecord(); + cryptoKeyRecord.salts = cryptoKeyRecord.salts || {}; + cryptoKeyRecord.salts[extensionId] = salt; + await this.upsert(cryptoKeyRecord); + } + + /** + * Hash an extension ID for a given user so that an attacker can't + * identify the extensions a user has installed. + * + * The extension ID is assumed to be a string (i.e. series of + * code points), and its UTF8 encoding is prefixed with the salt + * for that collection and hashed. + * + * The returned hash must conform to the syntax for Kinto + * identifiers, which (as of this writing) must match + * [a-zA-Z0-9][a-zA-Z0-9_-]*. We thus encode the hash using + * "base64-url" without padding (so that we don't get any equals + * signs (=)). For fear that a hash could start with a hyphen + * (-) or an underscore (_), prefix it with "ext-". + * + * @param {string} extensionId The extension ID to obfuscate. + * @returns {Promise} A collection ID suitable for use to sync to. + */ + extensionIdToCollectionId(extensionId) { + return this.hashWithExtensionSalt(CommonUtils.encodeUTF8(extensionId), extensionId) + .then(hash => `ext-${hash}`); + } + + /** + * Hash some value with the salt for the given extension. + * + * The value should be a "bytestring", i.e. a string whose + * "characters" are values, each within [0, 255]. You can produce + * such a bytestring using e.g. CommonUtils.encodeUTF8. + * + * The returned value is a base64url-encoded string of the hash. + * + * @param {bytestring} value The value to be hashed. + * @param {string} extensionId The ID of the extension whose salt + * we should use. + * @returns {Promise} The hashed value. + */ + async hashWithExtensionSalt(value, extensionId) { + const salts = await this.getSalts(); + const saltBase64 = salts && salts[extensionId]; + if (!saltBase64) { + // This should never happen; salts should be populated before + // we need them by ensureCanSync. + throw new Error(`no salt available for ${extensionId}; how did this happen?`); + } + + const hasher = Cc["@mozilla.org/security/hash;1"] + .createInstance(Ci.nsICryptoHash); + hasher.init(hasher.SHA256); + + const salt = atob(saltBase64); + const message = `${salt}\x00${value}`; + const hash = CryptoUtils.digestBytes(message, hasher); + return CommonUtils.encodeBase64URL(hash, false); + } + + /** + * Retrieve the actual keyring from the crypto collection. + * + * @returns {Promise} + */ + async getKeyRing() { + const cryptoKeyRecord = await this.getKeyRingRecord(); + const collectionKeys = new CollectionKeyManager(); + if (cryptoKeyRecord.keys) { + collectionKeys.setContents(cryptoKeyRecord.keys, cryptoKeyRecord.last_modified); + } else { + // We never actually use the default key, so it's OK if we + // generate one multiple times. + collectionKeys.generateDefaultKey(); + } + // Pass through uuid field so that we can save it if we need to. + collectionKeys.uuid = cryptoKeyRecord.uuid; + return collectionKeys; + } + + async updateKBHash(kbHash) { + const coll = await this.getCollection(); + await coll.update({id: STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID, + kbHash: kbHash}, + {patch: true}); + } + + async upsert(record) { + const collection = await this.getCollection(); + await collection.upsert(record); + } + + async sync(extensionStorageSync) { + const collection = await this.getCollection(); + return await extensionStorageSync._syncCollection(collection, { + strategy: "server_wins", + }); + } + + /** + * Reset sync status for ALL collections by directly + * accessing the FirefoxAdapter. + */ + async resetSyncStatus() { + const coll = await this.getCollection(); + await coll.db.resetSyncStatus(); + } + + // Used only for testing. + async _clear() { + const collection = await this.getCollection(); + await collection.clear(); + } +} +this.CryptoCollection = CryptoCollection; + +/** + * An EncryptionRemoteTransformer for extension records. + * + * It uses the special "keys" record to find a key for a given + * extension, thus its name + * CollectionKeyEncryptionRemoteTransformer. + * + * Also, during encryption, it will replace the ID of the new record + * with a hashed ID, using the salt for this collection. + * + * @param {string} extensionId The extension ID for which to find a key. + */ +let CollectionKeyEncryptionRemoteTransformer = class extends EncryptionRemoteTransformer { + constructor(cryptoCollection, extensionId) { + super(); + this.cryptoCollection = cryptoCollection; + this.extensionId = extensionId; + } + + async getKeys() { + // FIXME: cache the crypto record for the duration of a sync cycle? + const collectionKeys = await this.cryptoCollection.getKeyRing(); + if (!collectionKeys.hasKeysFor([this.extensionId])) { + // This should never happen. Keys should be created (and + // synced) at the beginning of the sync cycle. + throw new Error(`tried to encrypt records for ${this.extensionId}, but key is not present`); + } + return collectionKeys.keyForCollection(this.extensionId); + } + + getEncodedRecordId(record) { + // It isn't really clear whether kinto.js record IDs are + // bytestrings or strings that happen to only contain ASCII + // characters, so encode them to be sure. + const id = CommonUtils.encodeUTF8(record.id); + // Like extensionIdToCollectionId, the rules about Kinto record + // IDs preclude equals signs or strings starting with a + // non-alphanumeric, so prefix all IDs with a constant "id-". + return this.cryptoCollection.hashWithExtensionSalt(id, this.extensionId) + .then(hash => `id-${hash}`); + } +}; + +global.CollectionKeyEncryptionRemoteTransformer = CollectionKeyEncryptionRemoteTransformer; + +/** + * Clean up now that one context is no longer using this extension's collection. + * + * @param {Extension} extension + * The extension whose context just ended. + * @param {Context} context + * The context that just ended. + */ +function cleanUpForContext(extension, context) { + const contexts = extensionContexts.get(extension); + if (!contexts) { + Cu.reportError(new Error(`Internal error: cannot find any contexts for extension ${extension.id}`)); + } + contexts.delete(context); + if (contexts.size === 0) { + // Nobody else is using this collection. Clean up. + extensionContexts.delete(extension); + } +} + +/** + * Generate a promise that produces the Collection for an extension. + * + * @param {CryptoCollection} cryptoCollection + * @param {Extension} extension + * The extension whose collection needs to + * be opened. + * @param {Context} context + * The context for this extension. The Collection + * will shut down automatically when all contexts + * close. + * @returns {Promise} + */ +const openCollection = async function(cryptoCollection, extension, context) { + let collectionId = extension.id; + const {kinto} = await storageSyncInit; + const remoteTransformers = [new CollectionKeyEncryptionRemoteTransformer(cryptoCollection, extension.id)]; + const coll = kinto.collection(collectionId, { + idSchema: storageSyncIdSchema, + remoteTransformers, + }); + return coll; +}; + +class ExtensionStorageSync { + /** + * @param {FXAccounts} fxaService (Optional) If not + * present, trying to sync will fail. + * @param {nsITelemetry} telemetry Telemetry service to use to + * report sync usage. + */ + constructor(fxaService, telemetry) { + this._fxaService = fxaService; + this._telemetry = telemetry; + this.cryptoCollection = new CryptoCollection(fxaService); + this.listeners = new WeakMap(); + } + + async syncAll() { + const extensions = extensionContexts.keys(); + const extIds = Array.from(extensions, extension => extension.id); + log.debug(`Syncing extension settings for ${JSON.stringify(extIds)}`); + if (extIds.length == 0) { + // No extensions to sync. Get out. + return; + } + await this.ensureCanSync(extIds); + await this.checkSyncKeyRing(); + const promises = Array.from(extensionContexts.keys(), extension => { + return openCollection(this.cryptoCollection, extension).then(coll => { + return this.sync(extension, coll); + }); + }); + await Promise.all(promises); + + // This needs access to an adapter, but any adapter will do + const collection = await this.cryptoCollection.getCollection(); + const storage = await collection.db.calculateStorage(); + this._telemetry.scalarSet(SCALAR_EXTENSIONS_USING, storage.length); + for (let {collectionName, size, numRecords} of storage) { + this._telemetry.keyedScalarSet(SCALAR_ITEMS_STORED, collectionName, numRecords); + this._telemetry.keyedScalarSet(SCALAR_STORAGE_CONSUMED, collectionName, size); + } + } + + async sync(extension, collection) { + throwIfNoFxA(this._fxaService, "syncing chrome.storage.sync"); + const signedInUser = await this._fxaService.getSignedInUser(); + if (!signedInUser) { + // FIXME: this should support syncing to self-hosted + log.info("User was not signed into FxA; cannot sync"); + throw new Error("Not signed in to FxA"); + } + const collectionId = await this.cryptoCollection.extensionIdToCollectionId(extension.id); + let syncResults; + try { + syncResults = await this._syncCollection(collection, { + strategy: "client_wins", + collection: collectionId, + }); + } catch (err) { + log.warn("Syncing failed", err); + throw err; + } + + let changes = {}; + for (const record of syncResults.created) { + changes[record.key] = { + newValue: record.data, + }; + } + for (const record of syncResults.updated) { + // N.B. It's safe to just pick old.key because it's not + // possible to "rename" a record in the storage.sync API. + const key = record.old.key; + changes[key] = { + oldValue: record.old.data, + newValue: record.new.data, + }; + } + for (const record of syncResults.deleted) { + changes[record.key] = { + oldValue: record.data, + }; + } + for (const resolution of syncResults.resolved) { + // FIXME: We can't send a "changed" notification because + // kinto.js only provides the newly-resolved value. But should + // we even send a notification? We use CLIENT_WINS so nothing + // has really "changed" on this end. (The change will come on + // the other end when it pulls down the update, which is handled + // by the "updated" case above.) If we are going to send a + // notification, what best values for "old" and "new"? This + // might violate client code's assumptions, since from their + // perspective, we were in state L, but this diff is from R -> + // L. + const accepted = resolution.accepted; + changes[accepted.key] = { + newValue: accepted.data, + }; + } + if (Object.keys(changes).length > 0) { + this.notifyListeners(extension, changes); + } + } + + /** + * Utility function that handles the common stuff about syncing all + * Kinto collections (including "meta" collections like the crypto + * one). + * + * @param {Collection} collection + * @param {Object} options + * Additional options to be passed to sync(). + * @returns {Promise} + */ + async _syncCollection(collection, options) { + // FIXME: this should support syncing to self-hosted + return await this._requestWithToken(`Syncing ${collection.name}`, async function(token) { + const allOptions = Object.assign({}, { + remote: prefStorageSyncServerURL, + headers: { + Authorization: "Bearer " + token, + }, + }, options); + + return await collection.sync(allOptions); + }); + } + + // Make a Kinto request with a current FxA token. + // If the response indicates that the token might have expired, + // retry the request. + async _requestWithToken(description, f) { + throwIfNoFxA(this._fxaService, "making remote requests from chrome.storage.sync"); + const fxaToken = await this._fxaService.getOAuthToken(FXA_OAUTH_OPTIONS); + try { + return await f(fxaToken); + } catch (e) { + log.error(`${description}: request failed`, e); + if (e && e.response && e.response.status == 401) { + // Our token might have expired. Refresh and retry. + log.info("Token might have expired"); + await this._fxaService.removeCachedOAuthToken({token: fxaToken}); + const newToken = await this._fxaService.getOAuthToken(FXA_OAUTH_OPTIONS); + + // If this fails too, let it go. + return await f(newToken); + } + // Otherwise, we don't know how to handle this error, so just reraise. + throw e; + } + } + + /** + * Helper similar to _syncCollection, but for deleting the user's bucket. + * + * @returns {Promise} + */ + async _deleteBucket() { + log.error("Deleting default bucket and everything in it"); + return await this._requestWithToken("Clearing server", async function(token) { + const headers = {Authorization: "Bearer " + token}; + const kintoHttp = new KintoHttpClient(prefStorageSyncServerURL, { + headers: headers, + timeout: KINTO_REQUEST_TIMEOUT, + }); + return await kintoHttp.deleteBucket("default"); + }); + } + + async ensureSaltsFor(keysRecord, extIds) { + const newSalts = Object.assign({}, keysRecord.salts); + for (let collectionId of extIds) { + if (newSalts[collectionId]) { + continue; + } + + newSalts[collectionId] = this.cryptoCollection.getNewSalt(); + } + + return newSalts; + } + + /** + * Check whether the keys record (provided) already has salts for + * all the extensions given in extIds. + * + * @param {Object} keysRecord A previously-retrieved keys record. + * @param {Array} extIds The IDs of the extensions which + * need salts. + * @returns {boolean} + */ + hasSaltsFor(keysRecord, extIds) { + if (!keysRecord.salts) { + return false; + } + + for (let collectionId of extIds) { + if (!keysRecord.salts[collectionId]) { + return false; + } + } + + return true; + } + + /** + * Recursive promise that terminates when our local collectionKeys, + * as well as that on the server, have keys for all the extensions + * in extIds. + * + * @param {Array} extIds + * The IDs of the extensions which need keys. + * @returns {Promise} + */ + async ensureCanSync(extIds) { + const keysRecord = await this.cryptoCollection.getKeyRingRecord(); + const collectionKeys = await this.cryptoCollection.getKeyRing(); + if (collectionKeys.hasKeysFor(extIds) && this.hasSaltsFor(keysRecord, extIds)) { + return collectionKeys; + } + + log.info(`Need to create keys and/or salts for ${JSON.stringify(extIds)}`); + const kbHash = await getKBHash(this._fxaService); + const newKeys = await collectionKeys.ensureKeysFor(extIds); + const newSalts = await this.ensureSaltsFor(keysRecord, extIds); + const newRecord = { + id: STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID, + keys: newKeys.asWBO().cleartext, + salts: newSalts, + uuid: collectionKeys.uuid, + // Add a field for the current kB hash. + kbHash: kbHash, + }; + await this.cryptoCollection.upsert(newRecord); + const result = await this._syncKeyRing(newRecord); + if (result.resolved.length != 0) { + // We had a conflict which was automatically resolved. We now + // have a new keyring which might have keys for the + // collections. Recurse. + return await this.ensureCanSync(extIds); + } + + // No conflicts. We're good. + return newKeys; + } + + /** + * Update the kB in the crypto record. + */ + async updateKeyRingKB() { + throwIfNoFxA(this._fxaService, "use of chrome.storage.sync \"keyring\""); + const signedInUser = await this._fxaService.getSignedInUser(); + if (!signedInUser) { + // Although this function is meant to be called on login, + // it's not unreasonable to check any time, even if we aren't + // logged in. + // + // If we aren't logged in, we don't have any information about + // the user's kB, so we can't be sure that the user changed + // their kB, so just return. + return; + } + + const thisKBHash = await getKBHash(this._fxaService); + await this.cryptoCollection.updateKBHash(thisKBHash); + } + + /** + * Make sure the keyring is up to date and synced. + * + * This is called on syncs to make sure that we don't sync anything + * to any collection unless the key for that collection is on the + * server. + */ + async checkSyncKeyRing() { + await this.updateKeyRingKB(); + + const cryptoKeyRecord = await this.cryptoCollection.getKeyRingRecord(); + if (cryptoKeyRecord && cryptoKeyRecord._status !== "synced") { + // We haven't successfully synced the keyring since the last + // change. This could be because kB changed and we touched the + // keyring, or it could be because we failed to sync after + // adding a key. Either way, take this opportunity to sync the + // keyring. + await this._syncKeyRing(cryptoKeyRecord); + } + } + + async _syncKeyRing(cryptoKeyRecord) { + throwIfNoFxA(this._fxaService, "syncing chrome.storage.sync \"keyring\""); + try { + // Try to sync using server_wins. + // + // We use server_wins here because whatever is on the server is + // at least consistent with itself -- the crypto in the keyring + // matches the crypto on the collection records. This is because + // we generate and upload keys just before syncing data. + // + // It's possible that we can't decode the version on the server. + // This can happen if a user is locked out of their account, and + // does a "reset password" to get in on a new device. In this + // case, we are in a bind -- we can't decrypt the record on the + // server, so we can't merge keys. If this happens, we try to + // figure out if we're the one with the correct (new) kB or if + // we just got locked out because we have the old kB. If we're + // the one with the correct kB, we wipe the server and reupload + // everything, including a new keyring. + // + // If another device has wiped the server, we need to reupload + // everything we have on our end too, so we detect this by + // adding a UUID to the keyring. UUIDs are preserved throughout + // the lifetime of a keyring, so the only time a keyring UUID + // changes is when a new keyring is uploaded, which only happens + // after a server wipe. So when we get a "conflict" (resolved by + // server_wins), we check whether the server version has a new + // UUID. If so, reset our sync status, so that we'll reupload + // everything. + const result = await this.cryptoCollection.sync(this); + if (result.resolved.length > 0) { + // Automatically-resolved conflict. It should + // be for the keys record. + const resolutionIds = result.resolved.map(resolution => resolution.id); + if (resolutionIds > 1) { + // This should never happen -- there is only ever one record + // in this collection. + log.error(`Too many resolutions for sync-storage-crypto collection: ${JSON.stringify(resolutionIds)}`); + } + const keyResolution = result.resolved[0]; + if (keyResolution.id != STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID) { + // This should never happen -- there should only ever be the + // keyring in this collection. + log.error(`Strange conflict in sync-storage-crypto collection: ${JSON.stringify(resolutionIds)}`); + } + + // Due to a bug in the server-side code (see + // https://github.com/Kinto/kinto/issues/1209), lots of users' + // keyrings were deleted. We discover this by trying to push a + // new keyring (because the user aded a new extension), and we + // get a conflict. We have SERVER_WINS, so the client will + // accept this deleted keyring and delete it locally. Discover + // this and undo it. + if (keyResolution.accepted === null) { + log.error("Conflict spotted -- the server keyring was deleted"); + await this.cryptoCollection.upsert(keyResolution.rejected); + // It's possible that the keyring on the server that was + // deleted had keys for other extensions, which had already + // encrypted data. For this to happen, another client would + // have had to upload the keyring and then the delete happened + // before this client did a sync (and got the new extension + // and tried to sync the keyring again). Just to be safe, + // let's signal that something went wrong and we should wipe + // the bucket. + throw new ServerKeyringDeleted(); + } + + if (keyResolution.accepted.uuid != cryptoKeyRecord.uuid) { + log.info(`Detected a new UUID (${keyResolution.accepted.uuid}, was ${cryptoKeyRecord.uuid}). Reseting sync status for everything.`); + await this.cryptoCollection.resetSyncStatus(); + + // Server version is now correct. Return that result. + return result; + } + } + // No conflicts, or conflict was just someone else adding keys. + return result; + } catch (e) { + if (KeyRingEncryptionRemoteTransformer.isOutdatedKB(e) || + e instanceof ServerKeyringDeleted || + // This is another way that ServerKeyringDeleted can + // manifest; see bug 1350088 for more details. + e.message == "Server has been flushed.") { + // Check if our token is still valid, or if we got locked out + // between starting the sync and talking to Kinto. + const isSessionValid = await this._fxaService.sessionStatus(); + if (isSessionValid) { + log.error("Couldn't decipher old keyring; deleting the default bucket and resetting sync status"); + await this._deleteBucket(); + await this.cryptoCollection.resetSyncStatus(); + + // Reupload our keyring, which is the only new keyring. + // We don't want client_wins here because another device + // could have uploaded another keyring in the meantime. + return await this.cryptoCollection.sync(this); + } + } + throw e; + } + } + + /** + * Get the collection for an extension, and register the extension + * as being "in use". + * + * @param {Extension} extension + * The extension for which we are seeking + * a collection. + * @param {Context} context + * The context of the extension, so that we can + * stop syncing the collection when the extension ends. + * @returns {Promise} + */ + getCollection(extension, context) { + if (prefPermitsStorageSync !== true) { + return Promise.reject({message: `Please set ${STORAGE_SYNC_ENABLED_PREF} to true in about:config`}); + } + // Register that the extension and context are in use. + if (!extensionContexts.has(extension)) { + extensionContexts.set(extension, new Set()); + } + const contexts = extensionContexts.get(extension); + if (!contexts.has(context)) { + // New context. Register it and make sure it cleans itself up + // when it closes. + contexts.add(context); + context.callOnClose({ + close: () => cleanUpForContext(extension, context), + }); + } + + return openCollection(this.cryptoCollection, extension, context); + } + + async set(extension, items, context) { + const coll = await this.getCollection(extension, context); + const keys = Object.keys(items); + const ids = keys.map(keyToId); + const histogramSize = this._telemetry.getKeyedHistogramById(HISTOGRAM_SET_OPS_SIZE); + const changes = await coll.execute(txn => { + let changes = {}; + for (let [i, key] of keys.entries()) { + const id = ids[i]; + let item = items[key]; + histogramSize.add(extension.id, JSON.stringify(item).length); + let {oldRecord} = txn.upsert({ + id, + key, + data: item, + }); + changes[key] = { + newValue: item, + }; + if (oldRecord && oldRecord.data) { + // Extract the "data" field from the old record, which + // represents the value part of the key-value store + changes[key].oldValue = oldRecord.data; + } + } + return changes; + }, {preloadIds: ids}); + this.notifyListeners(extension, changes); + } + + async remove(extension, keys, context) { + const coll = await this.getCollection(extension, context); + keys = [].concat(keys); + const ids = keys.map(keyToId); + let changes = {}; + await coll.execute(txn => { + for (let [i, key] of keys.entries()) { + const id = ids[i]; + const res = txn.deleteAny(id); + if (res.deleted) { + changes[key] = { + oldValue: res.data.data, + }; + } + } + return changes; + }, {preloadIds: ids}); + if (Object.keys(changes).length > 0) { + this.notifyListeners(extension, changes); + } + const histogram = this._telemetry.getKeyedHistogramById(HISTOGRAM_REMOVE_OPS); + histogram.add(extension.id, keys.length); + } + + async clear(extension, context) { + // We can't call Collection#clear here, because that just clears + // the local database. We have to explicitly delete everything so + // that the deletions can be synced as well. + const coll = await this.getCollection(extension, context); + const res = await coll.list(); + const records = res.data; + const keys = records.map(record => record.key); + await this.remove(extension, keys, context); + } + + async get(extension, spec, context) { + const coll = await this.getCollection(extension, context); + const histogramSize = this._telemetry.getKeyedHistogramById(HISTOGRAM_GET_OPS_SIZE); + let keys, records; + if (spec === null) { + records = {}; + const res = await coll.list(); + for (let record of res.data) { + histogramSize.add(extension.id, JSON.stringify(record.data).length); + records[record.key] = record.data; + } + return records; + } + if (typeof spec === "string") { + keys = [spec]; + records = {}; + } else if (Array.isArray(spec)) { + keys = spec; + records = {}; + } else { + keys = Object.keys(spec); + records = Cu.cloneInto(spec, global); + } + + for (let key of keys) { + const res = await coll.getAny(keyToId(key)); + if (res.data && res.data._status != "deleted") { + histogramSize.add(extension.id, JSON.stringify(res.data.data).length); + records[res.data.key] = res.data.data; + } + } + + return records; + } + + addOnChangedListener(extension, listener, context) { + let listeners = this.listeners.get(extension) || new Set(); + listeners.add(listener); + this.listeners.set(extension, listeners); + + // Force opening the collection so that we will sync for this extension. + return this.getCollection(extension, context); + } + + removeOnChangedListener(extension, listener) { + let listeners = this.listeners.get(extension); + listeners.delete(listener); + if (listeners.size == 0) { + this.listeners.delete(extension); + } + } + + notifyListeners(extension, changes) { + Observers.notify("ext.storage.sync-changed"); + let listeners = this.listeners.get(extension) || new Set(); + if (listeners) { + for (let listener of listeners) { + runSafeSyncWithoutClone(listener, changes); + } + } + } +} +this.ExtensionStorageSync = ExtensionStorageSync; +this.extensionStorageSync = new ExtensionStorageSync(_fxaService, Services.telemetry); diff --git a/toolkit/components/extensions/ExtensionTabs.jsm b/toolkit/components/extensions/ExtensionTabs.jsm new file mode 100644 index 0000000000..5f6c8abbd6 --- /dev/null +++ b/toolkit/components/extensions/ExtensionTabs.jsm @@ -0,0 +1,1836 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ +"use strict"; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +/* exported TabTrackerBase, TabManagerBase, TabBase, WindowTrackerBase, WindowManagerBase, WindowBase */ + +var EXPORTED_SYMBOLS = ["TabTrackerBase", "TabManagerBase", "TabBase", "WindowTrackerBase", "WindowManagerBase", "WindowBase"]; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); + +const { + DefaultMap, + DefaultWeakMap, + EventEmitter, + ExtensionError, + defineLazyGetter, + getWinUtils, +} = ExtensionUtils; + +/** + * The platform-specific type of native tab objects, which are wrapped by + * TabBase instances. + * + * @typedef {Object|XULElement} NativeTab + */ + +/** + * @typedef {Object} MutedInfo + * @property {boolean} muted + * True if the tab is currently muted, false otherwise. + * @property {string} [reason] + * The reason the tab is muted. Either "user", if the tab was muted by a + * user, or "extension", if it was muted by an extension. + * @property {string} [extensionId] + * If the tab was muted by an extension, contains the internal ID of that + * extension. + */ + +/** + * A platform-independent base class for extension-specific wrappers around + * native tab objects. + * + * @param {Extension} extension + * The extension object for which this wrapper is being created. Used to + * determine permissions for access to certain properties and + * functionality. + * @param {NativeTab} nativeTab + * The native tab object which is being wrapped. The type of this object + * varies by platform. + * @param {integer} id + * The numeric ID of this tab object. This ID should be the same for + * every extension, and for the lifetime of the tab. + */ +class TabBase { + constructor(extension, nativeTab, id) { + this.extension = extension; + this.tabManager = extension.tabManager; + this.id = id; + this.nativeTab = nativeTab; + this.activeTabWindowID = null; + } + + /** + * Sends a message, via the given context, to the ExtensionContent running in + * this tab. The tab's current innerWindowID is automatically added to the + * recipient filter for the message, and is used to ensure that the message is + * not processed if the content process navigates to a different content page + * before the message is received. + * + * @param {BaseContext} context + * The context through which to send the message. + * @param {string} messageName + * The name of the messge to send. + * @param {object} [data = {}] + * Arbitrary, structured-clonable message data to send. + * @param {object} [options] + * An options object, as accepted by BaseContext.sendMessage. + * + * @returns {Promise} + */ + sendMessage(context, messageName, data = {}, options = null) { + let {browser, innerWindowID} = this; + + options = Object.assign({}, options); + options.recipient = Object.assign({innerWindowID}, options.recipient); + + return context.sendMessage(browser.messageManager, messageName, + data, options); + } + + /** + * Capture the visible area of this tab, and return the result as a data: URL. + * + * @param {BaseContext} context + * The extension context for which to perform the capture. + * @param {Object} [options] + * The options with which to perform the capture. + * @param {string} [options.format = "png"] + * The image format in which to encode the captured data. May be one of + * "png" or "jpeg". + * @param {integer} [options.quality = 92] + * The quality at which to encode the captured image data, ranging from + * 0 to 100. Has no effect for the "png" format. + * + * @returns {Promise} + */ + capture(context, options = null) { + if (!options) { + options = {}; + } + if (options.format == null) { + options.format = "png"; + } + if (options.quality == null) { + options.quality = 92; + } + + let message = { + options, + width: this.width, + height: this.height, + }; + + return this.sendMessage(context, "Extension:Capture", message); + } + + /** + * @property {integer | null} innerWindowID + * The last known innerWindowID loaded into this tab's docShell. This + * property must remain in sync with the last known values of + * properties such as `url` and `title`. Any operations on the content + * of an out-of-process tab will automatically fail if the + * innerWindowID of the tab when the message is received does not match + * the value of this property when the message was sent. + * @readonly + */ + get innerWindowID() { + return this.browser.innerWindowID; + } + + /** + * @property {boolean} hasTabPermission + * Returns true if the extension has permission to access restricted + * properties of this tab, such as `url`, `title`, and `favIconUrl`. + * @readonly + */ + get hasTabPermission() { + return this.extension.hasPermission("tabs") || this.hasActiveTabPermission; + } + + /** + * @property {boolean} hasActiveTabPermission + * Returns true if the extension has the "activeTab" permission, and + * has been granted access to this tab due to a user executing an + * extension action. + * + * If true, the extension may load scripts and CSS into this tab, and + * access restricted properties, such as its `url`. + * @readonly + */ + get hasActiveTabPermission() { + return (this.extension.hasPermission("activeTab") && + this.activeTabWindowID != null && + this.activeTabWindowID === this.innerWindowID); + } + + /** + * @property {boolean} incognito + * Returns true if this is a private browsing tab, false otherwise. + * @readonly + */ + get _incognito() { + return PrivateBrowsingUtils.isBrowserPrivate(this.browser); + } + + /** + * @property {string} _url + * Returns the current URL of this tab. Does not do any permission + * checks. + * @readonly + */ + get _url() { + return this.browser.currentURI.spec; + } + + /** + * @property {string | null} url + * Returns the current URL of this tab if the extension has permission + * to read it, or null otherwise. + * @readonly + */ + get url() { + if (this.hasTabPermission) { + return this._url; + } + } + + /** + * @property {nsIURI | null} uri + * Returns the current URI of this tab if the extension has permission + * to read it, or null otherwise. + * @readonly + */ + get uri() { + if (this.hasTabPermission) { + return this.browser.currentURI; + } + } + + /** + * @property {string} _title + * Returns the current title of this tab. Does not do any permission + * checks. + * @readonly + */ + get _title() { + return this.browser.contentTitle || this.nativeTab.label; + } + + + /** + * @property {nsIURI | null} title + * Returns the current title of this tab if the extension has permission + * to read it, or null otherwise. + * @readonly + */ + get title() { + if (this.hasTabPermission) { + return this._title; + } + } + + /** + * @property {string} _favIconUrl + * Returns the current favicon URL of this tab. Does not do any permission + * checks. + * @readonly + * @abstract + */ + get _favIconUrl() { + throw new Error("Not implemented"); + } + + /** + * @property {nsIURI | null} faviconUrl + * Returns the current faviron URL of this tab if the extension has permission + * to read it, or null otherwise. + * @readonly + */ + get favIconUrl() { + if (this.hasTabPermission) { + return this._favIconUrl; + } + } + + /** + * @property {boolean} audible + * Returns true if the tab is currently playing audio, false otherwise. + * @readonly + * @abstract + */ + get audible() { + throw new Error("Not implemented"); + } + + /** + * @property {XULElement} browser + * Returns the XUL browser for the given tab. + * @readonly + * @abstract + */ + get browser() { + throw new Error("Not implemented"); + } + + /** + * @property {nsIFrameLoader} browser + * Returns the frameloader for the given tab. + * @readonly + */ + get frameLoader() { + return this.browser.frameLoader; + } + + /** + * @property {string} cookieStoreId + * Returns the cookie store identifier for the given tab. + * @readonly + * @abstract + */ + get cookieStoreId() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} height + * Returns the pixel height of the visible area of the tab. + * @readonly + * @abstract + */ + get height() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} index + * Returns the index of the tab in its window's tab list. + * @readonly + * @abstract + */ + get index() { + throw new Error("Not implemented"); + } + + /** + * @property {MutedInfo} mutedInfo + * Returns information about the tab's current audio muting status. + * @readonly + * @abstract + */ + get mutedInfo() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} pinned + * Returns true if the tab is pinned, false otherwise. + * @readonly + * @abstract + */ + get pinned() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} active + * Returns true if the tab is the currently-selected tab, false + * otherwise. + * @readonly + * @abstract + */ + get active() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} selected + * An alias for `active`. + * @readonly + * @abstract + */ + get selected() { + throw new Error("Not implemented"); + } + + /** + * @property {string} status + * Returns the current loading status of the tab. May be either + * "loading" or "complete". + * @readonly + * @abstract + */ + get status() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} height + * Returns the pixel height of the visible area of the tab. + * @readonly + * @abstract + */ + get width() { + throw new Error("Not implemented"); + } + + /** + * @property {DOMWindow} window + * Returns the browser window to which the tab belongs. + * @readonly + * @abstract + */ + get window() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} window + * Returns the numeric ID of the browser window to which the tab belongs. + * @readonly + * @abstract + */ + get windowId() { + throw new Error("Not implemented"); + } + + /** + * Returns true if this tab matches the the given query info object. Omitted + * or null have no effect on the match. + * + * @param {object} queryInfo + * The query info against which to match. + * @param {boolean} [queryInfo.active] + * Matches against the exact value of the tab's `active` attribute. + * @param {boolean} [queryInfo.audible] + * Matches against the exact value of the tab's `audible` attribute. + * @param {string} [queryInfo.cookieStoreId] + * Matches against the exact value of the tab's `cookieStoreId` attribute. + * @param {boolean} [queryInfo.highlighted] + * Matches against the exact value of the tab's `highlighted` attribute. + * @param {integer} [queryInfo.index] + * Matches against the exact value of the tab's `index` attribute. + * @param {boolean} [queryInfo.muted] + * Matches against the exact value of the tab's `mutedInfo.muted` attribute. + * @param {boolean} [queryInfo.pinned] + * Matches against the exact value of the tab's `pinned` attribute. + * @param {string} [queryInfo.status] + * Matches against the exact value of the tab's `status` attribute. + * @param {string} [queryInfo.title] + * Matches against the exact value of the tab's `title` attribute. + * + * Note: Per specification, this should perform a pattern match, rather + * than an exact value match, and will do so in the future. + * @param {MatchPattern} [queryInfo.url] + * Requires the tab's URL to match the given MatchPattern object. + * + * @returns {boolean} + * True if the tab matches the query. + */ + matches(queryInfo) { + const PROPS = ["active", "audible", "cookieStoreId", "highlighted", "index", "pinned", "status", "title"]; + + if (PROPS.some(prop => queryInfo[prop] !== null && queryInfo[prop] !== this[prop])) { + return false; + } + + if (queryInfo.muted !== null) { + if (queryInfo.muted !== this.mutedInfo.muted) { + return false; + } + } + + if (queryInfo.url && !queryInfo.url.matches(this.uri)) { + return false; + } + + return true; + } + + /** + * Converts this tab object to a JSON-compatible object containing the values + * of its properties which the extension is permitted to access, in the format + * requried to be returned by WebExtension APIs. + * + * @param {Tab} [fallbackTab] + * A tab to retrieve geometry data from if the lazy geometry data for + * this tab hasn't been initialized yet. + * @returns {object} + */ + convert(fallbackTab = null) { + let result = { + id: this.id, + index: this.index, + windowId: this.windowId, + highlighted: this.selected, + active: this.selected, + pinned: this.pinned, + status: this.status, + incognito: this.incognito, + width: this.width, + height: this.height, + audible: this.audible, + mutedInfo: this.mutedInfo, + }; + + // If the tab has not been fully layed-out yet, fallback to the geometry + // from a different tab (usually the currently active tab). + if (fallbackTab && (!result.width || !result.height)) { + result.width = fallbackTab.width; + result.height = fallbackTab.height; + } + + if (this.extension.hasPermission("cookies")) { + result.cookieStoreId = this.cookieStoreId; + } + + if (this.hasTabPermission) { + for (let prop of ["url", "title", "favIconUrl"]) { + // We use the underscored variants here to avoid the redundant + // permissions checks imposed on the public properties. + let val = this[`_${prop}`]; + if (val) { + result[prop] = val; + } + } + } + + return result; + } + + /** + * Inserts a script or stylesheet in the given tab, and returns a promise + * which resolves when the operation has completed. + * + * @param {BaseContext} context + * The extension context for which to perform the injection. + * @param {InjectDetails} details + * The InjectDetails object, specifying what to inject, where, and + * when. + * @param {string} kind + * The kind of data being injected. Either "script" or "css". + * @param {string} method + * The name of the method which was called to trigger the injection. + * Used to generate appropriate error messages on failure. + * + * @returns {Promise} + * Resolves to the result of the execution, once it has completed. + * @private + */ + _execute(context, details, kind, method) { + let options = { + js: [], + css: [], + remove_css: method == "removeCSS", + }; + + // 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`}); + } + + if (this.hasActiveTabPermission) { + // If we have the "activeTab" permission for this tab, ignore + // the host whitelist. + options.matches = [""]; + } else { + options.matches = this.extension.whiteListedHosts.patterns.map(host => host.pattern); + } + + if (details.code !== null) { + options[`${kind}Code`] = details.code; + } + if (details.file !== null) { + let url = context.uri.resolve(details.file); + if (!this.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"; + } + if (details.cssOrigin !== null) { + options.css_origin = details.cssOrigin; + } else { + options.css_origin = "author"; + } + + options.wantReturnValue = true; + + return this.sendMessage(context, "Extension:Execute", {options}); + } + + /** + * Executes a script in the tab's content window, and returns a Promise which + * resolves to the result of the evaluation, or rejects to the value of any + * error the injection generates. + * + * @param {BaseContext} context + * The extension context for which to inject the script. + * @param {InjectDetails} details + * The InjectDetails object, specifying what to inject, where, and + * when. + * + * @returns {Promise} + * Resolves to the result of the evaluation of the given script, once + * it has completed, or rejects with any error the evaluation + * generates. + */ + executeScript(context, details) { + return this._execute(context, details, "js", "executeScript"); + } + + /** + * Injects CSS into the tab's content window, and returns a Promise which + * resolves when the injection is complete. + * + * @param {BaseContext} context + * The extension context for which to inject the script. + * @param {InjectDetails} details + * The InjectDetails object, specifying what to inject, and where. + * + * @returns {Promise} + * Resolves when the injection has completed. + */ + insertCSS(context, details) { + return this._execute(context, details, "css", "insertCSS").then(() => {}); + } + + + /** + * Removes CSS which was previously into the tab's content window via + * `insertCSS`, and returns a Promise which resolves when the operation is + * complete. + * + * @param {BaseContext} context + * The extension context for which to remove the CSS. + * @param {InjectDetails} details + * The InjectDetails object, specifying what to remove, and from where. + * + * @returns {Promise} + * Resolves when the operation has completed. + */ + removeCSS(context, details) { + return this._execute(context, details, "css", "removeCSS").then(() => {}); + } +} + +defineLazyGetter(TabBase.prototype, "incognito", function() { return this._incognito; }); + +// Note: These must match the values in windows.json. +const WINDOW_ID_NONE = -1; +const WINDOW_ID_CURRENT = -2; + +/** + * A platform-independent base class for extension-specific wrappers around + * native browser windows + * + * @param {Extension} extension + * The extension object for which this wrapper is being created. + * @param {DOMWindow} window + * The browser DOM window which is being wrapped. + * @param {integer} id + * The numeric ID of this DOM window object. This ID should be the same for + * every extension, and for the lifetime of the window. + */ +class WindowBase { + constructor(extension, window, id) { + this.extension = extension; + this.window = window; + this.id = id; + } + + /** + * @property {nsIXULWindow} xulWindow + * The nsIXULWindow object for this browser window. + * @readonly + */ + get xulWindow() { + return this.window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + .treeOwner.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIXULWindow); + } + + /** + * Returns true if this window is the current window for the given extension + * context, false otherwise. + * + * @param {BaseContext} context + * The extension context for which to perform the check. + * + * @returns {boolean} + */ + isCurrentFor(context) { + if (context && context.currentWindow) { + return this.window === context.currentWindow; + } + return this.isLastFocused; + } + + /** + * @property {string} type + * The type of the window, as defined by the WebExtension API. May be + * either "normal" or "popup". + * @readonly + */ + get type() { + let {chromeFlags} = this.xulWindow; + + if (chromeFlags & Ci.nsIWebBrowserChrome.CHROME_OPENAS_DIALOG) { + return "popup"; + } + + return "normal"; + } + + /** + * Converts this window object to a JSON-compatible object which may be + * returned to an extension, in the format requried to be returned by + * WebExtension APIs. + * + * @param {object} [getInfo] + * An optional object, the properties of which determine what data is + * available on the result object. + * @param {boolean} [getInfo.populate] + * Of true, the result object will contain a `tabs` property, + * containing an array of converted Tab objects, one for each tab in + * the window. + * + * @returns {object} + */ + convert(getInfo) { + let result = { + id: this.id, + focused: this.focused, + top: this.top, + left: this.left, + width: this.width, + height: this.height, + incognito: this.incognito, + type: this.type, + state: this.state, + alwaysOnTop: this.alwaysOnTop, + }; + + if (getInfo && getInfo.populate) { + result.tabs = Array.from(this.getTabs(), tab => tab.convert()); + } + + return result; + } + + /** + * Returns true if this window matches the the given query info object. Omitted + * or null have no effect on the match. + * + * @param {object} queryInfo + * The query info against which to match. + * @param {boolean} [queryInfo.currentWindow] + * Matches against against the return value of `isCurrentFor()` for the + * given context. + * @param {boolean} [queryInfo.lastFocusedWindow] + * Matches against the exact value of the window's `isLastFocused` attribute. + * @param {boolean} [queryInfo.windowId] + * Matches against the exact value of the window's ID, taking into + * account the special WINDOW_ID_CURRENT value. + * @param {string} [queryInfo.windowType] + * Matches against the exact value of the window's `type` attribute. + * @param {BaseContext} context + * The extension context for which the matching is being performed. + * Used to determine the current window for relevant properties. + * + * @returns {boolean} + * True if the window matches the query. + */ + matches(queryInfo, context) { + if (queryInfo.lastFocusedWindow !== null && queryInfo.lastFocusedWindow !== this.isLastFocused) { + return false; + } + + if (queryInfo.windowType !== null && queryInfo.windowType !== this.type) { + return false; + } + + if (queryInfo.windowId !== null) { + if (queryInfo.windowId === WINDOW_ID_CURRENT) { + if (!this.isCurrentFor(context)) { + return false; + } + } else if (queryInfo.windowId !== this.id) { + return false; + } + } + + if (queryInfo.currentWindow !== null && queryInfo.currentWindow !== this.isCurrentFor(context)) { + return false; + } + + return true; + } + + /** + * @property {boolean} focused + * Returns true if the browser window is currently focused. + * @readonly + * @abstract + */ + get focused() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} top + * Returns the pixel offset of the top of the window from the top of + * the screen. + * @readonly + * @abstract + */ + get top() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} left + * Returns the pixel offset of the left of the window from the left of + * the screen. + * @readonly + * @abstract + */ + get left() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} width + * Returns the pixel width of the window. + * @readonly + * @abstract + */ + get width() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} height + * Returns the pixel height of the window. + * @readonly + * @abstract + */ + get height() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} incognito + * Returns true if this is a private browsing window, false otherwise. + * @readonly + * @abstract + */ + get incognito() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} alwaysOnTop + * Returns true if this window is constrained to always remain above + * other windows. + * @readonly + * @abstract + */ + get alwaysOnTop() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} isLastFocused + * Returns true if this is the browser window which most recently had + * focus. + * @readonly + * @abstract + */ + get isLastFocused() { + throw new Error("Not implemented"); + } + + /** + * @property {string} state + * Returns or sets the current state of this window, as determined by + * `getState()`. + * @abstract + */ + get state() { + throw new Error("Not implemented"); + } + + set state(state) { + throw new Error("Not implemented"); + } + + // The JSDoc validator does not support @returns tags in abstract functions or + // star functions without return statements. + /* eslint-disable valid-jsdoc */ + /** + * Returns the window state of the given window. + * + * @param {DOMWindow} window + * The window for which to return a state. + * + * @returns {string} + * The window's state. One of "normal", "minimized", "maximized", + * "fullscreen", or "docked". + * @static + * @abstract + */ + static getState(window) { + throw new Error("Not implemented"); + } + + /** + * Returns an iterator of TabBase objects for each tab in this window. + * + * @returns {Iterator} + */ + getTabs() { + throw new Error("Not implemented"); + } + /* eslint-enable valid-jsdoc */ +} + +Object.assign(WindowBase, {WINDOW_ID_NONE, WINDOW_ID_CURRENT}); + +/** + * The parameter type of "tab-attached" events, which are emitted when a + * pre-existing tab is attached to a new window. + * + * @typedef {Object} TabAttachedEvent + * @property {NativeTab} tab + * The native tab object in the window to which the tab is being + * attached. This may be a different object than was used to represent + * the tab in the old window. + * @property {integer} tabId + * The ID of the tab being attached. + * @property {integer} newWindowId + * The ID of the window to which the tab is being attached. + * @property {integer} newPosition + * The position of the tab in the tab list of the new window. + */ + +/** + * The parameter type of "tab-detached" events, which are emitted when a + * pre-existing tab is detached from a window, in order to be attached to a new + * window. + * + * @typedef {Object} TabDetachedEvent + * @property {NativeTab} tab + * The native tab object in the window from which the tab is being + * detached. This may be a different object than will be used to + * represent the tab in the new window. + * @property {NativeTab} adoptedBy + * The native tab object in the window to which the tab will be attached, + * and is adopting the contents of this tab. This may be a different + * object than the tab in the previous window. + * @property {integer} tabId + * The ID of the tab being detached. + * @property {integer} oldWindowId + * The ID of the window from which the tab is being detached. + * @property {integer} oldPosition + * The position of the tab in the tab list of the window from which it is + * being detached. + */ + +/** + * The parameter type of "tab-created" events, which are emitted when a + * new tab is created. + * + * @typedef {Object} TabCreatedEvent + * @property {NativeTab} tab + * The native tab object for the tab which is being created. + */ + +/** + * The parameter type of "tab-removed" events, which are emitted when a + * tab is removed and destroyed. + * + * @typedef {Object} TabRemovedEvent + * @property {NativeTab} tab + * The native tab object for the tab which is being removed. + * @property {integer} tabId + * The ID of the tab being removed. + * @property {integer} windowId + * The ID of the window from which the tab is being removed. + * @property {boolean} isWindowClosing + * True if the tab is being removed because the window is closing. + */ + +/** + * An object containg basic, extension-independent information about the window + * and tab that a XUL belongs to. + * + * @typedef {Object} BrowserData + * @property {integer} tabId + * The numeric ID of the tab that a belongs to, or -1 if it + * does not belong to a tab. + * @property {integer} windowId + * The numeric ID of the browser window that a belongs to, or -1 + * if it does not belong to a browser window. + */ + +/** + * A platform-independent base class for the platform-specific TabTracker + * classes, which track the opening and closing of tabs, and manage the mapping + * of them between numeric IDs and native tab objects. + * + * Instances of this class are EventEmitters which emit the following events, + * each with an argument of the given type: + * + * - "tab-attached" {@link TabAttacheEvent} + * - "tab-detached" {@link TabDetachedEvent} + * - "tab-created" {@link TabCreatedEvent} + * - "tab-removed" {@link TabRemovedEvent} + */ +class TabTrackerBase extends EventEmitter { + on(...args) { + if (!this.initialized) { + this.init(); + } + + return super.on(...args); // eslint-disable-line mozilla/balanced-listeners + } + + + /** + * Called to initialize the tab tracking listeners the first time that an + * event listener is added. + * + * @protected + * @abstract + */ + init() { + throw new Error("Not implemented"); + } + + // The JSDoc validator does not support @returns tags in abstract functions or + // star functions without return statements. + /* eslint-disable valid-jsdoc */ + /** + * Returns the numeric ID for the given native tab. + * + * @param {NativeTab} nativeTab + * The native tab for which to return an ID. + * + * @returns {integer} + * The tab's numeric ID. + * @abstract + */ + getId(nativeTab) { + throw new Error("Not implemented"); + } + + /** + * Returns the native tab with the given numeric ID. + * + * @param {integer} tabId + * The numeric ID of the tab to return. + * @param {*} default_ + * The value to return if no tab exists with the given ID. + * + * @returns {NativeTab} + * @throws {ExtensionError} + * If no tab exists with the given ID and a default return value is not + * provided. + * @abstract + */ + getTab(tabId, default_ = undefined) { + throw new Error("Not implemented"); + } + + /** + * Returns basic information about the tab and window that the given browser + * belongs to. + * + * @param {XULElement} browser + * The XUL browser element for which to return data. + * + * @returns {BrowserData} + * @abstract + */ + /* eslint-enable valid-jsdoc */ + getBrowserData(browser) { + throw new Error("Not implemented"); + } + + /** + * @property {NativeTab} activeTab + * Returns the native tab object for the active tab in the + * most-recently focused window, or null if no live tabs currently + * exist. + * @abstract + */ + get activeTab() { + throw new Error("Not implemented"); + } +} + +/** + * A browser progress listener instance which calls a given listener function + * whenever the status of the given browser changes. + * + * @param {function(Object)} listener + * A function to be called whenever the status of a tab's top-level + * browser. It is passed an object with a `browser` property pointing to + * the XUL browser, and a `status` property with a string description of + * the browser's status. + * @private + */ +class StatusListener { + constructor(listener) { + this.listener = listener; + } + + onStateChange(browser, webProgress, request, stateFlags, statusCode) { + if (!webProgress.isTopLevel) { + return; + } + + let status; + if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) { + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + status = "loading"; + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + status = "complete"; + } + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP && + statusCode == Cr.NS_BINDING_ABORTED) { + status = "complete"; + } + + if (status) { + this.listener({browser, status}); + } + } + + onLocationChange(browser, webProgress, request, locationURI, flags) { + if (webProgress.isTopLevel) { + let status = webProgress.isLoadingDocument ? "loading" : "complete"; + this.listener({browser, status, url: locationURI.spec}); + } + } +} + +/** + * A platform-independent base class for the platform-specific WindowTracker + * classes, which track the opening and closing of windows, and manage the + * mapping of them between numeric IDs and native tab objects. + */ +class WindowTrackerBase extends EventEmitter { + constructor() { + super(); + + this._handleWindowOpened = this._handleWindowOpened.bind(this); + + this._openListeners = new Set(); + this._closeListeners = new Set(); + + this._listeners = new DefaultMap(() => new Set()); + + this._statusListeners = new DefaultWeakMap(listener => { + return new StatusListener(listener); + }); + + this._windowIds = new DefaultWeakMap(window => { + window.QueryInterface(Ci.nsIInterfaceRequestor); + + return getWinUtils(window).outerWindowID; + }); + } + + isBrowserWindow(window) { + let {documentElement} = window.document; + + return documentElement.getAttribute("windowtype") === "navigator:browser"; + } + + // The JSDoc validator does not support @returns tags in abstract functions or + // star functions without return statements. + /* eslint-disable valid-jsdoc */ + /** + * Returns an iterator for all currently active browser windows. + * + * @param {boolean} [includeInomplete = false] + * If true, include browser windows which are not yet fully loaded. + * Otherwise, only include windows which are. + * + * @returns {Iterator} + */ + /* eslint-enable valid-jsdoc */ + * browserWindows(includeIncomplete = false) { + // The window type parameter is only available once the window's document + // element has been created. This means that, when looking for incomplete + // browser windows, we need to ignore the type entirely for windows which + // haven't finished loading, since we would otherwise skip browser windows + // in their early loading stages. + // This is particularly important given that the "domwindowcreated" event + // fires for browser windows when they're in that in-between state, and just + // before we register our own "domwindowcreated" listener. + + let e = Services.wm.getEnumerator(""); + while (e.hasMoreElements()) { + let window = e.getNext(); + + let ok = includeIncomplete; + if (window.document.readyState === "complete") { + ok = this.isBrowserWindow(window); + } + + if (ok) { + yield window; + } + } + } + + /** + * @property {DOMWindow|null} topWindow + * The currently active, or topmost, browser window, or null if no + * browser window is currently open. + * @readonly + */ + get topWindow() { + return Services.wm.getMostRecentWindow("navigator:browser"); + } + + /** + * Returns the numeric ID for the given browser window. + * + * @param {DOMWindow} window + * The DOM window for which to return an ID. + * + * @returns {integer} + * The window's numeric ID. + */ + getId(window) { + return this._windowIds.get(window); + } + + /** + * Returns the browser window to which the given context belongs, or the top + * browser window if the context does not belong to a browser window. + * + * @param {BaseContext} context + * The extension context for which to return the current window. + * + * @returns {DOMWindow|null} + */ + getCurrentWindow(context) { + return context.currentWindow || this.topWindow; + } + + /** + * Returns the browser window with the given ID. + * + * @param {integer} id + * The ID of the window to return. + * @param {BaseContext} context + * The extension context for which the matching is being performed. + * Used to determine the current window for relevant properties. + * + * @returns {DOMWindow} + * @throws {ExtensionError} + * If no window exists with the given ID. + */ + getWindow(id, context) { + if (id === WINDOW_ID_CURRENT) { + return this.getCurrentWindow(context); + } + + for (let window of this.browserWindows(true)) { + if (this.getId(window) === id) { + return window; + } + } + throw new ExtensionError(`Invalid window ID: ${id}`); + } + + /** + * @property {boolean} _haveListeners + * Returns true if any window open or close listeners are currently + * registered. + * @private + */ + get _haveListeners() { + return this._openListeners.size > 0 || this._closeListeners.size > 0; + } + + /** + * Register the given listener function to be called whenever a new browser + * window is opened. + * + * @param {function(DOMWindow)} listener + * The listener function to register. + */ + addOpenListener(listener) { + if (!this._haveListeners) { + Services.ww.registerNotification(this); + } + + this._openListeners.add(listener); + + for (let window of this.browserWindows(true)) { + if (window.document.readyState !== "complete") { + window.addEventListener("load", this); + } + } + } + + /** + * Unregister a listener function registered in a previous addOpenListener + * call. + * + * @param {function(DOMWindow)} listener + * The listener function to unregister. + */ + removeOpenListener(listener) { + this._openListeners.delete(listener); + + if (!this._haveListeners) { + Services.ww.unregisterNotification(this); + } + } + + /** + * Register the given listener function to be called whenever a browser + * window is closed. + * + * @param {function(DOMWindow)} listener + * The listener function to register. + */ + addCloseListener(listener) { + if (!this._haveListeners) { + Services.ww.registerNotification(this); + } + + this._closeListeners.add(listener); + } + + /** + * Unregister a listener function registered in a previous addCloseListener + * call. + * + * @param {function(DOMWindow)} listener + * The listener function to unregister. + */ + removeCloseListener(listener) { + this._closeListeners.delete(listener); + + if (!this._haveListeners) { + Services.ww.unregisterNotification(this); + } + } + + /** + * Handles load events for recently-opened windows, and adds additional + * listeners which may only be safely added when the window is fully loaded. + * + * @param {Event} event + * A DOM event to handle. + * @private + */ + handleEvent(event) { + if (event.type === "load") { + event.currentTarget.removeEventListener(event.type, this); + + let window = event.target.defaultView; + if (!this.isBrowserWindow(window)) { + return; + } + + for (let listener of this._openListeners) { + try { + listener(window); + } catch (e) { + Cu.reportError(e); + } + } + } + } + + /** + * Observes "domwindowopened" and "domwindowclosed" events, notifies the + * appropriate listeners, and adds necessary additional listeners to the new + * windows. + * + * @param {DOMWindow} window + * A DOM window. + * @param {string} topic + * The topic being observed. + * @private + */ + observe(window, topic) { + if (topic === "domwindowclosed") { + if (!this.isBrowserWindow(window)) { + return; + } + + window.removeEventListener("load", this); + for (let listener of this._closeListeners) { + try { + listener(window); + } catch (e) { + Cu.reportError(e); + } + } + } else if (topic === "domwindowopened") { + window.addEventListener("load", this); + } + } + + /** + * Add an event listener to be called whenever the given DOM event is recieved + * at the top level of any browser window. + * + * @param {string} type + * The type of event to listen for. May be any valid DOM event name, or + * one of the following special cases: + * + * - "progress": Adds a tab progress listener to every browser window. + * - "status": Adds a StatusListener to every tab of every browser + * window. + * - "domwindowopened": Acts as an alias for addOpenListener. + * - "domwindowclosed": Acts as an alias for addCloseListener. + * @param {function|object} listener + * The listener to invoke in response to the given events. + * + * @returns {undefined} + */ + addListener(type, listener) { + if (type === "domwindowopened") { + return this.addOpenListener(listener); + } else if (type === "domwindowclosed") { + return this.addCloseListener(listener); + } + + if (this._listeners.size === 0) { + this.addOpenListener(this._handleWindowOpened); + } + + if (type === "status") { + listener = this._statusListeners.get(listener); + type = "progress"; + } + + this._listeners.get(type).add(listener); + + // Register listener on all existing windows. + for (let window of this.browserWindows()) { + this._addWindowListener(window, type, listener); + } + } + + /** + * Removes an event listener previously registered via an addListener call. + * + * @param {string} type + * The type of event to stop listening for. + * @param {function|object} listener + * The listener to remove. + * + * @returns {undefined} + */ + removeListener(type, listener) { + if (type === "domwindowopened") { + return this.removeOpenListener(listener); + } else if (type === "domwindowclosed") { + return this.removeCloseListener(listener); + } + + if (type === "status") { + listener = this._statusListeners.get(listener); + type = "progress"; + } + + let listeners = this._listeners.get(type); + listeners.delete(listener); + + if (listeners.size === 0) { + this._listeners.delete(type); + if (this._listeners.size === 0) { + this.removeOpenListener(this._handleWindowOpened); + } + } + + // Unregister listener from all existing windows. + let useCapture = type === "focus" || type === "blur"; + for (let window of this.browserWindows()) { + if (type === "progress") { + this.removeProgressListener(window, listener); + } else { + window.removeEventListener(type, listener, useCapture); + } + } + } + + /** + * Adds a listener for the given event to the given window. + * + * @param {DOMWindow} window + * The browser window to which to add the listener. + * @param {string} eventType + * The type of DOM event to listen for, or "progress" to add a tab + * progress listener. + * @param {function|object} listener + * The listener to add. + * @private + */ + _addWindowListener(window, eventType, listener) { + let useCapture = eventType === "focus" || eventType === "blur"; + + if (eventType === "progress") { + this.addProgressListener(window, listener); + } else { + window.addEventListener(eventType, listener, useCapture); + } + } + + /** + * A private method which is called whenever a new browser window is opened, + * and adds the necessary listeners to it. + * + * @param {DOMWindow} window + * The window being opened. + * @private + */ + _handleWindowOpened(window) { + for (let [eventType, listeners] of this._listeners) { + for (let listener of listeners) { + this._addWindowListener(window, eventType, listener); + } + } + } + + /** + * Adds a tab progress listener to the given browser window. + * + * @param {DOMWindow} window + * The browser window to which to add the listener. + * @param {object} listener + * The tab progress listener to add. + * @abstract + */ + addProgressListener(window, listener) { + throw new Error("Not implemented"); + } + + /** + * Removes a tab progress listener from the given browser window. + * + * @param {DOMWindow} window + * The browser window from which to remove the listener. + * @param {object} listener + * The tab progress listener to remove. + * @abstract + */ + removeProgressListener(window, listener) { + throw new Error("Not implemented"); + } +} + +/** + * Manages native tabs, their wrappers, and their dynamic permissions for a + * particular extension. + * + * @param {Extension} extension + * The extension for which to manage tabs. + */ +class TabManagerBase { + constructor(extension) { + this.extension = extension; + + this._tabs = new DefaultWeakMap(tab => this.wrapTab(tab)); + } + + /** + * If the extension has requested activeTab permission, grant it those + * permissions for the current inner window in the given native tab. + * + * @param {NativeTab} nativeTab + * The native tab for which to grant permissions. + */ + addActiveTabPermission(nativeTab) { + if (this.extension.hasPermission("activeTab")) { + // Note that, unlike Chrome, we don't currently clear this permission with + // the tab navigates. If the inner window is revived from BFCache before + // we've granted this permission to a new inner window, the extension + // maintains its permissions for it. + let tab = this.getWrapper(nativeTab); + tab.activeTabWindowID = tab.innerWindowID; + } + } + + /** + * Revoke the extension's activeTab permissions for the current inner window + * of the given native tab. + * + * @param {NativeTab} nativeTab + * The native tab for which to revoke permissions. + */ + revokeActiveTabPermission(nativeTab) { + this.getWrapper(nativeTab).activeTabWindowID = null; + } + + /** + * Returns true if the extension has requested activeTab permission, and has + * been granted permissions for the current inner window if this tab. + * + * @param {NativeTab} nativeTab + * The native tab for which to check permissions. + * @returns {boolean} + * True if the extension has activeTab permissions for this tab. + */ + hasActiveTabPermission(nativeTab) { + return this.getWrapper(nativeTab).hasActiveTabPermission; + } + + /** + * Returns true if the extension has permissions to access restricted + * properties of the given native tab. In practice, this means that it has + * either requested the "tabs" permission or has activeTab permissions for the + * given tab. + * + * @param {NativeTab} nativeTab + * The native tab for which to check permissions. + * @returns {boolean} + * True if the extension has permissions for this tab. + */ + hasTabPermission(nativeTab) { + return this.getWrapper(nativeTab).hasTabPermission; + } + + /** + * Returns this extension's TabBase wrapper for the given native tab. This + * method will always return the same wrapper object for any given native tab. + * + * @param {NativeTab} nativeTab + * The tab for which to return a wrapper. + * + * @returns {TabBase} + * The wrapper for this tab. + */ + getWrapper(nativeTab) { + return this._tabs.get(nativeTab); + } + + /** + * Converts the given native tab to a JSON-compatible object, in the format + * requried to be returned by WebExtension APIs, which may be safely passed to + * extension code. + * + * @param {NativeTab} nativeTab + * The native tab to convert. + * @param {NativeTab} [fallbackTab] + * A tab to retrieve geometry data from if the lazy geometry data for + * this tab hasn't been initialized yet. + * + * @returns {Object} + */ + convert(nativeTab, fallbackTab = null) { + return this.getWrapper(nativeTab) + .convert(fallbackTab && this.getWrapper(fallbackTab)); + } + + // The JSDoc validator does not support @returns tags in abstract functions or + // star functions without return statements. + /* eslint-disable valid-jsdoc */ + /** + * Returns an iterator of TabBase objects which match the given query info. + * + * @param {Object|null} [queryInfo = null] + * An object containing properties on which to filter. May contain any + * properties which are recognized by {@link TabBase#matches} or + * {@link WindowBase#matches}. Unknown properties will be ignored. + * @param {BaseContext|null} [context = null] + * The extension context for which the matching is being performed. + * Used to determine the current window for relevant properties. + * + * @returns {Iterator} + */ + * query(queryInfo = null, context = null) { + for (let window of this.extension.windowManager.query(queryInfo, context)) { + for (let tab of window.getTabs()) { + if (!queryInfo || tab.matches(queryInfo)) { + yield tab; + } + } + } + } + + /** + * Returns a TabBase wrapper for the tab with the given ID. + * + * @param {integer} id + * The ID of the tab for which to return a wrapper. + * + * @returns {TabBase} + * @throws {ExtensionError} + * If no tab exists with the given ID. + * @abstract + */ + get(tabId) { + throw new Error("Not implemented"); + } + + /** + * Returns a new TabBase instance wrapping the given native tab. + * + * @param {NativeTab} nativeTab + * The native tab for which to return a wrapper. + * + * @returns {TabBase} + * @protected + * @abstract + */ + /* eslint-enable valid-jsdoc */ + wrapTab(nativeTab) { + throw new Error("Not implemented"); + } +} + +/** + * Manages native browser windows and their wrappers for a particular extension. + * + * @param {Extension} extension + * The extension for which to manage windows. + */ +class WindowManagerBase { + constructor(extension) { + this.extension = extension; + + this._windows = new DefaultWeakMap(window => this.wrapWindow(window)); + } + + /** + * Converts the given browser window to a JSON-compatible object, in the + * format requried to be returned by WebExtension APIs, which may be safely + * passed to extension code. + * + * @param {DOMWindow} window + * The browser window to convert. + * @param {*} args + * Additional arguments to be passed to {@link WindowBase#convert}. + * + * @returns {Object} + */ + convert(window, ...args) { + return this.getWrapper(window).convert(...args); + } + + /** + * Returns this extension's WindowBase wrapper for the given browser window. + * This method will always return the same wrapper object for any given + * browser window. + * + * @param {DOMWindow} window + * The browser window for which to return a wrapper. + * + * @returns {WindowBase} + * The wrapper for this tab. + */ + getWrapper(window) { + return this._windows.get(window); + } + + // The JSDoc validator does not support @returns tags in abstract functions or + // star functions without return statements. + /* eslint-disable valid-jsdoc */ + /** + * Returns an iterator of WindowBase objects which match the given query info. + * + * @param {Object|null} [queryInfo = null] + * An object containing properties on which to filter. May contain any + * properties which are recognized by {@link WindowBase#matches}. + * Unknown properties will be ignored. + * @param {BaseContext|null} [context = null] + * The extension context for which the matching is being performed. + * Used to determine the current window for relevant properties. + * + * @returns {Iterator} + */ + * query(queryInfo = null, context = null) { + for (let window of this.getAll()) { + if (!queryInfo || window.matches(queryInfo, context)) { + yield window; + } + } + } + + /** + * Returns a WindowBase wrapper for the browser window with the given ID. + * + * @param {integer} id + * The ID of the browser window for which to return a wrapper. + * @param {BaseContext} context + * The extension context for which the matching is being performed. + * Used to determine the current window for relevant properties. + * + * @returns{WindowBase} + * @throws {ExtensionError} + * If no window exists with the given ID. + * @abstract + */ + get(windowId, context) { + throw new Error("Not implemented"); + } + + /** + * Returns an iterator of WindowBase wrappers for each currently existing + * browser window. + * + * @returns {Iterator} + * @abstract + */ + getAll() { + throw new Error("Not implemented"); + } + + /** + * Returns a new WindowBase instance wrapping the given browser window. + * + * @param {DOMWindow} window + * The browser window for which to return a wrapper. + * + * @returns {WindowBase} + * @protected + * @abstract + */ + wrapWindow(window) { + throw new Error("Not implemented"); + } + /* eslint-enable valid-jsdoc */ +} diff --git a/toolkit/components/extensions/ExtensionTestCommon.jsm b/toolkit/components/extensions/ExtensionTestCommon.jsm new file mode 100644 index 0000000000..0457a0c7fa --- /dev/null +++ b/toolkit/components/extensions/ExtensionTestCommon.jsm @@ -0,0 +1,370 @@ +/* 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/. */ +"use strict"; + +/** + * This module contains extension testing helper logic which is common + * between all test suites. + */ + +/* exported ExtensionTestCommon, MockExtension */ + +this.EXPORTED_SYMBOLS = ["ExtensionTestCommon", "MockExtension"]; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.importGlobalProperties(["TextEncoder"]); + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Extension", + "resource://gre/modules/Extension.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionParent", + "resource://gre/modules/ExtensionParent.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); + +XPCOMUtils.defineLazyGetter(this, "apiManager", + () => ExtensionParent.apiManager); + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "uuidGen", + "@mozilla.org/uuid-generator;1", + "nsIUUIDGenerator"); + +const { + flushJarCache, + instanceOf, +} = ExtensionUtils; + +XPCOMUtils.defineLazyGetter(this, "console", ExtensionUtils.getConsole); + + +/** + * A skeleton Extension-like object, used for testing, which installs an + * add-on via the add-on manager when startup() is called, and + * uninstalles it on shutdown(). + * + * @param {string} id + * @param {nsIFile} file + * @param {nsIURI} rootURI + * @param {string} installType + */ +class MockExtension { + constructor(file, rootURI, installType) { + this.id = null; + this.file = file; + this.rootURI = rootURI; + this.installType = installType; + this.addon = null; + + let promiseEvent = eventName => new Promise(resolve => { + let onstartup = (msg, extension) => { + if (this.addon && extension.id == this.addon.id) { + apiManager.off(eventName, onstartup); + + this.id = extension.id; + this._extension = extension; + resolve(extension); + } + }; + apiManager.on(eventName, onstartup); + }); + + this._extension = null; + this._extensionPromise = promiseEvent("startup"); + this._readyPromise = promiseEvent("ready"); + } + + testMessage(...args) { + return this._extension.testMessage(...args); + } + + on(...args) { + this._extensionPromise.then(extension => { + extension.on(...args); + }); + } + + off(...args) { + this._extensionPromise.then(extension => { + extension.off(...args); + }); + } + + startup() { + if (this.installType == "temporary") { + return AddonManager.installTemporaryAddon(this.file).then(addon => { + this.addon = addon; + return this._readyPromise; + }); + } else if (this.installType == "permanent") { + return new Promise((resolve, reject) => { + AddonManager.getInstallForFile(this.file, install => { + let listener = { + onInstallFailed: reject, + onInstallEnded: (install, newAddon) => { + this.addon = newAddon; + resolve(this._readyPromise); + }, + }; + + install.addListener(listener); + install.install(); + }); + }); + } + throw new Error("installType must be one of: temporary, permanent"); + } + + shutdown() { + this.addon.uninstall(); + return this.cleanupGeneratedFile(); + } + + cleanupGeneratedFile() { + return this._extensionPromise.then(extension => { + return extension.broadcast("Extension:FlushJarCache", {path: this.file.path}); + }).then(() => { + return OS.File.remove(this.file.path); + }); + } +} + +this.ExtensionTestCommon = class ExtensionTestCommon { + /** + * This code is designed to make it easy to test a WebExtension + * without creating a bunch of files. Everything is contained in a + * single JSON blob. + * + * Properties: + * "background": "" + * A script to be loaded as the background script. + * The "background" section of the "manifest" property is overwritten + * if this is provided. + * "manifest": {...} + * Contents of manifest.json + * "files": {"filename1": "contents1", ...} + * Data to be included as files. Can be referenced from the manifest. + * If a manifest file is provided here, it takes precedence over + * a generated one. Always use "/" as a directory separator. + * Directories should appear here only implicitly (as a prefix + * to file names) + * + * To make things easier, the value of "background" and "files"[] can + * be a function, which is converted to source that is run. + * + * The generated extension is stored in the system temporary directory, + * and an nsIFile object pointing to it is returned. + * + * @param {object} data + * @returns {nsIFile} + */ + static generateXPI(data) { + let manifest = data.manifest; + if (!manifest) { + manifest = {}; + } + + let files = data.files; + if (!files) { + files = {}; + } + + function provide(obj, keys, value, override = false) { + if (keys.length == 1) { + if (!(keys[0] in obj) || override) { + obj[keys[0]] = value; + } + } else { + if (!(keys[0] in obj)) { + obj[keys[0]] = {}; + } + provide(obj[keys[0]], keys.slice(1), value, override); + } + } + + provide(manifest, ["name"], "Generated extension"); + provide(manifest, ["manifest_version"], 2); + provide(manifest, ["version"], "1.0"); + + if (data.background) { + let bgScript = uuidGen.generateUUID().number + ".js"; + + provide(manifest, ["background", "scripts"], [bgScript], true); + files[bgScript] = data.background; + } + + provide(files, ["manifest.json"], manifest); + + if (data.embedded) { + // Package this as a webextension embedded inside a legacy + // extension. + + let xpiFiles = { + "install.rdf": ` + + + + + + + + + + `, + + "bootstrap.js": ` + function install() {} + function uninstall() {} + function shutdown() {} + + function startup(data) { + data.webExtension.startup(); + } + `, + }; + + for (let [path, data] of Object.entries(files)) { + xpiFiles[`webextension/${path}`] = data; + } + + files = xpiFiles; + } + + return this.generateZipFile(files); + } + + static generateZipFile(files, baseName = "generated-extension.xpi") { + let ZipWriter = Components.Constructor("@mozilla.org/zipwriter;1", "nsIZipWriter"); + let zipW = new ZipWriter(); + + let file = FileUtils.getFile("TmpD", [baseName]); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + + const MODE_WRONLY = 0x02; + const MODE_TRUNCATE = 0x20; + zipW.open(file, MODE_WRONLY | MODE_TRUNCATE); + + // Needs to be in microseconds for some reason. + let time = Date.now() * 1000; + + function generateFile(filename) { + let components = filename.split("/"); + let path = ""; + for (let component of components.slice(0, -1)) { + path += component + "/"; + if (!zipW.hasEntry(path)) { + zipW.addEntryDirectory(path, time, false); + } + } + } + + for (let filename in files) { + let script = files[filename]; + if (typeof(script) == "function") { + script = this.serializeScript(script); + } else if (instanceOf(script, "Object") || instanceOf(script, "Array")) { + script = JSON.stringify(script); + } + + if (!instanceOf(script, "ArrayBuffer")) { + script = new TextEncoder("utf-8").encode(script).buffer; + } + + let stream = Cc["@mozilla.org/io/arraybuffer-input-stream;1"].createInstance(Ci.nsIArrayBufferInputStream); + stream.setData(script, 0, script.byteLength); + + generateFile(filename); + zipW.addEntryStream(filename, time, 0, stream, false); + } + + zipW.close(); + + return file; + } + + /** + * Properly serialize a script into eval-able code string. + * + * @param {string|function|Array} script + * @returns {string} + */ + static serializeScript(script) { + if (Array.isArray(script)) { + return script.map(this.serializeScript).join(";"); + } + if (typeof script !== "function") { + return script; + } + // Serialization of object methods doesn't include `function` anymore. + const method = /^(async )?(\w+)\(/; + + let code = script.toString(); + let match = code.match(method); + if (match && match[2] !== "function") { + code = code.replace(method, "$1function $2("); + } + return `(${code})();`; + } + + /** + * Generates a new extension using |Extension.generateXPI|, and initializes a + * new |Extension| instance which will execute it. + * + * @param {object} data + * @returns {Extension} + */ + static generate(data) { + let file = this.generateXPI(data); + + flushJarCache(file.path); + Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", {path: file.path}); + + let fileURI = Services.io.newFileURI(file); + let jarURI = Services.io.newURI("jar:" + fileURI.spec + "!/"); + + // This may be "temporary" or "permanent". + if (data.useAddonManager) { + return new MockExtension(file, jarURI, data.useAddonManager); + } + + let id; + if (data.manifest) { + if (data.manifest.applications && data.manifest.applications.gecko) { + id = data.manifest.applications.gecko.id; + } else if (data.manifest.browser_specific_settings && data.manifest.browser_specific_settings.gecko) { + id = data.manifest.browser_specific_settings.gecko.id; + } + } + if (!id) { + id = uuidGen.generateUUID().number; + } + + return new Extension({ + id, + resourceURI: jarURI, + cleanupFile: file, + }); + } +}; diff --git a/toolkit/components/extensions/ExtensionUtils.jsm b/toolkit/components/extensions/ExtensionUtils.jsm new file mode 100644 index 0000000000..a651bd77af --- /dev/null +++ b/toolkit/components/extensions/ExtensionUtils.jsm @@ -0,0 +1,651 @@ +/* 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/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["ExtensionUtils"]; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ConsoleAPI", + "resource://gre/modules/Console.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel", + "resource://gre/modules/MessageChannel.jsm"); + +function getConsole() { + return new ConsoleAPI({ + maxLogLevelPref: "extensions.webextensions.log.level", + prefix: "WebExtensions", + }); +} + +XPCOMUtils.defineLazyGetter(this, "console", getConsole); + +let nextId = 0; +XPCOMUtils.defineLazyGetter(this, "uniqueProcessID", () => Services.appinfo.uniqueProcessID); + +function getUniqueId() { + return `${nextId++}-${uniqueProcessID}`; +} + + +/** + * An Error subclass for which complete error messages are always passed + * to extensions, rather than being interpreted as an unknown error. + */ +class ExtensionError extends Error {} + +function filterStack(error) { + return String(error.stack).replace(/(^.*(Task\.jsm|Promise-backend\.js).*\n)+/gm, "\n"); +} + +// Run a function and report exceptions. +function runSafeSyncWithoutClone(f, ...args) { + try { + return f(...args); + } catch (e) { + dump(`Extension error: ${e} ${e.fileName} ${e.lineNumber}\n[[Exception stack\n${filterStack(e)}Current stack\n${filterStack(Error())}]]\n`); + Cu.reportError(e); + } +} + +// Run a function and report exceptions. +function runSafeWithoutClone(f, ...args) { + if (typeof(f) != "function") { + dump(`Extension error: expected function\n${filterStack(Error())}`); + return; + } + + Promise.resolve().then(() => { + runSafeSyncWithoutClone(f, ...args); + }); +} + +// Run a function, cloning arguments into context.cloneScope, and +// report exceptions. |f| is expected to be in context.cloneScope. +function runSafeSync(context, f, ...args) { + if (context.unloaded) { + Cu.reportError("runSafeSync called after context unloaded"); + return; + } + + try { + args = Cu.cloneInto(args, context.cloneScope); + } catch (e) { + Cu.reportError(e); + dump(`runSafe failure: cloning into ${context.cloneScope}: ${e}\n\n${filterStack(Error())}`); + } + return runSafeSyncWithoutClone(f, ...args); +} + +// Run a function, cloning arguments into context.cloneScope, and +// report exceptions. |f| is expected to be in context.cloneScope. +function runSafe(context, f, ...args) { + try { + args = Cu.cloneInto(args, context.cloneScope); + } catch (e) { + Cu.reportError(e); + dump(`runSafe failure: cloning into ${context.cloneScope}: ${e}\n\n${filterStack(Error())}`); + } + if (context.unloaded) { + dump(`runSafe failure: context is already unloaded ${filterStack(new Error())}\n`); + return undefined; + } + return runSafeWithoutClone(f, ...args); +} + +// Return true if the given value is an instance of the given +// native type. +function instanceOf(value, type) { + return {}.toString.call(value) == `[object ${type}]`; +} + +/** + * Similar to a WeakMap, but creates a new key with the given + * constructor if one is not present. + */ +class DefaultWeakMap extends WeakMap { + constructor(defaultConstructor, init) { + super(init); + this.defaultConstructor = defaultConstructor; + } + + get(key) { + if (!this.has(key)) { + this.set(key, this.defaultConstructor(key)); + } + return super.get(key); + } +} + +class DefaultMap extends Map { + constructor(defaultConstructor, init) { + super(init); + this.defaultConstructor = defaultConstructor; + } + + get(key) { + if (!this.has(key)) { + this.set(key, this.defaultConstructor(key)); + } + return super.get(key); + } +} + +const _winUtils = new DefaultWeakMap(win => { + return win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); +}); +const getWinUtils = win => _winUtils.get(win); + +function getInnerWindowID(window) { + return getWinUtils(window).currentInnerWindowID; +} + +const LISTENERS = Symbol("listeners"); +const ONCE_MAP = Symbol("onceMap"); + +class EventEmitter { + constructor() { + this[LISTENERS] = new Map(); + this[ONCE_MAP] = new WeakMap(); + } + + /** + * Adds the given function as a listener for the given event. + * + * The listener function may optionally return a Promise which + * resolves when it has completed all operations which event + * dispatchers may need to block on. + * + * @param {string} event + * The name of the event to listen for. + * @param {function(string, ...any)} listener + * The listener to call when events are emitted. + */ + on(event, listener) { + if (!this[LISTENERS].has(event)) { + this[LISTENERS].set(event, new Set()); + } + + this[LISTENERS].get(event).add(listener); + } + + /** + * Removes the given function as a listener for the given event. + * + * @param {string} event + * The name of the event to stop listening for. + * @param {function(string, ...any)} listener + * The listener function to remove. + */ + off(event, listener) { + if (this[LISTENERS].has(event)) { + let set = this[LISTENERS].get(event); + + set.delete(listener); + set.delete(this[ONCE_MAP].get(listener)); + if (!set.size) { + this[LISTENERS].delete(event); + } + } + } + + /** + * Adds the given function as a listener for the given event once. + * + * @param {string} event + * The name of the event to listen for. + * @param {function(string, ...any)} listener + * The listener to call when events are emitted. + */ + once(event, listener) { + let wrapper = (...args) => { + this.off(event, wrapper); + this[ONCE_MAP].delete(listener); + + return listener(...args); + }; + this[ONCE_MAP].set(listener, wrapper); + + this.on(event, wrapper); + } + + + /** + * Triggers all listeners for the given event, and returns a promise + * which resolves when all listeners have been called, and any + * promises they have returned have likewise resolved. + * + * @param {string} event + * The name of the event to emit. + * @param {any} args + * Arbitrary arguments to pass to the listener functions, after + * the event name. + * @returns {Promise} + */ + emit(event, ...args) { + let listeners = this[LISTENERS].get(event) || new Set(); + + let promises = Array.from(listeners, listener => { + return runSafeSyncWithoutClone(listener, event, ...args); + }); + + return Promise.all(promises); + } +} + +/** + * A set with a limited number of slots, which flushes older entries as + * newer ones are added. + * + * @param {integer} limit + * The maximum size to trim the set to after it grows too large. + * @param {integer} [slop = limit * .25] + * The number of extra entries to allow in the set after it + * reaches the size limit, before it is truncated to the limit. + * @param {iterable} [iterable] + * An iterable of initial entries to add to the set. + */ +class LimitedSet extends Set { + constructor(limit, slop = Math.round(limit * .25), iterable = undefined) { + super(iterable); + this.limit = limit; + this.slop = slop; + } + + truncate(limit) { + for (let item of this) { + // Live set iterators can ge relatively expensive, since they need + // to be updated after every modification to the set. Since + // breaking out of the loop early will keep the iterator alive + // until the next full GC, we're currently better off finishing + // the entire loop even after we're done truncating. + if (this.size > limit) { + this.delete(item); + } + } + } + + add(item) { + if (!this.has(item) && this.size >= this.limit + this.slop) { + this.truncate(this.limit - 1); + } + super.add(item); + } +} + +/** + * Returns a Promise which resolves when the given document's DOM has + * fully loaded. + * + * @param {Document} doc The document to await the load of. + * @returns {Promise} + */ +function promiseDocumentReady(doc) { + if (doc.readyState == "interactive" || doc.readyState == "complete") { + return Promise.resolve(doc); + } + + return new Promise(resolve => { + doc.addEventListener("DOMContentLoaded", function onReady(event) { + if (event.target === event.currentTarget) { + doc.removeEventListener("DOMContentLoaded", onReady, true); + resolve(doc); + } + }, true); + }); +} + +/** + * Returns a Promise which resolves when the given document is fully + * loaded. + * + * @param {Document} doc The document to await the load of. + * @returns {Promise} + */ +function promiseDocumentLoaded(doc) { + if (doc.readyState == "complete") { + return Promise.resolve(doc); + } + + return new Promise(resolve => { + doc.defaultView.addEventListener("load", function(event) { + resolve(doc); + }, {once: true}); + }); +} + +/** + * Returns a Promise which resolves when the given event is dispatched to the + * given element. + * + * @param {Element} element + * The element on which to listen. + * @param {string} eventName + * The event to listen for. + * @param {boolean} [useCapture = true] + * If true, listen for the even in the capturing rather than + * bubbling phase. + * @param {Event} [test] + * An optional test function which, when called with the + * observer's subject and data, should return true if this is the + * expected event, false otherwise. + * @returns {Promise} + */ +function promiseEvent(element, eventName, useCapture = true, test = event => true) { + return new Promise(resolve => { + function listener(event) { + if (test(event)) { + element.removeEventListener(eventName, listener, useCapture); + resolve(event); + } + } + element.addEventListener(eventName, listener, useCapture); + }); +} + +/** + * Returns a Promise which resolves the given observer topic has been + * observed. + * + * @param {string} topic + * The topic to observe. + * @param {function(nsISupports, string)} [test] + * An optional test function which, when called with the + * observer's subject and data, should return true if this is the + * expected notification, false otherwise. + * @returns {Promise} + */ +function promiseObserved(topic, test = () => true) { + return new Promise(resolve => { + let observer = (subject, topic, data) => { + if (test(subject, data)) { + Services.obs.removeObserver(observer, topic); + resolve({subject, data}); + } + }; + Services.obs.addObserver(observer, topic); + }); +} + +function getMessageManager(target) { + if (target instanceof Ci.nsIFrameLoaderOwner) { + return target.QueryInterface(Ci.nsIFrameLoaderOwner).frameLoader.messageManager; + } + return target.QueryInterface(Ci.nsIMessageSender); +} + +function flushJarCache(jarPath) { + Services.obs.notifyObservers(null, "flush-cache-entry", jarPath); +} + +/** + * Convert any of several different representations of a date/time to a Date object. + * Accepts several formats: + * a Date object, an ISO8601 string, or a number of milliseconds since the epoch as + * either a number or a string. + * + * @param {Date|string|number} date + * The date to convert. + * @returns {Date} + * A Date object + */ +function normalizeTime(date) { + // Of all the formats we accept the "number of milliseconds since the epoch as a string" + // is an outlier, everything else can just be passed directly to the Date constructor. + return new Date((typeof date == "string" && /^\d+$/.test(date)) + ? parseInt(date, 10) : date); +} + +/** + * Defines a lazy getter for the given property on the given object. The + * first time the property is accessed, the return value of the getter + * is defined on the current `this` object with the given property name. + * Importantly, this means that a lazy getter defined on an object + * prototype will be invoked separately for each object instance that + * it's accessed on. + * + * @param {object} object + * The prototype object on which to define the getter. + * @param {string|Symbol} prop + * The property name for which to define the getter. + * @param {function} getter + * The function to call in order to generate the final property + * value. + */ +function defineLazyGetter(object, prop, getter) { + let redefine = (obj, value) => { + Object.defineProperty(obj, prop, { + enumerable: true, + configurable: true, + writable: true, + value, + }); + return value; + }; + + Object.defineProperty(object, prop, { + enumerable: true, + configurable: true, + + get() { + return redefine(this, getter.call(this)); + }, + + set(value) { + redefine(this, value); + }, + }); +} + +/** + * Acts as a proxy for a message manager or message manager owner, and + * tracks docShell swaps so that messages are always sent to the same + * receiver, even if it is moved to a different . + * + * @param {nsIMessageSender|Element} target + * The target message manager on which to send messages, or the + * element which owns it. + */ +class MessageManagerProxy { + constructor(target) { + this.listeners = new DefaultMap(() => new Map()); + + if (target instanceof Ci.nsIMessageSender) { + Object.defineProperty(this, "messageManager", { + value: target, + configurable: true, + writable: true, + }); + } else { + this.addListeners(target); + } + } + + /** + * Disposes of the proxy object, removes event listeners, and drops + * all references to the underlying message manager. + * + * Must be called before the last reference to the proxy is dropped, + * unless the underlying message manager or is also being + * destroyed. + */ + dispose() { + if (this.eventTarget) { + this.removeListeners(this.eventTarget); + this.eventTarget = null; + } else { + this.messageManager = null; + } + } + + /** + * Returns true if the given target is the same as, or owns, the given + * message manager. + * + * @param {nsIMessageSender|MessageManagerProxy|Element} target + * The message manager, MessageManagerProxy, or + * element agaisnt which to match. + * @param {nsIMessageSender} messageManager + * The message manager against which to match `target`. + * + * @returns {boolean} + * True if `messageManager` is the same object as `target`, or + * `target` is a MessageManagerProxy or element that + * is tied to it. + */ + static matches(target, messageManager) { + return target === messageManager || target.messageManager === messageManager; + } + + /** + * @property {nsIMessageSender|null} messageManager + * The message manager that is currently being proxied. This + * may change during the life of the proxy object, so should + * not be stored elsewhere. + */ + get messageManager() { + return this.eventTarget && this.eventTarget.messageManager; + } + + /** + * Sends a message on the proxied message manager. + * + * @param {array} args + * Arguments to be passed verbatim to the underlying + * sendAsyncMessage method. + * @returns {undefined} + */ + sendAsyncMessage(...args) { + if (this.messageManager) { + return this.messageManager.sendAsyncMessage(...args); + } + + Cu.reportError(`Cannot send message: Other side disconnected: ${uneval(args)}`); + } + + get isDisconnected() { + return !this.messageManager; + } + + /** + * Adds a message listener to the current message manager, and + * transfers it to the new message manager after a docShell swap. + * + * @param {string} message + * The name of the message to listen for. + * @param {nsIMessageListener} listener + * The listener to add. + * @param {boolean} [listenWhenClosed = false] + * If true, the listener will receive messages which were sent + * after the remote side of the listener began closing. + */ + addMessageListener(message, listener, listenWhenClosed = false) { + this.messageManager.addMessageListener(message, listener, listenWhenClosed); + this.listeners.get(message).set(listener, listenWhenClosed); + } + + /** + * Adds a message listener from the current message manager. + * + * @param {string} message + * The name of the message to stop listening for. + * @param {nsIMessageListener} listener + * The listener to remove. + */ + removeMessageListener(message, listener) { + this.messageManager.removeMessageListener(message, listener); + + let listeners = this.listeners.get(message); + listeners.delete(listener); + if (!listeners.size) { + this.listeners.delete(message); + } + } + + /** + * @private + * Iterates over all of the currently registered message listeners. + */ + * iterListeners() { + for (let [message, listeners] of this.listeners) { + for (let [listener, listenWhenClosed] of listeners) { + yield {message, listener, listenWhenClosed}; + } + } + } + + /** + * @private + * Adds docShell swap listeners to the message manager owner. + * + * @param {Element} target + * The target element. + */ + addListeners(target) { + target.addEventListener("SwapDocShells", this); + + for (let {message, listener, listenWhenClosed} of this.iterListeners()) { + target.addMessageListener(message, listener, listenWhenClosed); + } + + this.eventTarget = target; + } + + /** + * @private + * Removes docShell swap listeners to the message manager owner. + * + * @param {Element} target + * The target element. + */ + removeListeners(target) { + target.removeEventListener("SwapDocShells", this); + + for (let {message, listener} of this.iterListeners()) { + target.removeMessageListener(message, listener); + } + } + + handleEvent(event) { + if (event.type == "SwapDocShells") { + this.removeListeners(this.eventTarget); + this.addListeners(event.detail); + } + } +} + +this.ExtensionUtils = { + defineLazyGetter, + flushJarCache, + getConsole, + getInnerWindowID, + getMessageManager, + getUniqueId, + filterStack, + getWinUtils, + instanceOf, + normalizeTime, + promiseDocumentLoaded, + promiseDocumentReady, + promiseEvent, + promiseObserved, + runSafe, + runSafeSync, + runSafeSyncWithoutClone, + runSafeWithoutClone, + DefaultMap, + DefaultWeakMap, + EventEmitter, + ExtensionError, + LimitedSet, + MessageManagerProxy, +}; diff --git a/toolkit/components/extensions/ExtensionXPCShellUtils.jsm b/toolkit/components/extensions/ExtensionXPCShellUtils.jsm new file mode 100644 index 0000000000..e55705ca2a --- /dev/null +++ b/toolkit/components/extensions/ExtensionXPCShellUtils.jsm @@ -0,0 +1,677 @@ +/* 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/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["ExtensionTestUtils"]; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Components.utils.import("resource://gre/modules/ExtensionUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AddonTestUtils", + "resource://testing-common/AddonTestUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Extension", + "resource://gre/modules/Extension.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Schemas", + "resource://gre/modules/Schemas.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyGetter(this, "Management", () => { + const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {}); + return Management; +}); + +/* exported ExtensionTestUtils */ + +const { + promiseDocumentLoaded, + promiseEvent, + promiseObserved, +} = ExtensionUtils; + +var REMOTE_CONTENT_SCRIPTS = false; + +let BASE_MANIFEST = Object.freeze({ + "applications": Object.freeze({ + "gecko": Object.freeze({ + "id": "test@web.ext", + }), + }), + + "manifest_version": 2, + + "name": "name", + "version": "0", +}); + + +function frameScript() { + Components.utils.import("resource://gre/modules/Services.jsm"); + + Services.obs.notifyObservers(this, "tab-content-frameloader-created"); +} + +const FRAME_SCRIPT = `data:text/javascript,(${encodeURI(frameScript)}).call(this)`; + + +const XUL_URL = "data:application/vnd.mozilla.xul+xml;charset=utf-8," + encodeURI( + ` + `); + +let kungFuDeathGrip = new Set(); +function promiseBrowserLoaded(browser, url) { + return new Promise(resolve => { + const listener = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsISupportsWeakReference, Ci.nsIWebProgressListener]), + + onStateChange(webProgress, request, stateFlags, statusCode) { + let requestUrl = request.URI ? request.URI.spec : webProgress.DOMWindow.location.href; + + if (webProgress.isTopLevel && requestUrl === url && + (stateFlags & Ci.nsIWebProgressListener.STATE_STOP)) { + resolve(); + kungFuDeathGrip.delete(listener); + browser.removeProgressListener(listener); + } + }, + }; + + // addProgressListener only supports weak references, so we need to + // use one. But we also need to make sure it stays alive until we're + // done with it, so thunk away a strong reference to keep it alive. + kungFuDeathGrip.add(listener); + browser.addProgressListener(listener, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW); + }); +} + +class ContentPage { + constructor(remote = REMOTE_CONTENT_SCRIPTS) { + this.remote = remote; + + this.browserReady = this._initBrowser(); + } + + async _initBrowser() { + this.windowlessBrowser = Services.appShell.createWindowlessBrowser(true); + + let system = Services.scriptSecurityManager.getSystemPrincipal(); + + let chromeShell = this.windowlessBrowser.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIWebNavigation); + + chromeShell.createAboutBlankContentViewer(system); + chromeShell.useGlobalHistory = false; + chromeShell.loadURI(XUL_URL, 0, null, null, null); + + await promiseObserved("chrome-document-global-created", + win => win.document == chromeShell.document); + + let chromeDoc = await promiseDocumentLoaded(chromeShell.document); + + let browser = chromeDoc.createElement("browser"); + browser.setAttribute("type", "content"); + browser.setAttribute("disableglobalhistory", "true"); + + let awaitFrameLoader = Promise.resolve(); + if (this.remote) { + awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated"); + browser.setAttribute("remote", "true"); + } + + chromeDoc.documentElement.appendChild(browser); + + await awaitFrameLoader; + browser.messageManager.loadFrameScript(FRAME_SCRIPT, true); + + this.browser = browser; + return browser; + } + + async loadURL(url) { + await this.browserReady; + + this.browser.loadURI(url); + return promiseBrowserLoaded(this.browser, url); + } + + async close() { + await this.browserReady; + + this.browser = null; + + this.windowlessBrowser.close(); + this.windowlessBrowser = null; + } +} + +class ExtensionWrapper { + constructor(testScope, extension = null) { + this.testScope = testScope; + + this.extension = null; + + this.handleResult = this.handleResult.bind(this); + this.handleMessage = this.handleMessage.bind(this); + + this.state = "uninitialized"; + + this.testResolve = null; + this.testDone = new Promise(resolve => { this.testResolve = resolve; }); + + this.messageHandler = new Map(); + this.messageAwaiter = new Map(); + + this.messageQueue = new Set(); + + + this.testScope.do_register_cleanup(() => { + this.clearMessageQueues(); + + if (this.state == "pending" || this.state == "running") { + this.testScope.equal(this.state, "unloaded", "Extension left running at test shutdown"); + return this.unload(); + } else if (this.state == "unloading") { + this.testScope.equal(this.state, "unloaded", "Extension not fully unloaded at test shutdown"); + } + this.destroy(); + }); + + if (extension) { + this.id = extension.id; + this.uuid = extension.uuid; + this.attachExtension(extension); + } + } + + destroy() { + // This method should be implemented in subclasses which need to + // perform cleanup when destroyed. + } + + attachExtension(extension) { + if (extension === this.extension) { + return; + } + + if (this.extension) { + this.extension.off("test-eq", this.handleResult); + this.extension.off("test-log", this.handleResult); + this.extension.off("test-result", this.handleResult); + this.extension.off("test-done", this.handleResult); + this.extension.off("test-message", this.handleMessage); + this.clearMessageQueues(); + } + this.extension = extension; + + extension.on("test-eq", this.handleResult); + extension.on("test-log", this.handleResult); + extension.on("test-result", this.handleResult); + extension.on("test-done", this.handleResult); + extension.on("test-message", this.handleMessage); + + this.testScope.do_print(`Extension attached`); + } + + clearMessageQueues() { + if (this.messageQueue.size) { + let names = Array.from(this.messageQueue, ([msg]) => msg); + this.testScope.equal(JSON.stringify(names), "[]", "message queue is empty"); + this.messageQueue.clear(); + } + if (this.messageAwaiter.size) { + let names = Array.from(this.messageAwaiter.keys()); + this.testScope.equal(JSON.stringify(names), "[]", "no tasks awaiting on messages"); + for (let promise of this.messageAwaiter.values()) { + promise.reject(); + } + this.messageAwaiter.clear(); + } + } + + handleResult(kind, pass, msg, expected, actual) { + switch (kind) { + case "test-eq": + this.testScope.ok(pass, `${msg} - Expected: ${expected}, Actual: ${actual}`); + break; + + case "test-log": + this.testScope.do_print(msg); + break; + + case "test-result": + this.testScope.ok(pass, msg); + break; + + case "test-done": + this.testScope.ok(pass, msg); + this.testResolve(msg); + break; + } + } + + handleMessage(kind, msg, ...args) { + let handler = this.messageHandler.get(msg); + if (handler) { + handler(...args); + } else { + this.messageQueue.add([msg, ...args]); + this.checkMessages(); + } + } + + awaitStartup() { + return this.startupPromise; + } + + startup() { + if (this.state != "uninitialized") { + throw new Error("Extension already started"); + } + this.state = "pending"; + + this.startupPromise = this.extension.startup().then( + result => { + this.state = "running"; + + return result; + }, + error => { + this.state = "failed"; + + return Promise.reject(error); + }); + + return this.startupPromise; + } + + async unload() { + if (this.state != "running") { + throw new Error("Extension not running"); + } + this.state = "unloading"; + + if (this.addon) { + this.addon.uninstall(); + } else { + await this.extension.shutdown(); + } + + this.state = "unloaded"; + } + + /* + * This method marks the extension unloading without actually calling + * shutdown, since shutting down a MockExtension causes it to be uninstalled. + * + * Normally you shouldn't need to use this unless you need to test something + * that requires a restart, such as updates. + */ + markUnloaded() { + if (this.state != "running") { + throw new Error("Extension not running"); + } + this.state = "unloaded"; + + return Promise.resolve(); + } + + sendMessage(...args) { + this.extension.testMessage(...args); + } + + awaitFinish(msg) { + return this.testDone.then(actual => { + if (msg) { + this.testScope.equal(actual, msg, "test result correct"); + } + return actual; + }); + } + + checkMessages() { + for (let message of this.messageQueue) { + let [msg, ...args] = message; + + let listener = this.messageAwaiter.get(msg); + if (listener) { + this.messageQueue.delete(message); + this.messageAwaiter.delete(msg); + + listener.resolve(...args); + return; + } + } + } + + checkDuplicateListeners(msg) { + if (this.messageHandler.has(msg) || this.messageAwaiter.has(msg)) { + throw new Error("only one message handler allowed"); + } + } + + awaitMessage(msg) { + return new Promise((resolve, reject) => { + this.checkDuplicateListeners(msg); + + this.messageAwaiter.set(msg, {resolve, reject}); + this.checkMessages(); + }); + } + + onMessage(msg, callback) { + this.checkDuplicateListeners(msg); + this.messageHandler.set(msg, callback); + } +} + +class AOMExtensionWrapper extends ExtensionWrapper { + constructor(testScope, xpiFile, installType) { + super(testScope); + + this.onEvent = this.onEvent.bind(this); + + this.file = xpiFile; + this.installType = installType; + + this.cleanupFiles = [xpiFile]; + + Management.on("ready", this.onEvent); + Management.on("shutdown", this.onEvent); + Management.on("startup", this.onEvent); + + AddonTestUtils.on("addon-manager-shutdown", this.onEvent); + AddonTestUtils.on("addon-manager-started", this.onEvent); + + AddonManager.addAddonListener(this); + } + + destroy() { + this.id = null; + this.addon = null; + + Management.off("ready", this.onEvent); + Management.off("shutdown", this.onEvent); + Management.off("startup", this.onEvent); + + AddonTestUtils.off("addon-manager-shutdown", this.onEvent); + AddonTestUtils.off("addon-manager-started", this.onEvent); + + AddonManager.removeAddonListener(this); + + for (let file of this.cleanupFiles.splice(0)) { + try { + Services.obs.notifyObservers(file, "flush-cache-entry"); + file.remove(false); + } catch (e) { + Cu.reportError(e); + } + } + } + + setRestarting() { + if (this.state !== "restarting") { + this.startupPromise = new Promise(resolve => { + this.resolveStartup = resolve; + }); + } + this.state = "restarting"; + } + + onEnabling(addon) { + if (addon.id === this.id) { + this.setRestarting(); + } + } + + onInstalling(addon) { + if (addon.id === this.id) { + this.setRestarting(); + } + } + + onInstalled(addon) { + if (addon.id === this.id) { + this.addon = addon; + } + } + + onUninstalled(addon) { + if (addon.id === this.id) { + this.destroy(); + } + } + + onEvent(kind, ...args) { + switch (kind) { + case "addon-manager-started": + AddonManager.getAddonByID(this.id).then(addon => { + this.addon = addon; + }); + // FALLTHROUGH + case "addon-manager-shutdown": + this.addon = null; + + this.setRestarting(); + break; + + case "startup": { + let [extension] = args; + if (extension.id === this.id) { + this.attachExtension(extension); + this.state = "pending"; + } + break; + } + + case "shutdown": { + let [extension] = args; + if (extension.id === this.id && this.state !== "restarting") { + this.state = "unloaded"; + } + break; + } + + case "ready": { + let [extension] = args; + if (extension.id === this.id) { + this.state = "running"; + this.resolveStartup(extension); + } + break; + } + } + } + + _install(xpiFile) { + if (this.installType === "temporary") { + return AddonManager.installTemporaryAddon(xpiFile).then(addon => { + this.id = addon.id; + this.addon = addon; + + return this.startupPromise; + }).catch(e => { + this.state = "unloaded"; + return Promise.reject(e); + }); + } else if (this.installType === "permanent") { + return AddonManager.getInstallForFile(xpiFile).then(install => { + let listener = { + onInstallFailed: () => { + this.state = "unloaded"; + this.resolveStartup(Promise.reject(new Error("Install failed"))); + }, + onInstallEnded: (install, newAddon) => { + this.id = newAddon.id; + this.addon = newAddon; + }, + }; + + install.addListener(listener); + install.install(); + + return this.startupPromise; + }); + } + } + + get version() { + return this.addon && this.addon.version; + } + + startup() { + if (this.state != "uninitialized") { + throw new Error("Extension already started"); + } + + this.state = "pending"; + this.startupPromise = new Promise(resolve => { + this.resolveStartup = resolve; + }); + + return this._install(this.file); + } + + upgrade(data) { + this.startupPromise = new Promise(resolve => { + this.resolveStartup = resolve; + }); + this.state = "restarting"; + + let xpiFile = Extension.generateXPI(data); + + this.cleanupFiles.push(xpiFile); + + return this._install(xpiFile); + } +} + +var ExtensionTestUtils = { + BASE_MANIFEST, + + async normalizeManifest(manifest, baseManifest = BASE_MANIFEST) { + await Management.lazyInit(); + + let errors = []; + let context = { + url: null, + + logError: error => { + errors.push(error); + }, + + preprocessors: {}, + }; + + manifest = Object.assign({}, baseManifest, manifest); + + let normalized = Schemas.normalize(manifest, "manifest.WebExtensionManifest", context); + normalized.errors = errors; + + return normalized; + }, + + currentScope: null, + + profileDir: null, + + init(scope) { + this.currentScope = scope; + + this.profileDir = scope.do_get_profile(); + + // We need to load at least one frame script into every message + // manager to ensure that the scriptable wrapper for its global gets + // created before we try to access it externally. If we don't, we + // fail sanity checks on debug builds the first time we try to + // create a wrapper, because we should never have a global without a + // cached wrapper. + Services.mm.loadFrameScript("data:text/javascript,//", true); + + + let tmpD = this.profileDir.clone(); + tmpD.append("tmp"); + tmpD.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + let dirProvider = { + getFile(prop, persistent) { + persistent.value = false; + if (prop == "TmpD") { + return tmpD.clone(); + } + return null; + }, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIDirectoryServiceProvider]), + }; + Services.dirsvc.registerProvider(dirProvider); + + + scope.do_register_cleanup(() => { + tmpD.remove(true); + Services.dirsvc.unregisterProvider(dirProvider); + + this.currentScope = null; + }); + }, + + addonManagerStarted: false, + + mockAppInfo() { + const {updateAppInfo} = Cu.import("resource://testing-common/AppInfo.jsm", {}); + updateAppInfo({ + ID: "xpcshell@tests.mozilla.org", + name: "XPCShell", + version: "48", + platformVersion: "48", + }); + }, + + startAddonManager() { + if (this.addonManagerStarted) { + return; + } + this.addonManagerStarted = true; + this.mockAppInfo(); + + let manager = Cc["@mozilla.org/addons/integration;1"].getService(Ci.nsIObserver) + .QueryInterface(Ci.nsITimerCallback); + manager.observe(null, "addons-startup", null); + }, + + loadExtension(data) { + if (data.useAddonManager) { + let xpiFile = Extension.generateXPI(data); + + return new AOMExtensionWrapper(this.currentScope, xpiFile, data.useAddonManager); + } + + let extension = Extension.generate(data); + + return new ExtensionWrapper(this.currentScope, extension); + }, + + get remoteContentScripts() { + return REMOTE_CONTENT_SCRIPTS; + }, + + set remoteContentScripts(val) { + REMOTE_CONTENT_SCRIPTS = !!val; + }, + + loadContentPage(url, remote = undefined) { + let contentPage = new ContentPage(remote); + + return contentPage.loadURL(url).then(() => { + return contentPage; + }); + }, +}; diff --git a/toolkit/components/extensions/LegacyExtensionsUtils.jsm b/toolkit/components/extensions/LegacyExtensionsUtils.jsm new file mode 100644 index 0000000000..ca4e160b9c --- /dev/null +++ b/toolkit/components/extensions/LegacyExtensionsUtils.jsm @@ -0,0 +1,251 @@ +/* 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/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["LegacyExtensionsUtils"]; + +/* exported LegacyExtensionsUtils, LegacyExtensionContext */ + +/** + * This file exports helpers for Legacy Extensions that want to embed a webextensions + * and exchange messages with the embedded WebExtension. + */ + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Extension", + "resource://gre/modules/Extension.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionChild", + "resource://gre/modules/ExtensionChild.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +Cu.import("resource://gre/modules/ExtensionCommon.jsm"); + +var { + BaseContext, +} = ExtensionCommon; + +/** + * Instances created from this class provide to a legacy extension + * a simple API to exchange messages with a webextension. + */ +var LegacyExtensionContext = class extends BaseContext { + /** + * Create a new LegacyExtensionContext given a target Extension instance. + * + * @param {Extension} targetExtension + * The webextension instance associated with this context. This will be the + * instance of the newly created embedded webextension when this class is + * used through the EmbeddedWebExtensionsUtils. + */ + constructor(targetExtension) { + super("legacy_extension", targetExtension); + + // Legacy Extensions (xul overlays, bootstrap restartless and Addon SDK) + // runs with a systemPrincipal. + let addonPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + Object.defineProperty( + this, "principal", + {value: addonPrincipal, enumerable: true, configurable: true} + ); + + let cloneScope = Cu.Sandbox(this.principal, {}); + Cu.setSandboxMetadata(cloneScope, {addonId: targetExtension.id}); + Object.defineProperty( + this, "cloneScope", + {value: cloneScope, enumerable: true, configurable: true, writable: true} + ); + + let sender = {id: targetExtension.id}; + let filter = {extensionId: targetExtension.id}; + // Legacy addons live in the main process. Messages from other addons are + // Messages from WebExtensions are sent to the main process and forwarded via + // the parent process manager to the legacy extension. + this.messenger = new ExtensionChild.Messenger(this, [Services.cpmm], sender, filter); + + this.api = { + browser: { + runtime: { + onConnect: this.messenger.onConnect("runtime.onConnect"), + onMessage: this.messenger.onMessage("runtime.onMessage"), + }, + }, + }; + } + + /** + * This method is called when the extension shuts down or is unloaded, + * and it nukes the cloneScope sandbox, if any. + */ + unload() { + if (this.unloaded) { + throw new Error("Error trying to unload LegacyExtensionContext twice."); + } + super.unload(); + Cu.nukeSandbox(this.cloneScope); + this.cloneScope = null; + } +}; + +var EmbeddedExtensionManager; + +/** + * Instances of this class are used internally by the exported EmbeddedWebExtensionsUtils + * to manage the embedded webextension instance and the related LegacyExtensionContext + * instance used to exchange messages with it. + */ +class EmbeddedExtension { + /** + * Create a new EmbeddedExtension given the add-on id and the base resource URI of the + * container add-on (the webextension resources will be loaded from the "webextension/" + * subdir of the base resource URI for the legacy extension add-on). + * + * @param {Object} containerAddonParams + * An object with the following properties: + * @param {string} containerAddonParams.id + * The Add-on id of the Legacy Extension which will contain the embedded webextension. + * @param {string} containerAddonParams.version + * The add-on version. + * @param {nsIURI} containerAddonParams.resourceURI + * The nsIURI of the Legacy Extension container add-on. + */ + constructor({id, resourceURI, version}) { + this.addonId = id; + this.resourceURI = resourceURI; + this.version = version; + + // Setup status flag. + this.started = false; + } + + /** + * Start the embedded webextension. + * + * @returns {Promise} A promise which resolve to the API exposed to the + * legacy context. + */ + startup() { + if (this.started) { + return Promise.reject(new Error("This embedded extension has already been started")); + } + + // Setup the startup promise. + this.startupPromise = new Promise((resolve, reject) => { + let embeddedExtensionURI = Services.io.newURI("webextension/", null, this.resourceURI); + + // This is the instance of the WebExtension embedded in the hybrid add-on. + this.extension = new Extension({ + id: this.addonId, + resourceURI: embeddedExtensionURI, + version: this.version, + }); + + // This callback is register to the "startup" event, emitted by the Extension instance + // after the extension manifest.json has been loaded without any errors, but before + // starting any of the defined contexts (which give the legacy part a chance to subscribe + // runtime.onMessage/onConnect listener before the background page has been loaded). + const onBeforeStarted = () => { + this.extension.off("startup", onBeforeStarted); + + // Resolve the startup promise and reset the startupError. + this.started = true; + this.startupPromise = null; + + // Create the legacy extension context, the legacy container addon + // needs to use it before the embedded webextension startup, + // because it is supposed to be used during the legacy container startup + // to subscribe its message listeners (which are supposed to be able to + // receive any message that the embedded part can try to send to it + // during its startup). + this.context = new LegacyExtensionContext(this.extension); + + // Destroy the LegacyExtensionContext cloneScope when + // the embedded webextensions is unloaded. + this.extension.callOnClose({ + close: () => { + this.context.unload(); + }, + }); + + // resolve startupPromise to execute any pending shutdown that has been + // chained to it. + resolve(this.context.api); + }; + + this.extension.on("startup", onBeforeStarted); + + // Run ambedded extension startup and catch any error during embedded extension + // startup. + this.extension.startup().catch((err) => { + this.started = false; + this.startupPromise = null; + this.extension.off("startup", onBeforeStarted); + + reject(err); + }); + }); + + return this.startupPromise; + } + + /** + * Shuts down the embedded webextension. + * + * @returns {Promise} a promise that is resolved when the shutdown has been done + */ + shutdown() { + EmbeddedExtensionManager.untrackEmbeddedExtension(this); + + // If there is a pending startup, wait to be completed and then shutdown. + if (this.startupPromise) { + return this.startupPromise.then(() => { + this.extension.shutdown(); + }); + } + + // Run shutdown now if the embedded webextension has been correctly started + if (this.extension && this.started && !this.extension.hasShutdown) { + this.extension.shutdown(); + } + + return Promise.resolve(); + } +} + +// Keep track on the created EmbeddedExtension instances and destroy +// them when their container addon is going to be disabled or uninstalled. +EmbeddedExtensionManager = { + // Map of the existent EmbeddedExtensions instances by addon id. + embeddedExtensionsByAddonId: new Map(), + + untrackEmbeddedExtension(embeddedExtensionInstance) { + // Remove this instance from the tracked embedded extensions + let id = embeddedExtensionInstance.addonId; + if (this.embeddedExtensionsByAddonId.get(id) == embeddedExtensionInstance) { + this.embeddedExtensionsByAddonId.delete(id); + } + }, + + getEmbeddedExtensionFor({id, resourceURI, version}) { + let embeddedExtension = this.embeddedExtensionsByAddonId.get(id); + + if (!embeddedExtension) { + embeddedExtension = new EmbeddedExtension({id, resourceURI, version}); + // Keep track of the embedded extension instance. + this.embeddedExtensionsByAddonId.set(id, embeddedExtension); + } + + return embeddedExtension; + }, +}; + +this.LegacyExtensionsUtils = { + getEmbeddedExtensionFor: (addon) => { + return EmbeddedExtensionManager.getEmbeddedExtensionFor(addon); + }, +}; diff --git a/toolkit/components/extensions/MatchGlob.h b/toolkit/components/extensions/MatchGlob.h new file mode 100644 index 0000000000..2c9e3dcbe4 --- /dev/null +++ b/toolkit/components/extensions/MatchGlob.h @@ -0,0 +1,98 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#ifndef mozilla_extensions_MatchGlob_h +#define mozilla_extensions_MatchGlob_h + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/MatchGlobBinding.h" + +#include "jspubtd.h" +#include "js/RootingAPI.h" + +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +namespace mozilla { +namespace extensions { + +class MatchPattern; + +class MatchGlob final : public nsISupports + , public nsWrapperCache +{ +public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(MatchGlob) + + static already_AddRefed + Constructor(dom::GlobalObject& aGlobal, const nsAString& aGlob, bool aAllowQuestion, + ErrorResult& aRv); + + bool Matches(const nsAString& aString) const; + + bool IsWildcard() const + { + return mIsPrefix && mPathLiteral.IsEmpty(); + } + + void GetGlob(nsAString& aGlob) const + { + aGlob = mGlob; + } + + nsISupports* GetParentObject() const { return mParent; } + + virtual JSObject* WrapObject(JSContext* aCx, JS::HandleObject aGivenProto) override; + +protected: + virtual ~MatchGlob(); + +private: + friend class MatchPattern; + + explicit MatchGlob(nsISupports* aParent) : mParent(aParent) {} + + void Init(JSContext* aCx, const nsAString& aGlob, bool aAllowQuestion, ErrorResult& aRv); + + nsCOMPtr mParent; + + // The original glob string that this glob object represents. + nsString mGlob; + + // The literal path string to match against. If this contains a non-void + // value, the glob matches against this exact literal string, rather than + // performng a pattern match. If mIsPrefix is true, the literal must appear + // at the start of the matched string. If it is false, the the literal must + // be exactly equal to the matched string. + nsString mPathLiteral; + bool mIsPrefix = false; + + // The regular expression object which is equivalent to this glob pattern. + // Used for matching if, and only if, mPathLiteral is non-void. + JS::Heap mRegExp; +}; + +class MatchGlobSet final : public nsTArray> +{ +public: + // Note: We can't use the nsTArray constructors directly, since the static + // analyzer doesn't handle their MOZ_IMPLICIT annotations correctly. + MatchGlobSet() {} + explicit MatchGlobSet(size_type aCapacity) : nsTArray(aCapacity) {} + explicit MatchGlobSet(const nsTArray& aOther) : nsTArray(aOther) {} + MOZ_IMPLICIT MatchGlobSet(nsTArray&& aOther) : nsTArray(mozilla::Move(aOther)) {} + MOZ_IMPLICIT MatchGlobSet(std::initializer_list> aIL) : nsTArray(aIL) {} + + bool Matches(const nsAString& aValue) const; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_MatchGlob_h + diff --git a/toolkit/components/extensions/MatchPattern.cpp b/toolkit/components/extensions/MatchPattern.cpp new file mode 100644 index 0000000000..859b683314 --- /dev/null +++ b/toolkit/components/extensions/MatchPattern.cpp @@ -0,0 +1,758 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#include "mozilla/extensions/MatchPattern.h" +#include "mozilla/extensions/MatchGlob.h" + +#include "mozilla/dom/ScriptSettings.h" +#include "mozilla/HoldDropJSObjects.h" +#include "mozilla/Unused.h" + +#include "nsGkAtoms.h" +#include "nsIProtocolHandler.h" +#include "nsIURL.h" +#include "nsNetUtil.h" + +namespace mozilla { +namespace extensions { + +using namespace mozilla::dom; + + +/***************************************************************************** + * AtomSet + *****************************************************************************/ + +AtomSet::AtomSet(const nsTArray& aElems) +{ + mElems.SetCapacity(aElems.Length()); + + for (const auto& elem : aElems) { + mElems.AppendElement(NS_AtomizeMainThread(elem)); + } + + SortAndUniquify(); +} + +AtomSet::AtomSet(const char** aElems) +{ + for (const char** elemp = aElems; *elemp; elemp++) { + mElems.AppendElement(NS_Atomize(*elemp)); + } + + SortAndUniquify(); +} + +AtomSet::AtomSet(std::initializer_list aIL) +{ + mElems.SetCapacity(aIL.size()); + + for (const auto& elem : aIL) { + mElems.AppendElement(elem); + } + + SortAndUniquify(); +} + +void +AtomSet::SortAndUniquify() +{ + mElems.Sort(); + + nsIAtom* prev = nullptr; + mElems.RemoveElementsBy([&prev] (const RefPtr& aAtom) { + bool remove = aAtom == prev; + prev = aAtom; + return remove; + }); + + mElems.Compact(); +} + +bool +AtomSet::Intersects(const AtomSet& aOther) const +{ + for (const auto& atom : *this) { + if (aOther.Contains(atom)) { + return true; + } + } + for (const auto& atom : aOther) { + if (Contains(atom)) { + return true; + } + } + return false; +} + +void +AtomSet::Add(nsIAtom* aAtom) +{ + auto index = mElems.IndexOfFirstElementGt(aAtom); + if (index == 0 || mElems[index - 1] != aAtom) { + mElems.InsertElementAt(index, aAtom); + } +} + +void +AtomSet::Remove(nsIAtom* aAtom) +{ + auto index = mElems.BinaryIndexOf(aAtom); + if (index != mElems.NoIndex) { + mElems.RemoveElementAt(index); + } +} + + +/***************************************************************************** + * URLInfo + *****************************************************************************/ + +nsIAtom* +URLInfo::Scheme() const +{ + if (!mScheme) { + nsCString scheme; + if (NS_SUCCEEDED(mURI->GetScheme(scheme))) { + mScheme = NS_AtomizeMainThread(NS_ConvertASCIItoUTF16(scheme)); + } + } + return mScheme; +} + +const nsCString& +URLInfo::Host() const +{ + if (mHost.IsVoid()) { + Unused << mURI->GetHost(mHost); + } + return mHost; +} + +const nsString& +URLInfo::FilePath() const +{ + if (mFilePath.IsEmpty()) { + nsCString path; + nsCOMPtr url = do_QueryInterface(mURI); + if (url && NS_SUCCEEDED(url->GetFilePath(path))) { + AppendUTF8toUTF16(path, mFilePath); + } else { + mFilePath = Path(); + } + } + return mFilePath; +} + +const nsString& +URLInfo::Path() const +{ + if (mPath.IsEmpty()) { + nsCString path; + if (NS_SUCCEEDED(URINoRef()->GetPath(path))) { + AppendUTF8toUTF16(path, mPath); + } + } + return mPath; +} + +const nsString& +URLInfo::Spec() const +{ + if (mSpec.IsEmpty()) { + nsCString spec; + if (NS_SUCCEEDED(URINoRef()->GetSpec(spec))) { + AppendUTF8toUTF16(spec, mSpec); + } + } + return mSpec; +} + +nsIURI* +URLInfo::URINoRef() const +{ + if (!mURINoRef) { + if (NS_FAILED(mURI->CloneIgnoringRef(getter_AddRefs(mURINoRef)))) { + mURINoRef = mURI; + } + } + return mURINoRef; +} + +bool +URLInfo::InheritsPrincipal() const +{ + if (!mInheritsPrincipal.isSome()) { + // For our purposes, about:blank and about:srcdoc are treated as URIs that + // inherit principals. + bool inherits = Spec().EqualsLiteral("about:blank") || Spec().EqualsLiteral("about:srcdoc"); + + if (!inherits) { + nsresult rv = NS_URIChainHasFlags(mURI, nsIProtocolHandler::URI_INHERITS_SECURITY_CONTEXT, + &inherits); + Unused << NS_WARN_IF(NS_FAILED(rv)); + } + + mInheritsPrincipal.emplace(inherits); + } + return mInheritsPrincipal.ref(); +} + + +/***************************************************************************** + * CookieInfo + *****************************************************************************/ + +bool +CookieInfo::IsDomain() const +{ + if (mIsDomain.isNothing()) { + mIsDomain.emplace(false); + MOZ_ALWAYS_SUCCEEDS(mCookie->GetIsDomain(mIsDomain.ptr())); + } + return mIsDomain.ref(); +} + +bool +CookieInfo::IsSecure() const +{ + if (mIsSecure.isNothing()) { + mIsSecure.emplace(false); + MOZ_ALWAYS_SUCCEEDS(mCookie->GetIsSecure(mIsSecure.ptr())); + } + return mIsSecure.ref(); +} + +const nsCString& +CookieInfo::Host() const +{ + if (mHost.IsEmpty()) { + MOZ_ALWAYS_SUCCEEDS(mCookie->GetHost(mHost)); + } + return mHost; +} + +const nsCString& +CookieInfo::RawHost() const +{ + if (mRawHost.IsEmpty()) { + MOZ_ALWAYS_SUCCEEDS(mCookie->GetRawHost(mRawHost)); + } + return mRawHost; +} + + +/***************************************************************************** + * MatchPattern + *****************************************************************************/ + +const char* PERMITTED_SCHEMES[] = {"http", "https", "ws", "wss", "file", "ftp", "data", nullptr}; + +const char* WILDCARD_SCHEMES[] = {"http", "https", "ws", "wss", nullptr}; + +/* static */ already_AddRefed +MatchPattern::Constructor(dom::GlobalObject& aGlobal, + const nsAString& aPattern, + const MatchPatternOptions& aOptions, + ErrorResult& aRv) +{ + RefPtr pattern = new MatchPattern(aGlobal.GetAsSupports()); + pattern->Init(aGlobal.Context(), aPattern, aOptions.mIgnorePath, aRv); + if (aRv.Failed()) { + return nullptr; + } + return pattern.forget(); +} + +void +MatchPattern::Init(JSContext* aCx, const nsAString& aPattern, bool aIgnorePath, ErrorResult& aRv) +{ + RefPtr permittedSchemes = AtomSet::Get(); + + mPattern = aPattern; + + if (aPattern.EqualsLiteral("")) { + mSchemes = permittedSchemes; + mMatchSubdomain = true; + return; + } + + // The portion of the URL we're currently examining. + uint32_t offset = 0; + auto tail = Substring(aPattern, offset); + + /*************************************************************************** + * Scheme + ***************************************************************************/ + int32_t index = aPattern.FindChar(':'); + if (index <= 0) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + nsCOMPtr scheme = NS_AtomizeMainThread(StringHead(aPattern, index)); + if (scheme == nsGkAtoms::_asterisk) { + mSchemes = AtomSet::Get(); + } else if (permittedSchemes->Contains(scheme)) { + mSchemes = new AtomSet({scheme}); + } else { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + /*************************************************************************** + * Host + ***************************************************************************/ + offset = index + 1; + tail.Rebind(aPattern, offset); + + if (!StringHead(tail, 2).EqualsLiteral("//")) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + offset += 2; + tail.Rebind(aPattern, offset); + index = tail.FindChar('/'); + if (index < 0) { + index = tail.Length(); + } + + auto host = StringHead(tail, index); + if (host.IsEmpty() && !scheme->Equals(NS_LITERAL_STRING("file"))) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + offset += index; + tail.Rebind(aPattern, offset); + + if (host.EqualsLiteral("*")) { + mMatchSubdomain = true; + } else if (StringHead(host, 2).EqualsLiteral("*.")) { + mDomain = NS_ConvertUTF16toUTF8(Substring(host, 2)); + mMatchSubdomain = true; + } else { + mDomain = NS_ConvertUTF16toUTF8(host); + } + + /*************************************************************************** + * Path + ***************************************************************************/ + if (aIgnorePath) { + mPattern.Truncate(offset); + mPattern.AppendLiteral("/*"); + return; + } + + auto path = tail; + if (path.IsEmpty()) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + mPath = new MatchGlob(this); + mPath->Init(aCx, path, false, aRv); +} + + +bool +MatchPattern::MatchesDomain(const nsACString& aDomain) const +{ + if (DomainIsWildcard() || mDomain == aDomain) { + return true; + } + + if (mMatchSubdomain) { + int64_t offset = (int64_t)aDomain.Length() - mDomain.Length(); + if (offset > 0 && aDomain[offset - 1] == '.' && + Substring(aDomain, offset) == mDomain) { + return true; + } + } + + return false; +} + +bool +MatchPattern::Matches(const URLInfo& aURL, bool aExplicit) const +{ + if (aExplicit && mMatchSubdomain) { + return false; + } + + if (!mSchemes->Contains(aURL.Scheme())) { + return false; + } + + if (!DomainIsWildcard() && !MatchesDomain(aURL.Host())) { + return false; + } + + if (mPath && !mPath->IsWildcard() && !mPath->Matches(aURL.Path())) { + return false; + } + + return true; +} + +bool +MatchPattern::MatchesCookie(const CookieInfo& aCookie) const +{ + if (!mSchemes->Contains(NS_LITERAL_CSTRING("https")) && + (aCookie.IsSecure() || !mSchemes->Contains(NS_LITERAL_CSTRING("http")))) { + return false; + } + + if (MatchesDomain(aCookie.RawHost())) { + return true; + } + + if (!aCookie.IsDomain()) { + return false; + } + + // Things get tricker for domain cookies. The extension needs to be able + // to read any cookies that could be read by any host it has permissions + // for. This means that our normal host matching checks won't work, + // since the pattern "*://*.foo.example.com/" doesn't match ".example.com", + // but it does match "bar.foo.example.com", which can read cookies + // with the domain ".example.com". + // + // So, instead, we need to manually check our filters, and accept any + // with hosts that end with our cookie's host. + + auto& host = aCookie.Host(); + return StringTail(mDomain, host.Length()) == host; +} + +bool +MatchPattern::SubsumesDomain(const MatchPattern& aPattern) const +{ + if (!mMatchSubdomain && aPattern.mMatchSubdomain && aPattern.mDomain == mDomain) { + return false; + } + + return MatchesDomain(aPattern.mDomain); +} + +bool +MatchPattern::Subsumes(const MatchPattern& aPattern) const +{ + for (auto& scheme : *aPattern.mSchemes) { + if (!mSchemes->Contains(scheme)) { + return false; + } + } + + return SubsumesDomain(aPattern); +} + +bool +MatchPattern::Overlaps(const MatchPattern& aPattern) const +{ + if (!mSchemes->Intersects(*aPattern.mSchemes)) { + return false; + } + + return SubsumesDomain(aPattern) || aPattern.SubsumesDomain(*this); +} + + +JSObject* +MatchPattern::WrapObject(JSContext* aCx, JS::HandleObject aGivenProto) +{ + return MatchPatternBinding::Wrap(aCx, this, aGivenProto); +} + + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(MatchPattern, mPath, mParent) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MatchPattern) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(MatchPattern) +NS_IMPL_CYCLE_COLLECTING_RELEASE(MatchPattern) + + +/***************************************************************************** + * MatchPatternSet + *****************************************************************************/ + +/* static */ already_AddRefed +MatchPatternSet::Constructor(dom::GlobalObject& aGlobal, + const nsTArray& aPatterns, + const MatchPatternOptions& aOptions, + ErrorResult& aRv) +{ + ArrayType patterns; + + for (auto& elem : aPatterns) { + if (elem.IsMatchPattern()) { + patterns.AppendElement(elem.GetAsMatchPattern()); + } else { + RefPtr pattern = MatchPattern::Constructor( + aGlobal, elem.GetAsString(), aOptions, aRv); + + if (!pattern) { + return nullptr; + } + patterns.AppendElement(Move(pattern)); + } + } + + RefPtr patternSet = new MatchPatternSet(aGlobal.GetAsSupports(), + Move(patterns)); + return patternSet.forget(); +} + + +bool +MatchPatternSet::Matches(const URLInfo& aURL, bool aExplicit) const +{ + for (const auto& pattern : mPatterns) { + if (pattern->Matches(aURL, aExplicit)) { + return true; + } + } + return false; +} + +bool +MatchPatternSet::MatchesCookie(const CookieInfo& aCookie) const +{ + for (const auto& pattern : mPatterns) { + if (pattern->MatchesCookie(aCookie)) { + return true; + } + } + return false; +} + +bool +MatchPatternSet::Subsumes(const MatchPattern& aPattern) const +{ + for (const auto& pattern : mPatterns) { + if (pattern->Subsumes(aPattern)) { + return true; + } + } + return false; +} + +bool +MatchPatternSet::Overlaps(const MatchPatternSet& aPatternSet) const +{ + for (const auto& pattern : aPatternSet.mPatterns) { + if (Overlaps(*pattern)) { + return true; + } + } + return false; +} + +bool +MatchPatternSet::Overlaps(const MatchPattern& aPattern) const +{ + for (const auto& pattern : mPatterns) { + if (pattern->Overlaps(aPattern)) { + return true; + } + } + return false; +} + + +bool +MatchPatternSet::OverlapsAll(const MatchPatternSet& aPatternSet) const +{ + for (const auto& pattern : aPatternSet.mPatterns) { + if (!Overlaps(*pattern)) { + return false; + } + } + return aPatternSet.mPatterns.Length() > 0; +} + + +JSObject* +MatchPatternSet::WrapObject(JSContext* aCx, JS::HandleObject aGivenProto) +{ + return MatchPatternSetBinding::Wrap(aCx, this, aGivenProto); +} + + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(MatchPatternSet, mPatterns, mParent) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MatchPatternSet) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(MatchPatternSet) +NS_IMPL_CYCLE_COLLECTING_RELEASE(MatchPatternSet) + + +/***************************************************************************** + * MatchGlob + *****************************************************************************/ + +MatchGlob::~MatchGlob() +{ + mozilla::DropJSObjects(this); +} + +/* static */ already_AddRefed +MatchGlob::Constructor(dom::GlobalObject& aGlobal, + const nsAString& aGlob, + bool aAllowQuestion, + ErrorResult& aRv) +{ + RefPtr glob = new MatchGlob(aGlobal.GetAsSupports()); + glob->Init(aGlobal.Context(), aGlob, aAllowQuestion, aRv); + if (aRv.Failed()) { + return nullptr; + } + return glob.forget(); +} + +void +MatchGlob::Init(JSContext* aCx, const nsAString& aGlob, bool aAllowQuestion, ErrorResult& aRv) +{ + mGlob = aGlob; + + // Check for a literal match with no glob metacharacters. + auto index = mGlob.FindCharInSet(aAllowQuestion ? "*?" : "*"); + if (index < 0) { + mPathLiteral = mGlob; + return; + } + + // Check for a prefix match, where the only glob metacharacter is a "*" + // at the end of the string. + if (index == (int32_t)mGlob.Length() - 1 && mGlob[index] == '*') { + mPathLiteral = StringHead(mGlob, index); + mIsPrefix = true; + return; + } + + // Fall back to the regexp slow path. + NS_NAMED_LITERAL_CSTRING(metaChars, ".+*?^${}()|[]\\"); + + nsAutoString escaped; + escaped.Append('^'); + + for (uint32_t i = 0; i < mGlob.Length(); i++) { + auto c = mGlob[i]; + if (c == '*') { + escaped.AppendLiteral(".*"); + } else if (c == '?' && aAllowQuestion) { + escaped.Append('.'); + } else { + if (metaChars.Contains(c)) { + escaped.Append('\\'); + } + escaped.Append(c); + } + } + + escaped.Append('$'); + + // TODO: Switch to the Rust regexp crate, when Rust integration is easier. + // It uses a much more efficient, linear time matching algorithm, and + // doesn't require special casing for the literal and prefix cases. + mRegExp = JS_NewUCRegExpObject(aCx, escaped.get(), escaped.Length(), 0); + if (mRegExp) { + mozilla::HoldJSObjects(this); + } else { + aRv.NoteJSContextException(aCx); + } +} + +bool +MatchGlob::Matches(const nsAString& aString) const +{ + if (mRegExp) { + AutoJSAPI jsapi; + jsapi.Init(); + JSContext* cx = jsapi.cx(); + + JSAutoCompartment ac(cx, mRegExp); + + JS::RootedObject regexp(cx, mRegExp); + JS::RootedValue result(cx); + + nsString input(aString); + + size_t index = 0; + if (!JS_ExecuteRegExpNoStatics(cx, regexp, input.BeginWriting(), aString.Length(), + &index, true, &result)) { + return false; + } + + return result.isBoolean() && result.toBoolean(); + } + + if (mIsPrefix) { + return mPathLiteral == StringHead(aString, mPathLiteral.Length()); + } + + return mPathLiteral == aString; +} + + +JSObject* +MatchGlob::WrapObject(JSContext* aCx, JS::HandleObject aGivenProto) +{ + return MatchGlobBinding::Wrap(aCx, this, aGivenProto); +} + + +NS_IMPL_CYCLE_COLLECTION_CLASS(MatchGlob) + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(MatchGlob) + NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER + NS_IMPL_CYCLE_COLLECTION_UNLINK(mParent) + tmp->mRegExp = nullptr; +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(MatchGlob) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mParent) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN(MatchGlob) + NS_IMPL_CYCLE_COLLECTION_TRACE_PRESERVED_WRAPPER + NS_IMPL_CYCLE_COLLECTION_TRACE_JS_MEMBER_CALLBACK(mRegExp) +NS_IMPL_CYCLE_COLLECTION_TRACE_END + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MatchGlob) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(MatchGlob) +NS_IMPL_CYCLE_COLLECTING_RELEASE(MatchGlob) + + +/***************************************************************************** + * MatchGlobSet + *****************************************************************************/ + +bool +MatchGlobSet::Matches(const nsAString& aValue) const +{ + for (auto& glob : *this) { + if (glob->Matches(aValue)) { + return true; + } + } + return false; +} + +} // namespace extensions +} // namespace mozilla + diff --git a/toolkit/components/extensions/MatchPattern.h b/toolkit/components/extensions/MatchPattern.h new file mode 100644 index 0000000000..8b416519c7 --- /dev/null +++ b/toolkit/components/extensions/MatchPattern.h @@ -0,0 +1,326 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#ifndef mozilla_extensions_MatchPattern_h +#define mozilla_extensions_MatchPattern_h + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/MatchPatternBinding.h" +#include "mozilla/extensions/MatchGlob.h" + +#include "jspubtd.h" + +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/Likely.h" +#include "mozilla/Maybe.h" +#include "mozilla/RefCounted.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsTArray.h" +#include "nsIAtom.h" +#include "nsICookie2.h" +#include "nsISupports.h" +#include "nsIURI.h" +#include "nsWrapperCache.h" + + +namespace mozilla { +namespace extensions { + +using dom::MatchPatternOptions; + + +// A sorted, binary-search-backed set of atoms, optimized for frequent lookups +// and infrequent updates. +class AtomSet final : public RefCounted +{ + using ArrayType = AutoTArray, 1>; + +public: + MOZ_DECLARE_REFCOUNTED_TYPENAME(AtomSet) + + explicit AtomSet(const nsTArray& aElems); + + explicit AtomSet(const char** aElems); + + MOZ_IMPLICIT AtomSet(std::initializer_list aIL); + + bool Contains(const nsAString& elem) const + { + nsCOMPtr atom = NS_AtomizeMainThread(elem); + return Contains(atom); + } + + bool Contains(const nsACString& aElem) const + { + nsCOMPtr atom = NS_Atomize(aElem); + return Contains(atom); + } + + bool Contains(const nsIAtom* aAtom) const + { + return mElems.BinaryIndexOf(aAtom) != mElems.NoIndex; + } + + bool Intersects(const AtomSet& aOther) const; + + + void Add(nsIAtom* aElem); + void Remove(nsIAtom* aElem); + + void Add(const nsAString& aElem) + { + nsCOMPtr atom = NS_AtomizeMainThread(aElem); + return Add(atom); + } + + void Remove(const nsAString& aElem) + { + nsCOMPtr atom = NS_AtomizeMainThread(aElem); + return Remove(atom); + } + + // Returns a cached, statically-allocated matcher for the given set of + // literal strings. + template + static already_AddRefed + Get() + { + static RefPtr sMatcher; + + if (MOZ_UNLIKELY(!sMatcher)) { + sMatcher = new AtomSet(schemes); + ClearOnShutdown(&sMatcher); + } + + return do_AddRef(sMatcher); + } + + void + Get(nsTArray& aResult) const + { + aResult.SetCapacity(mElems.Length()); + + for (const auto& atom : mElems) { + aResult.AppendElement(nsDependentAtomString(atom)); + } + } + + auto begin() const + -> decltype(DeclVal().begin()) + { + return mElems.begin(); + } + + auto end() const + -> decltype(DeclVal().end()) + { + return mElems.end(); + } + +private: + ArrayType mElems; + + void SortAndUniquify(); +}; + + +// A helper class to lazily retrieve, transcode, and atomize certain URI +// properties the first time they're used, and cache the results, so that they +// can be used across multiple match operations. +class MOZ_STACK_CLASS URLInfo final +{ +public: + MOZ_IMPLICIT URLInfo(nsIURI* aURI) + : mURI(aURI) + { + mHost.SetIsVoid(true); + } + + URLInfo(const URLInfo& aOther) + : URLInfo(aOther.mURI.get()) + {} + + nsIURI* URI() const { return mURI; } + + nsIAtom* Scheme() const; + const nsCString& Host() const; + const nsString& Path() const; + const nsString& FilePath() const; + const nsString& Spec() const; + + bool InheritsPrincipal() const; + +private: + nsIURI* URINoRef() const; + + nsCOMPtr mURI; + mutable nsCOMPtr mURINoRef; + + mutable nsCOMPtr mScheme; + mutable nsCString mHost; + + mutable nsAutoString mPath; + mutable nsAutoString mFilePath; + mutable nsAutoString mSpec; + + mutable Maybe mInheritsPrincipal; +}; + + +// Similar to URLInfo, but for cookies. +class MOZ_STACK_CLASS CookieInfo final +{ +public: + MOZ_IMPLICIT CookieInfo(nsICookie2* aCookie) + : mCookie(aCookie) + {} + + bool IsSecure() const; + bool IsDomain() const; + + const nsCString& Host() const; + const nsCString& RawHost() const; + +private: + nsCOMPtr mCookie; + + mutable Maybe mIsSecure; + mutable Maybe mIsDomain; + + mutable nsCString mHost; + mutable nsCString mRawHost; +}; + + +class MatchPattern final : public nsISupports + , public nsWrapperCache +{ + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(MatchPattern) + + static already_AddRefed + Constructor(dom::GlobalObject& aGlobal, + const nsAString& aPattern, + const MatchPatternOptions& aOptions, + ErrorResult& aRv); + + bool Matches(const URLInfo& aURL, bool aExplicit = false) const; + + bool MatchesCookie(const CookieInfo& aCookie) const; + + bool MatchesDomain(const nsACString& aDomain) const; + + bool Subsumes(const MatchPattern& aPattern) const; + + bool Overlaps(const MatchPattern& aPattern) const; + + bool DomainIsWildcard() const + { + return mMatchSubdomain && mDomain.IsEmpty(); + } + + void GetPattern(nsAString& aPattern) const + { + aPattern = mPattern; + } + + nsISupports* GetParentObject() const { return mParent; } + + virtual JSObject* WrapObject(JSContext* aCx, JS::HandleObject aGivenProto) override; + +protected: + virtual ~MatchPattern() = default; + +private: + explicit MatchPattern(nsISupports* aParent) : mParent(aParent) {} + + void Init(JSContext* aCx, const nsAString& aPattern, bool aIgnorePath, ErrorResult& aRv); + + bool SubsumesDomain(const MatchPattern& aPattern) const; + + + nsCOMPtr mParent; + + // The normalized match pattern string that this object represents. + nsString mPattern; + + // The set of atomized URI schemes that this pattern matches. + RefPtr mSchemes; + + // The domain that this matcher matches. If mMatchSubdomain is false, only + // matches the exact domain. If it's true, matches the domain or any + // subdomain. + // + // For instance, "*.foo.com" gives mDomain = "foo.com" and mMatchSubdomain = true, + // and matches "foo.com" or "bar.foo.com" but not "barfoo.com". + // + // While "foo.com" gives mDomain = "foo.com" and mMatchSubdomain = false, + // and matches "foo.com" but not "bar.foo.com". + nsCString mDomain; + bool mMatchSubdomain = false; + + // The glob against which the URL path must match. If null, the path is + // ignored entirely. If non-null, the path must match this glob. + RefPtr mPath; +}; + + +class MatchPatternSet final : public nsISupports + , public nsWrapperCache +{ + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(MatchPatternSet) + + using ArrayType = nsTArray>; + + + static already_AddRefed + Constructor(dom::GlobalObject& aGlobal, + const nsTArray& aPatterns, + const MatchPatternOptions& aOptions, + ErrorResult& aRv); + + + bool Matches(const URLInfo& aURL, bool aExplicit = false) const; + + bool MatchesCookie(const CookieInfo& aCookie) const; + + bool Subsumes(const MatchPattern& aPattern) const; + + bool Overlaps(const MatchPattern& aPattern) const; + + bool Overlaps(const MatchPatternSet& aPatternSet) const; + + bool OverlapsAll(const MatchPatternSet& aPatternSet) const; + + void GetPatterns(ArrayType& aPatterns) + { + aPatterns.AppendElements(mPatterns); + } + + + nsISupports* GetParentObject() const { return mParent; } + + virtual JSObject* WrapObject(JSContext* aCx, JS::HandleObject aGivenProto) override; + +protected: + virtual ~MatchPatternSet() = default; + +private: + explicit MatchPatternSet(nsISupports* aParent, ArrayType&& aPatterns) + : mParent(aParent) + , mPatterns(Forward(aPatterns)) + {} + + nsCOMPtr mParent; + + ArrayType mPatterns; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_MatchPattern_h diff --git a/toolkit/components/extensions/MessageChannel.jsm b/toolkit/components/extensions/MessageChannel.jsm new file mode 100644 index 0000000000..3883566faa --- /dev/null +++ b/toolkit/components/extensions/MessageChannel.jsm @@ -0,0 +1,827 @@ +/* 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/. */ + +"use strict"; + +/** + * This module provides wrappers around standard message managers to + * simplify bidirectional communication. It currently allows a caller to + * send a message to a single listener, and receive a reply. If there + * are no matching listeners, or the message manager disconnects before + * a reply is received, the caller is returned an error. + * + * The listener end may specify filters for the messages it wishes to + * receive, and the sender end likewise may specify recipient tags to + * match the filters. + * + * The message handler on the listener side may return its response + * value directly, or may return a promise, the resolution or rejection + * of which will be returned instead. The sender end likewise receives a + * promise which resolves or rejects to the listener's response. + * + * + * A basic setup works something like this: + * + * A content script adds a message listener to its global + * nsIContentFrameMessageManager, with an appropriate set of filters: + * + * { + * init(messageManager, window, extensionID) { + * this.window = window; + * + * MessageChannel.addListener( + * messageManager, "ContentScript:TouchContent", + * this); + * + * this.messageFilterStrict = { + * innerWindowID: getInnerWindowID(window), + * extensionID: extensionID, + * }; + * + * this.messageFilterPermissive = { + * outerWindowID: getOuterWindowID(window), + * }; + * }, + * + * receiveMessage({ target, messageName, sender, recipient, data }) { + * if (messageName == "ContentScript:TouchContent") { + * return new Promise(resolve => { + * this.touchWindow(data.touchWith, result => { + * resolve({ touchResult: result }); + * }); + * }); + * } + * }, + * }; + * + * A script in the parent process sends a message to the content process + * via a tab message manager, including recipient tags to match its + * filter, and an optional sender tag to identify itself: + * + * let data = { touchWith: "pencil" }; + * let sender = { extensionID, contextID }; + * let recipient = { innerWindowID: tab.linkedBrowser.innerWindowID, extensionID }; + * + * MessageChannel.sendMessage( + * tab.linkedBrowser.messageManager, "ContentScript:TouchContent", + * data, {recipient, sender} + * ).then(result => { + * alert(result.touchResult); + * }); + * + * Since the lifetimes of message senders and receivers may not always + * match, either side of the message channel may cancel pending + * responses which match its sender or recipient tags. + * + * For the above client, this might be done from an + * inner-window-destroyed observer, when its target scope is destroyed: + * + * observe(subject, topic, data) { + * if (topic == "inner-window-destroyed") { + * let innerWindowID = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + * + * MessageChannel.abortResponses({ innerWindowID }); + * } + * }, + * + * From the parent, it may be done when its context is being destroyed: + * + * onDestroy() { + * MessageChannel.abortResponses({ + * extensionID: this.extensionID, + * contextID: this.contextID, + * }); + * }, + * + */ + +this.EXPORTED_SYMBOLS = ["MessageChannel"]; + +/* globals MessageChannel */ + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionUtils", + "resource://gre/modules/ExtensionUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils", + "resource://gre/modules/PromiseUtils.jsm"); + +XPCOMUtils.defineLazyGetter(this, "MessageManagerProxy", + () => ExtensionUtils.MessageManagerProxy); + +/** + * Handles the mapping and dispatching of messages to their registered + * handlers. There is one broker per message manager and class of + * messages. Each class of messages is mapped to one native message + * name, e.g., "MessageChannel:Message", and is dispatched to handlers + * based on an internal message name, e.g., "Extension:ExecuteScript". + */ +class FilteringMessageManager { + /** + * @param {string} messageName + * The name of the native message this broker listens for. + * @param {function} callback + * A function which is called for each message after it has been + * mapped to its handler. The function receives two arguments: + * + * result: + * An object containing either a `handler` or an `error` property. + * If no error occurs, `handler` will be a matching handler that + * was registered by `addHandler`. Otherwise, the `error` property + * will contain an object describing the error. + * + * data: + * An object describing the message, as defined in + * `MessageChannel.addListener`. + * @param {nsIMessageListenerManager} messageManager + */ + constructor(messageName, callback, messageManager) { + this.messageName = messageName; + this.callback = callback; + this.messageManager = messageManager; + + this.messageManager.addMessageListener(this.messageName, this, true); + + this.handlers = new Map(); + } + + /** + * Receives a message from our message manager, maps it to a handler, and + * passes the result to our message callback. + */ + receiveMessage({data, target}) { + let handlers = Array.from(this.getHandlers(data.messageName, data.sender || null, data.recipient)); + + data.target = target; + this.callback(handlers, data); + } + + /** + * Iterates over all handlers for the given message name. If `recipient` + * is provided, only iterates over handlers whose filters match it. + * + * @param {string|number} messageName + * The message for which to return handlers. + * @param {object} sender + * The sender data on which to filter handlers. + * @param {object} recipient + * The recipient data on which to filter handlers. + */ + * getHandlers(messageName, sender, recipient) { + let handlers = this.handlers.get(messageName) || new Set(); + for (let handler of handlers) { + if (MessageChannel.matchesFilter(handler.messageFilterStrict || {}, recipient) && + MessageChannel.matchesFilter(handler.messageFilterPermissive || {}, recipient, false) && + (!handler.filterMessage || handler.filterMessage(sender, recipient))) { + yield handler; + } + } + } + + /** + * Registers a handler for the given message. + * + * @param {string} messageName + * The internal message name for which to register the handler. + * @param {object} handler + * An opaque handler object. The object may have a + * `messageFilterStrict` and/or a `messageFilterPermissive` + * property and/or a `filterMessage` method on which to filter messages. + * + * Final dispatching is handled by the message callback passed to + * the constructor. + */ + addHandler(messageName, handler) { + if (!this.handlers.has(messageName)) { + this.handlers.set(messageName, new Set()); + } + + this.handlers.get(messageName).add(handler); + } + + /** + * Unregisters a handler for the given message. + * + * @param {string} messageName + * The internal message name for which to unregister the handler. + * @param {object} handler + * The handler object to unregister. + */ + removeHandler(messageName, handler) { + this.handlers.get(messageName).delete(handler); + } +} + +/** + * Manages mappings of message managers to their corresponding message + * brokers. Brokers are lazily created for each message manager the + * first time they are accessed. In the case of content frame message + * managers, they are also automatically destroyed when the frame + * unload event fires. + */ +class FilteringMessageManagerMap extends Map { + // Unfortunately, we can't use a WeakMap for this, because message + // managers do not support preserved wrappers. + + /** + * @param {string} messageName + * The native message name passed to `FilteringMessageManager` constructors. + * @param {function} callback + * The message callback function passed to + * `FilteringMessageManager` constructors. + */ + constructor(messageName, callback) { + super(); + + this.messageName = messageName; + this.callback = callback; + } + + /** + * Returns, and possibly creates, a message broker for the given + * message manager. + * + * @param {nsIMessageListenerManager} target + * The message manager for which to return a broker. + * + * @returns {FilteringMessageManager} + */ + get(target) { + if (this.has(target)) { + return super.get(target); + } + + let broker = new FilteringMessageManager(this.messageName, this.callback, target); + this.set(target, broker); + + if (target instanceof Ci.nsIDOMEventTarget) { + let onUnload = event => { + target.removeEventListener("unload", onUnload); + this.delete(target); + }; + target.addEventListener("unload", onUnload); + } + + return broker; + } +} + +const MESSAGE_MESSAGE = "MessageChannel:Message"; +const MESSAGE_RESPONSE = "MessageChannel:Response"; + +this.MessageChannel = { + init() { + Services.obs.addObserver(this, "message-manager-close"); + Services.obs.addObserver(this, "message-manager-disconnect"); + + this.messageManagers = new FilteringMessageManagerMap( + MESSAGE_MESSAGE, this._handleMessage.bind(this)); + + this.responseManagers = new FilteringMessageManagerMap( + MESSAGE_RESPONSE, this._handleResponse.bind(this)); + + /** + * Contains a list of pending responses, either waiting to be + * received or waiting to be sent. @see _addPendingResponse + */ + this.pendingResponses = new Set(); + + /** + * Contains the message name of a limited number of aborted response + * handlers, the responses for which will be ignored. + */ + this.abortedResponses = new ExtensionUtils.LimitedSet(30); + }, + + RESULT_SUCCESS: 0, + RESULT_DISCONNECTED: 1, + RESULT_NO_HANDLER: 2, + RESULT_MULTIPLE_HANDLERS: 3, + RESULT_ERROR: 4, + RESULT_NO_RESPONSE: 5, + + REASON_DISCONNECTED: { + result: 1, // this.RESULT_DISCONNECTED + message: "Message manager disconnected", + }, + + /** + * Specifies that only a single listener matching the specified + * recipient tag may be listening for the given message, at the other + * end of the target message manager. + * + * If no matching listeners exist, a RESULT_NO_HANDLER error will be + * returned. If multiple matching listeners exist, a + * RESULT_MULTIPLE_HANDLERS error will be returned. + */ + RESPONSE_SINGLE: 0, + + /** + * If multiple message managers matching the specified recipient tag + * are listening for a message, all listeners are notified, but only + * the first response or error is returned. + * + * Only handlers which return a value other than `undefined` are + * considered to have responded. Returning a Promise which evaluates + * to `undefined` is interpreted as an explicit response. + * + * If no matching listeners exist, a RESULT_NO_HANDLER error will be + * returned. If no listeners return a response, a RESULT_NO_RESPONSE + * error will be returned. + */ + RESPONSE_FIRST: 1, + + /** + * If multiple message managers matching the specified recipient tag + * are listening for a message, all listeners are notified, and all + * responses are returned as an array, once all listeners have + * replied. + */ + RESPONSE_ALL: 2, + + /** + * Fire-and-forget: The sender of this message does not expect a reply. + */ + RESPONSE_NONE: 3, + + /** + * Initializes message handlers for the given message managers if needed. + * + * @param {Array} messageManagers + */ + setupMessageManagers(messageManagers) { + for (let mm of messageManagers) { + // This call initializes a FilteringMessageManager for |mm| if needed. + // The FilteringMessageManager must be created to make sure that senders + // of messages that expect a reply, such as MessageChannel:Message, do + // actually receive a default reply even if there are no explicit message + // handlers. + this.messageManagers.get(mm); + } + }, + + /** + * Returns true if the properties of the `data` object match those in + * the `filter` object. Matching is done on a strict equality basis, + * and the behavior varies depending on the value of the `strict` + * parameter. + * + * @param {object} filter + * The filter object to match against. + * @param {object} data + * The data object being matched. + * @param {boolean} [strict=true] + * If true, all properties in the `filter` object have a + * corresponding property in `data` with the same value. If + * false, properties present in both objects must have the same + * value. + * @returns {boolean} True if the objects match. + */ + matchesFilter(filter, data, strict = true) { + if (strict) { + return Object.keys(filter).every(key => { + return key in data && data[key] === filter[key]; + }); + } + return Object.keys(filter).every(key => { + return !(key in data) || data[key] === filter[key]; + }); + }, + + /** + * Adds a message listener to the given message manager. + * + * @param {nsIMessageListenerManager|Array} targets + * The message managers on which to listen. + * @param {string|number} messageName + * The name of the message to listen for. + * @param {MessageReceiver} handler + * The handler to dispatch to. Must be an object with the following + * properties: + * + * receiveMessage: + * A method which is called for each message received by the + * listener. The method takes one argument, an object, with the + * following properties: + * + * messageName: + * The internal message name, as passed to `sendMessage`. + * + * target: + * The message manager which received this message. + * + * channelId: + * The internal ID of the transaction, used to map responses to + * the original sender. + * + * sender: + * An object describing the sender, as passed to `sendMessage`. + * + * recipient: + * An object describing the recipient, as passed to + * `sendMessage`. + * + * data: + * The contents of the message, as passed to `sendMessage`. + * + * The method may return any structured-clone-compatible + * object, which will be returned as a response to the message + * sender. It may also instead return a `Promise`, the + * resolution or rejection value of which will likewise be + * returned to the message sender. + * + * messageFilterStrict: + * An object containing arbitrary properties on which to filter + * received messages. Messages will only be dispatched to this + * object if the `recipient` object passed to `sendMessage` + * matches this filter, as determined by `matchesFilter` with + * `strict=true`. + * + * messageFilterPermissive: + * An object containing arbitrary properties on which to filter + * received messages. Messages will only be dispatched to this + * object if the `recipient` object passed to `sendMessage` + * matches this filter, as determined by `matchesFilter` with + * `strict=false`. + * + * filterMessage: + * An optional function that prevents the handler from handling a + * message by returning `false`. See `getHandlers` for the parameters. + */ + addListener(targets, messageName, handler) { + if (!Array.isArray(targets)) { + targets = [targets]; + } + for (let target of targets) { + this.messageManagers.get(target).addHandler(messageName, handler); + } + }, + + /** + * Removes a message listener from the given message manager. + * + * @param {nsIMessageListenerManager|Array} targets + * The message managers on which to stop listening. + * @param {string|number} messageName + * The name of the message to stop listening for. + * @param {MessageReceiver} handler + * The handler to stop dispatching to. + */ + removeListener(targets, messageName, handler) { + if (!Array.isArray(targets)) { + targets = [targets]; + } + for (let target of targets) { + if (this.messageManagers.has(target)) { + this.messageManagers.get(target).removeHandler(messageName, handler); + } + } + }, + + /** + * Sends a message via the given message manager. Returns a promise which + * resolves or rejects with the return value of the message receiver. + * + * The promise also rejects if there is no matching listener, or the other + * side of the message manager disconnects before the response is received. + * + * @param {nsIMessageSender} target + * The message manager on which to send the message. + * @param {string} messageName + * The name of the message to send, as passed to `addListener`. + * @param {object} data + * A structured-clone-compatible object to send to the message + * recipient. + * @param {object} [options] + * An object containing any of the following properties: + * @param {object} [options.recipient] + * A structured-clone-compatible object to identify the message + * recipient. The object must match the `messageFilterStrict` and + * `messageFilterPermissive` filters defined by recipients in order + * for the message to be received. + * @param {object} [options.sender] + * A structured-clone-compatible object to identify the message + * sender. This object may also be used to avoid delivering the + * message to the sender, and as a filter to prematurely + * abort responses when the sender is being destroyed. + * @see `abortResponses`. + * @param {integer} [options.responseType=RESPONSE_SINGLE] + * Specifies the type of response expected. See the `RESPONSE_*` + * contents for details. + * @returns {Promise} + */ + sendMessage(target, messageName, data, options = {}) { + let sender = options.sender || {}; + let recipient = options.recipient || {}; + let responseType = options.responseType || this.RESPONSE_SINGLE; + + let channelId = ExtensionUtils.getUniqueId(); + let message = {messageName, channelId, sender, recipient, data, responseType}; + + if (responseType == this.RESPONSE_NONE) { + try { + target.sendAsyncMessage(MESSAGE_MESSAGE, message); + } catch (e) { + // Caller is not expecting a reply, so dump the error to the console. + Cu.reportError(e); + return Promise.reject(e); + } + return Promise.resolve(); // Not expecting any reply. + } + + let deferred = PromiseUtils.defer(); + deferred.sender = recipient; + deferred.messageManager = target; + deferred.channelId = channelId; + + this._addPendingResponse(deferred); + + // The channel ID is used as the message name when routing responses. + // Add a message listener to the response broker, and remove it once + // we've gotten (or canceled) a response. + let broker = this.responseManagers.get(target); + broker.addHandler(channelId, deferred); + + let cleanup = () => { + broker.removeHandler(channelId, deferred); + }; + deferred.promise.then(cleanup, cleanup); + + try { + target.sendAsyncMessage(MESSAGE_MESSAGE, message); + } catch (e) { + deferred.reject(e); + } + return deferred.promise; + }, + + _callHandlers(handlers, data) { + let responseType = data.responseType; + + // At least one handler is required for all response types but + // RESPONSE_ALL. + if (handlers.length == 0 && responseType != this.RESPONSE_ALL) { + return Promise.reject({result: MessageChannel.RESULT_NO_HANDLER, + message: "No matching message handler"}); + } + + if (responseType == this.RESPONSE_SINGLE) { + if (handlers.length > 1) { + return Promise.reject({result: MessageChannel.RESULT_MULTIPLE_HANDLERS, + message: `Multiple matching handlers for ${data.messageName}`}); + } + + // Note: We use `new Promise` rather than `Promise.resolve` here + // so that errors from the handler are trapped and converted into + // rejected promises. + return new Promise(resolve => { + resolve(handlers[0].receiveMessage(data)); + }); + } + + let responses = handlers.map(handler => { + try { + return handler.receiveMessage(data); + } catch (e) { + return Promise.reject(e); + } + }); + responses = responses.filter(response => response !== undefined); + + switch (responseType) { + case this.RESPONSE_FIRST: + if (responses.length == 0) { + return Promise.reject({result: MessageChannel.RESULT_NO_RESPONSE, + message: "No handler returned a response"}); + } + + return Promise.race(responses); + + case this.RESPONSE_ALL: + return Promise.all(responses); + } + return Promise.reject({message: "Invalid response type"}); + }, + + /** + * Handles dispatching message callbacks from the message brokers to their + * appropriate `MessageReceivers`, and routing the responses back to the + * original senders. + * + * Each handler object is a `MessageReceiver` object as passed to + * `addListener`. + * + * @param {Array} handlers + * @param {object} data + * @param {nsIMessageSender|{messageManager:nsIMessageSender}} data.target + */ + _handleMessage(handlers, data) { + if (data.responseType == this.RESPONSE_NONE) { + handlers.forEach(handler => { + // The sender expects no reply, so dump any errors to the console. + new Promise(resolve => { + resolve(handler.receiveMessage(data)); + }).catch(e => { + Cu.reportError(e.stack ? `${e}\n${e.stack}` : e.message || e); + }); + }); + // Note: Unhandled messages are silently dropped. + return; + } + + let target = new MessageManagerProxy(data.target); + + let deferred = { + sender: data.sender, + messageManager: target, + channelId: data.channelId, + }; + deferred.promise = new Promise((resolve, reject) => { + deferred.reject = reject; + + this._callHandlers(handlers, data).then(resolve, reject); + }).then( + value => { + let response = { + result: this.RESULT_SUCCESS, + messageName: data.channelId, + recipient: {}, + value, + }; + + target.sendAsyncMessage(MESSAGE_RESPONSE, response); + }, + error => { + if (target.isDisconnected) { + // Target is disconnected. We can't send an error response, so + // don't even try. + if (error.result !== this.RESULT_DISCONNECTED && + error.result !== this.RESULT_NO_RESPONSE) { + Cu.reportError(Cu.getClassName(error, false) === "Object" ? error.message : error); + } + return; + } + + let response = { + result: this.RESULT_ERROR, + messageName: data.channelId, + recipient: {}, + error: {}, + }; + + if (error && typeof(error) == "object") { + if (error.result) { + response.result = error.result; + } + // Error objects are not structured-clonable, so just copy + // over the important properties. + for (let key of ["fileName", "filename", "lineNumber", + "columnNumber", "message", "stack", "result"]) { + if (key in error) { + response.error[key] = error[key]; + } + } + } + + target.sendAsyncMessage(MESSAGE_RESPONSE, response); + }).catch(e => { + Cu.reportError(e); + }).then(() => { + target.dispose(); + }); + + this._addPendingResponse(deferred); + }, + + /** + * Handles message callbacks from the response brokers. + * + * Each handler object is a deferred object created by `sendMessage`, and + * should be resolved or rejected based on the contents of the response. + * + * @param {Array} handlers + * @param {object} data + * @param {nsIMessageSender|{messageManager:nsIMessageSender}} data.target + */ + _handleResponse(handlers, data) { + // If we have an error at this point, we have handler to report it to, + // so just log it. + if (handlers.length == 0) { + if (this.abortedResponses.has(data.messageName)) { + this.abortedResponses.delete(data.messageName); + Services.console.logStringMessage(`Ignoring response to aborted listener for ${data.messageName}`); + } else { + Cu.reportError(`No matching message response handler for ${data.messageName}`); + } + } else if (handlers.length > 1) { + Cu.reportError(`Multiple matching response handlers for ${data.messageName}`); + } else if (data.result === this.RESULT_SUCCESS) { + handlers[0].resolve(data.value); + } else { + handlers[0].reject(data.error); + } + }, + + /** + * Adds a pending response to the the `pendingResponses` list. + * + * The response object must be a deferred promise with the following + * properties: + * + * promise: + * The promise object which resolves or rejects when the response + * is no longer pending. + * + * reject: + * A function which, when called, causes the `promise` object to be + * rejected. + * + * sender: + * A sender object, as passed to `sendMessage. + * + * messageManager: + * The message manager the response will be sent or received on. + * + * When the promise resolves or rejects, it will be removed from the + * list. + * + * These values are used to clear pending responses when execution + * contexts are destroyed. + * + * @param {Deferred} deferred + */ + _addPendingResponse(deferred) { + let cleanup = () => { + this.pendingResponses.delete(deferred); + }; + this.pendingResponses.add(deferred); + deferred.promise.then(cleanup, cleanup); + }, + + /** + * Aborts any pending message responses to senders matching the given + * filter. + * + * @param {object} sender + * The object on which to filter senders, as determined by + * `matchesFilter`. + * @param {object} [reason] + * An optional object describing the reason the response was aborted. + * Will be passed to the promise rejection handler of all aborted + * responses. + */ + abortResponses(sender, reason = this.REASON_DISCONNECTED) { + for (let response of this.pendingResponses) { + if (this.matchesFilter(sender, response.sender)) { + this.pendingResponses.delete(response); + this.abortedResponses.add(response.channelId); + response.reject(reason); + } + } + }, + + /** + * Aborts any pending message responses to the broker for the given + * message manager. + * + * @param {nsIMessageListenerManager} target + * The message manager for which to abort brokers. + * @param {object} reason + * An object describing the reason the responses were aborted. + * Will be passed to the promise rejection handler of all aborted + * responses. + */ + abortMessageManager(target, reason) { + for (let response of this.pendingResponses) { + if (MessageManagerProxy.matches(response.messageManager, target)) { + this.abortedResponses.add(response.channelId); + response.reject(reason); + } + } + }, + + observe(subject, topic, data) { + switch (topic) { + case "message-manager-close": + case "message-manager-disconnect": + try { + if (this.responseManagers.has(subject)) { + this.abortMessageManager(subject, this.REASON_DISCONNECTED); + } + } finally { + this.responseManagers.delete(subject); + this.messageManagers.delete(subject); + } + break; + } + }, +}; + +MessageChannel.init(); diff --git a/toolkit/components/extensions/NativeMessaging.jsm b/toolkit/components/extensions/NativeMessaging.jsm new file mode 100644 index 0000000000..3c9d490882 --- /dev/null +++ b/toolkit/components/extensions/NativeMessaging.jsm @@ -0,0 +1,444 @@ +/* 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/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["HostManifestManager", "NativeApp"]; +/* globals NativeApp */ + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +const {EventEmitter} = Cu.import("resource://gre/modules/EventEmitter.jsm", {}); + +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown", + "resource://gre/modules/AsyncShutdown.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionChild", + "resource://gre/modules/ExtensionChild.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Schemas", + "resource://gre/modules/Schemas.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Subprocess", + "resource://gre/modules/Subprocess.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "clearTimeout", + "resource://gre/modules/Timer.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "setTimeout", + "resource://gre/modules/Timer.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WindowsRegistry", + "resource://gre/modules/WindowsRegistry.jsm"); + +const HOST_MANIFEST_SCHEMA = "chrome://extensions/content/schemas/native_host_manifest.json"; +const VALID_APPLICATION = /^\w+(\.\w+)*$/; + +// For a graceful shutdown (i.e., when the extension is unloaded or when it +// explicitly calls disconnect() on a native port), how long we give the native +// application to exit before we start trying to kill it. (in milliseconds) +const GRACEFUL_SHUTDOWN_TIME = 3000; + +// Hard limits on maximum message size that can be read/written +// These are defined in the native messaging documentation, note that +// the write limit is imposed by the "wire protocol" in which message +// boundaries are defined by preceding each message with its length as +// 4-byte unsigned integer so this is the largest value that can be +// represented. Good luck generating a serialized message that large, +// the practical write limit is likely to be dictated by available memory. +const MAX_READ = 1024 * 1024; +const MAX_WRITE = 0xffffffff; + +// Preferences that can lower the message size limits above, +// used for testing the limits. +const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes"; +const PREF_MAX_WRITE = "webextensions.native-messaging.max-output-message-bytes"; + +const REGPATH = "Software\\Mozilla\\NativeMessagingHosts"; + +const global = this; + +this.HostManifestManager = { + _initializePromise: null, + _lookup: null, + + init() { + if (!this._initializePromise) { + let platform = AppConstants.platform; + if (platform == "win") { + this._lookup = this._winLookup; + } else if (platform == "macosx" || platform == "linux") { + let dirs = [ + Services.dirsvc.get("XREUserNativeMessaging", Ci.nsIFile).path, + Services.dirsvc.get("XRESysNativeMessaging", Ci.nsIFile).path, + ]; + this._lookup = (application, context) => this._tryPaths(application, dirs, context); + } else { + throw new Error(`Native messaging is not supported on ${AppConstants.platform}`); + } + this._initializePromise = Schemas.load(HOST_MANIFEST_SCHEMA); + } + return this._initializePromise; + }, + + _winLookup(application, context) { + const REGISTRY = Ci.nsIWindowsRegKey; + let regPath = `${REGPATH}\\${application}`; + let path = WindowsRegistry.readRegKey(REGISTRY.ROOT_KEY_CURRENT_USER, + regPath, "", REGISTRY.WOW64_64); + if (!path) { + path = WindowsRegistry.readRegKey(Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE, + regPath, "", REGISTRY.WOW64_64); + } + if (!path) { + return null; + } + return this._tryPath(path, application, context) + .then(manifest => manifest ? {path, manifest} : null); + }, + + _tryPath(path, application, context) { + return Promise.resolve() + .then(() => OS.File.read(path, {encoding: "utf-8"})) + .then(data => { + let manifest; + try { + manifest = JSON.parse(data); + } catch (ex) { + let msg = `Error parsing native host manifest ${path}: ${ex.message}`; + Cu.reportError(msg); + return null; + } + + let normalized = Schemas.normalize(manifest, "manifest.NativeHostManifest", context); + if (normalized.error) { + Cu.reportError(normalized.error); + return null; + } + manifest = normalized.value; + if (manifest.name != application) { + let msg = `Native host manifest ${path} has name property ${manifest.name} (expected ${application})`; + Cu.reportError(msg); + return null; + } + return normalized.value; + }).catch(ex => { + if (ex instanceof OS.File.Error && ex.becauseNoSuchFile) { + return null; + } + throw ex; + }); + }, + + async _tryPaths(application, dirs, context) { + for (let dir of dirs) { + let path = OS.Path.join(dir, `${application}.json`); + let manifest = await this._tryPath(path, application, context); + if (manifest) { + return {path, manifest}; + } + } + return null; + }, + + /** + * Search for a valid native host manifest for the given application name. + * The directories searched and rules for manifest validation are all + * detailed in the native messaging documentation. + * + * @param {string} application The name of the applciation to search for. + * @param {object} context A context object as expected by Schemas.normalize. + * @returns {object} The contents of the validated manifest, or null if + * no valid manifest can be found for this application. + */ + lookupApplication(application, context) { + if (!VALID_APPLICATION.test(application)) { + throw new Error(`Invalid application "${application}"`); + } + return this.init().then(() => this._lookup(application, context)); + }, +}; + +this.NativeApp = class extends EventEmitter { + /** + * @param {BaseContext} context The context that initiated the native app. + * @param {string} application The identifier of the native app. + */ + constructor(context, application) { + super(); + + this.context = context; + this.name = application; + + // We want a close() notification when the window is destroyed. + this.context.callOnClose(this); + + this.proc = null; + this.readPromise = null; + this.sendQueue = []; + this.writePromise = null; + this.sentDisconnect = false; + + this.startupPromise = HostManifestManager.lookupApplication(application, context) + .then(hostInfo => { + // Put the two errors together to not leak information about whether a native + // application is installed to addons that do not have the right permission. + if (!hostInfo || !hostInfo.manifest.allowed_extensions.includes(context.extension.id)) { + throw new context.cloneScope.Error(`This extension does not have permission to use native application ${application} (or the application is not installed)`); + } + + let command = hostInfo.manifest.path; + if (AppConstants.platform == "win") { + // OS.Path.join() ignores anything before the last absolute path + // it sees, so if command is already absolute, it remains unchanged + // here. If it is relative, we get the proper absolute path here. + command = OS.Path.join(OS.Path.dirname(hostInfo.path), command); + } + + let subprocessOpts = { + command: command, + arguments: [hostInfo.path, context.extension.id], + workdir: OS.Path.dirname(command), + stderr: "pipe", + }; + return Subprocess.call(subprocessOpts); + }).then(proc => { + this.startupPromise = null; + this.proc = proc; + this._startRead(); + this._startWrite(); + this._startStderrRead(); + }).catch(err => { + this.startupPromise = null; + Cu.reportError(err instanceof Error ? err : err.message); + this._cleanup(err); + }); + } + + /** + * Open a connection to a native messaging host. + * + * @param {BaseContext} context The context associated with the port. + * @param {nsIMessageSender} messageManager The message manager used to send + * and receive messages from the port's creator. + * @param {string} portId A unique internal ID that identifies the port. + * @param {object} sender The object describing the creator of the connection + * request. + * @param {string} application The name of the native messaging host. + */ + static onConnectNative(context, messageManager, portId, sender, application) { + let app = new NativeApp(context, application); + let port = new ExtensionChild.Port(context, messageManager, [Services.mm], "", portId, sender, sender); + app.once("disconnect", (what, err) => port.disconnect(err)); + + /* eslint-disable mozilla/balanced-listeners */ + app.on("message", (what, msg) => port.postMessage(msg)); + /* eslint-enable mozilla/balanced-listeners */ + + port.registerOnMessage(holder => app.send(holder)); + port.registerOnDisconnect(msg => app.close()); + } + + /** + * @param {BaseContext} context The scope from where `message` originates. + * @param {*} message A message from the extension, meant for a native app. + * @returns {ArrayBuffer} An ArrayBuffer that can be sent to the native app. + */ + static encodeMessage(context, message) { + message = context.jsonStringify(message); + let buffer = new TextEncoder().encode(message).buffer; + if (buffer.byteLength > NativeApp.maxWrite) { + throw new context.cloneScope.Error("Write too big"); + } + return buffer; + } + + // A port is definitely "alive" if this.proc is non-null. But we have + // to provide a live port object immediately when connecting so we also + // need to consider a port alive if proc is null but the startupPromise + // is still pending. + get _isDisconnected() { + return (!this.proc && !this.startupPromise); + } + + _startRead() { + if (this.readPromise) { + throw new Error("Entered _startRead() while readPromise is non-null"); + } + this.readPromise = this.proc.stdout.readUint32() + .then(len => { + if (len > NativeApp.maxRead) { + throw new this.context.cloneScope.Error(`Native application tried to send a message of ${len} bytes, which exceeds the limit of ${NativeApp.maxRead} bytes.`); + } + return this.proc.stdout.readJSON(len); + }).then(msg => { + this.emit("message", msg); + this.readPromise = null; + this._startRead(); + }).catch(err => { + if (err.errorCode != Subprocess.ERROR_END_OF_FILE) { + Cu.reportError(err instanceof Error ? err : err.message); + } + this._cleanup(err); + }); + } + + _startWrite() { + if (this.sendQueue.length == 0) { + return; + } + + if (this.writePromise) { + throw new Error("Entered _startWrite() while writePromise is non-null"); + } + + let buffer = this.sendQueue.shift(); + let uintArray = Uint32Array.of(buffer.byteLength); + + this.writePromise = Promise.all([ + this.proc.stdin.write(uintArray.buffer), + this.proc.stdin.write(buffer), + ]).then(() => { + this.writePromise = null; + this._startWrite(); + }).catch(err => { + Cu.reportError(err.message); + this._cleanup(err); + }); + } + + _startStderrRead() { + let proc = this.proc; + let app = this.name; + (async function() { + let partial = ""; + while (true) { + let data = await proc.stderr.readString(); + if (data.length == 0) { + // We have hit EOF, just stop reading + if (partial) { + Services.console.logStringMessage(`stderr output from native app ${app}: ${partial}`); + } + break; + } + + let lines = data.split(/\r?\n/); + lines[0] = partial + lines[0]; + partial = lines.pop(); + + for (let line of lines) { + Services.console.logStringMessage(`stderr output from native app ${app}: ${line}`); + } + } + })(); + } + + send(holder) { + if (this._isDisconnected) { + throw new this.context.cloneScope.Error("Attempt to postMessage on disconnected port"); + } + let msg = holder.deserialize(global); + if (Cu.getClassName(msg, true) != "ArrayBuffer") { + // This error cannot be triggered by extensions; it indicates an error in + // our implementation. + throw new Error("The message to the native messaging host is not an ArrayBuffer"); + } + + let buffer = msg; + + if (buffer.byteLength > NativeApp.maxWrite) { + throw new this.context.cloneScope.Error("Write too big"); + } + + this.sendQueue.push(buffer); + if (!this.startupPromise && !this.writePromise) { + this._startWrite(); + } + } + + // Shut down the native application and also signal to the extension + // that the connect has been disconnected. + _cleanup(err) { + this.context.forgetOnClose(this); + + let doCleanup = () => { + // Set a timer to kill the process gracefully after one timeout + // interval and kill it forcefully after two intervals. + let timer = setTimeout(() => { + this.proc.kill(GRACEFUL_SHUTDOWN_TIME); + }, GRACEFUL_SHUTDOWN_TIME); + + let promise = Promise.all([ + this.proc.stdin.close() + .catch(err => { + if (err.errorCode != Subprocess.ERROR_END_OF_FILE) { + throw err; + } + }), + this.proc.wait(), + ]).then(() => { + this.proc = null; + clearTimeout(timer); + }); + + AsyncShutdown.profileBeforeChange.addBlocker( + `Native Messaging: Wait for application ${this.name} to exit`, + promise); + + promise.then(() => { + AsyncShutdown.profileBeforeChange.removeBlocker(promise); + }); + + return promise; + }; + + if (this.proc) { + doCleanup(); + } else if (this.startupPromise) { + this.startupPromise.then(doCleanup); + } + + if (!this.sentDisconnect) { + this.sentDisconnect = true; + if (err && err.errorCode == Subprocess.ERROR_END_OF_FILE) { + err = null; + } + this.emit("disconnect", err); + } + } + + // Called from Context when the extension is shut down. + close() { + this._cleanup(); + } + + sendMessage(holder) { + let responsePromise = new Promise((resolve, reject) => { + this.once("message", (what, msg) => { resolve(msg); }); + this.once("disconnect", (what, err) => { reject(err); }); + }); + + let result = this.startupPromise.then(() => { + this.send(holder); + return responsePromise; + }); + + result.then(() => { + this._cleanup(); + }, () => { + // Prevent the response promise from being reported as an + // unchecked rejection if the startup promise fails. + responsePromise.catch(() => {}); + + this._cleanup(); + }); + + return result; + } +}; + +XPCOMUtils.defineLazyPreferenceGetter(NativeApp, "maxRead", PREF_MAX_READ, MAX_READ); +XPCOMUtils.defineLazyPreferenceGetter(NativeApp, "maxWrite", PREF_MAX_WRITE, MAX_WRITE); diff --git a/toolkit/components/extensions/ProxyScriptContext.jsm b/toolkit/components/extensions/ProxyScriptContext.jsm new file mode 100644 index 0000000000..d1c6cf3246 --- /dev/null +++ b/toolkit/components/extensions/ProxyScriptContext.jsm @@ -0,0 +1,297 @@ +/* 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/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["ProxyScriptContext"]; + +/* exported ProxyScriptContext */ + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/ExtensionCommon.jsm"); +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionChild", + "resource://gre/modules/ExtensionChild.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Schemas", + "resource://gre/modules/Schemas.jsm"); +XPCOMUtils.defineLazyServiceGetter(this, "ProxyService", + "@mozilla.org/network/protocol-proxy-service;1", + "nsIProtocolProxyService"); + +const CATEGORY_EXTENSION_SCRIPTS_CONTENT = "webextension-scripts-content"; + +// The length of time (seconds) to wait for a proxy to resolve before ignoring it. +const PROXY_TIMEOUT_SEC = 10; + +const { + defineLazyGetter, +} = ExtensionUtils; + +const { + BaseContext, + CanOfAPIs, + LocalAPIImplementation, + SchemaAPIManager, +} = ExtensionCommon; + +const PROXY_TYPES = Object.freeze({ + DIRECT: "direct", + HTTPS: "https", + PROXY: "proxy", + HTTP: "http", // Synonym for PROXY_TYPES.PROXY + SOCKS: "socks", // SOCKS5 + SOCKS4: "socks4", +}); + +class ProxyScriptContext extends BaseContext { + constructor(extension, url, contextInfo = {}) { + super("proxy_script", extension); + this.contextInfo = contextInfo; + this.extension = extension; + this.messageManager = Services.cpmm; + this.sandbox = Cu.Sandbox(this.extension.principal, { + sandboxName: `proxyscript:${extension.id}:${url}`, + metadata: {addonID: extension.id}, + }); + this.url = url; + this.FindProxyForURL = null; + } + + /** + * Loads and validates a proxy script into the sandbox, and then + * registers a new proxy filter for the context. + * + * @returns {boolean} true if load succeeded; false otherwise. + */ + load() { + Schemas.exportLazyGetter(this.sandbox, "browser", () => this.browserObj); + + try { + Services.scriptloader.loadSubScript(this.url, this.sandbox, "UTF-8"); + } catch (error) { + this.extension.emit("proxy-error", { + message: this.normalizeError(error).message, + }); + return false; + } + + this.FindProxyForURL = Cu.unwaiveXrays(this.sandbox.FindProxyForURL); + if (typeof this.FindProxyForURL !== "function") { + this.extension.emit("proxy-error", { + message: "The proxy script must define FindProxyForURL as a function", + }); + return false; + } + + ProxyService.registerFilter( + this /* nsIProtocolProxyFilter aFilter */, + 0 /* unsigned long aPosition */ + ); + + return true; + } + + get principal() { + return this.extension.principal; + } + + get cloneScope() { + return this.sandbox; + } + + /** + * This method (which is required by the nsIProtocolProxyService interface) + * is called to apply proxy filter rules for the given URI and proxy object + * (or list of proxy objects). + * + * @param {Object} service A reference to the Protocol Proxy Service. + * @param {Object} uri The URI for which these proxy settings apply. + * @param {Object} defaultProxyInfo The proxy (or list of proxies) that + * would be used by default for the given URI. This may be null. + * @returns {Object} The proxy info to apply for the given URI. + */ + applyFilter(service, uri, defaultProxyInfo) { + let ret; + try { + // Bug 1337001 - provide path and query components to non-https URLs. + ret = this.FindProxyForURL(uri.prePath, uri.host, this.contextInfo); + } catch (e) { + let error = this.normalizeError(e); + this.extension.emit("proxy-error", { + message: error.message, + fileName: error.fileName, + lineNumber: error.lineNumber, + stack: error.stack, + }); + return defaultProxyInfo; + } + + if (!ret || typeof ret !== "string") { + this.extension.emit("proxy-error", { + message: "FindProxyForURL: Return type must be a string", + }); + return defaultProxyInfo; + } + + let rules = ret.split(";"); + let proxyInfo = this.createProxyInfo(rules); + + return proxyInfo || defaultProxyInfo; + } + + /** + * Creates a new proxy info object using the return value of FindProxyForURL. + * + * @param {Array} rules The list of proxy rules returned by FindProxyForURL. + * (e.g. ["PROXY 1.2.3.4:8080", "SOCKS 1.1.1.1:9090", "DIRECT"]) + * @returns {nsIProxyInfo} The proxy info to apply for the given URI. + */ + createProxyInfo(rules) { + if (!rules.length) { + return null; + } + + let rule = rules[0].trim(); + + if (!rule) { + this.extension.emit("proxy-error", { + message: "FindProxyForURL: Expected Proxy Rule", + }); + return null; + } + + let parts = rule.split(/\s+/); + if (!parts[0] || parts.length !== 2) { + this.extension.emit("proxy-error", { + message: `FindProxyForURL: Invalid Proxy Rule: ${rule}`, + }); + return null; + } + + parts[0] = parts[0].toLowerCase(); + + switch (parts[0]) { + case PROXY_TYPES.PROXY: + case PROXY_TYPES.HTTP: + case PROXY_TYPES.HTTPS: + case PROXY_TYPES.SOCKS: + case PROXY_TYPES.SOCKS4: + if (!parts[1]) { + this.extension.emit("proxy-error", { + message: `FindProxyForURL: Missing argument for "${parts[0]}"`, + }); + return null; + } + + let [host, port] = parts[1].split(":"); + if (!host || !port) { + this.extension.emit("proxy-error", { + message: `FindProxyForURL: Unable to parse argument for ${rule}`, + }); + return null; + } + + let type = parts[0]; + if (parts[0] == PROXY_TYPES.PROXY) { + // PROXY_TYPES.HTTP and PROXY_TYPES.PROXY are synonyms + type = PROXY_TYPES.HTTP; + } + + let failoverProxy = this.createProxyInfo(rules.slice(1)); + return ProxyService.newProxyInfo(type, host, port, 0, + PROXY_TIMEOUT_SEC, failoverProxy); + case PROXY_TYPES.DIRECT: + return null; + default: + this.extension.emit("proxy-error", { + message: `FindProxyForURL: Unrecognized proxy type: "${parts[0]}"`, + }); + return null; + } + } + + /** + * Unloads the proxy filter and shuts down the sandbox. + */ + unload() { + super.unload(); + ProxyService.unregisterFilter(this); + Cu.nukeSandbox(this.sandbox); + this.sandbox = null; + } +} + +class ProxyScriptAPIManager extends SchemaAPIManager { + constructor() { + super("proxy"); + this.initialized = false; + } + + lazyInit() { + if (!this.initialized) { + for (let [/* name */, value] of XPCOMUtils.enumerateCategoryEntries( + CATEGORY_EXTENSION_SCRIPTS_CONTENT)) { + this.loadScript(value); + } + this.initialized = true; + } + } +} + +class ProxyScriptInjectionContext { + constructor(context, apiCan) { + this.context = context; + this.localAPIs = apiCan.root; + this.apiCan = apiCan; + } + + shouldInject(namespace, name, allowedContexts) { + if (this.context.envType !== "proxy_script") { + throw new Error(`Unexpected context type "${this.context.envType}"`); + } + + // Do not generate proxy script APIs unless explicitly allowed. + return allowedContexts.includes("proxy"); + } + + getImplementation(namespace, name) { + this.apiCan.findAPIPath(`${namespace}.${name}`); + let obj = this.apiCan.findAPIPath(namespace); + + if (obj && name in obj) { + return new LocalAPIImplementation(obj, name, this.context); + } + } + + get cloneScope() { + return this.context.cloneScope; + } + + get principal() { + return this.context.principal; + } +} + +defineLazyGetter(ProxyScriptContext.prototype, "messenger", function() { + let sender = {id: this.extension.id, frameId: this.frameId, url: this.url}; + let filter = {extensionId: this.extension.id, toProxyScript: true}; + return new ExtensionChild.Messenger(this, [this.messageManager], sender, filter); +}); + +let proxyScriptAPIManager = new ProxyScriptAPIManager(); + +defineLazyGetter(ProxyScriptContext.prototype, "browserObj", function() { + let localAPIs = {}; + let can = new CanOfAPIs(this, proxyScriptAPIManager, localAPIs); + proxyScriptAPIManager.lazyInit(); + + let browserObj = Cu.createObjectIn(this.sandbox); + let injectionContext = new ProxyScriptInjectionContext(this, can); + Schemas.inject(browserObj, injectionContext); + return browserObj; +}); diff --git a/toolkit/components/extensions/Schemas.jsm b/toolkit/components/extensions/Schemas.jsm new file mode 100644 index 0000000000..70e2a4f7b3 --- /dev/null +++ b/toolkit/components/extensions/Schemas.jsm @@ -0,0 +1,2732 @@ +/* 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/. */ + +"use strict"; + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +const global = this; + +Cu.importGlobalProperties(["URL"]); + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); +var { + DefaultMap, + DefaultWeakMap, + instanceOf, +} = ExtensionUtils; + +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionParent", + "resource://gre/modules/ExtensionParent.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyServiceGetter(this, "contentPolicyService", + "@mozilla.org/addons/content-policy;1", + "nsIAddonContentPolicy"); + +XPCOMUtils.defineLazyGetter(this, "StartupCache", () => ExtensionParent.StartupCache); + +this.EXPORTED_SYMBOLS = ["Schemas"]; + +const {DEBUG} = AppConstants; + +const isParentProcess = Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT; + +function readJSON(url) { + return new Promise((resolve, reject) => { + NetUtil.asyncFetch({uri: url, loadUsingSystemPrincipal: true}, (inputStream, status) => { + if (!Components.isSuccessCode(status)) { + // Convert status code to a string + let e = Components.Exception("", status); + reject(new Error(`Error while loading '${url}' (${e.name})`)); + return; + } + try { + let text = NetUtil.readInputStreamToString(inputStream, inputStream.available()); + + // Chrome JSON files include a license comment that we need to + // strip off for this to be valid JSON. As a hack, we just + // look for the first '[' character, which signals the start + // of the JSON content. + let index = text.indexOf("["); + text = text.slice(index); + + resolve(JSON.parse(text)); + } catch (e) { + reject(e); + } + }); + }); +} + +/** + * Defines a lazy getter for the given property on the given object. Any + * security wrappers are waived on the object before the property is + * defined, and the getter and setter methods are wrapped for the target + * scope. + * + * The given getter function is guaranteed to be called only once, even + * if the target scope retrieves the wrapped getter from the property + * descriptor and calls it directly. + * + * @param {object} object + * The object on which to define the getter. + * @param {string|Symbol} prop + * The property name for which to define the getter. + * @param {function} getter + * The function to call in order to generate the final property + * value. + */ +function exportLazyGetter(object, prop, getter) { + object = Cu.waiveXrays(object); + + let redefine = value => { + if (value === undefined) { + delete object[prop]; + } else { + Object.defineProperty(object, prop, { + enumerable: true, + configurable: true, + writable: true, + value, + }); + } + + getter = null; + + return value; + }; + + Object.defineProperty(object, prop, { + enumerable: true, + configurable: true, + + get: Cu.exportFunction(function() { + return redefine(getter.call(this)); + }, object), + + set: Cu.exportFunction(value => { + redefine(value); + }, object), + }); +} + +/** + * Defines a lazily-instantiated property descriptor on the given + * object. Any security wrappers are waived on the object before the + * property is defined. + * + * The given getter function is guaranteed to be called only once, even + * if the target scope retrieves the wrapped getter from the property + * descriptor and calls it directly. + * + * @param {object} object + * The object on which to define the getter. + * @param {string|Symbol} prop + * The property name for which to define the getter. + * @param {function} getter + * The function to call in order to generate the final property + * descriptor object. This will be called, and the property + * descriptor installed on the object, the first time the + * property is written or read. The function may return + * undefined, which will cause the property to be deleted. + */ +function exportLazyProperty(object, prop, getter) { + object = Cu.waiveXrays(object); + + let redefine = obj => { + let desc = getter.call(obj); + getter = null; + + delete object[prop]; + if (desc) { + let defaults = { + configurable: true, + enumerable: true, + }; + + if (!desc.set && !desc.get) { + defaults.writable = true; + } + + Object.defineProperty(object, prop, + Object.assign(defaults, desc)); + } + }; + + Object.defineProperty(object, prop, { + enumerable: true, + configurable: true, + + get: Cu.exportFunction(function() { + redefine(this); + return object[prop]; + }, object), + + set: Cu.exportFunction(function(value) { + redefine(this); + object[prop] = value; + }, object), + }); +} + +const POSTPROCESSORS = { + convertImageDataToURL(imageData, context) { + let document = context.cloneScope.document; + let canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas"); + canvas.width = imageData.width; + canvas.height = imageData.height; + canvas.getContext("2d").putImageData(imageData, 0, 0); + + return canvas.toDataURL("image/png"); + }, +}; + +// Parses a regular expression, with support for the Python extended +// syntax that allows setting flags by including the string (?im) +function parsePattern(pattern) { + let flags = ""; + let match = /^\(\?([im]*)\)(.*)/.exec(pattern); + if (match) { + [, flags, pattern] = match; + } + return new RegExp(pattern, flags); +} + +function getValueBaseType(value) { + let t = typeof(value); + if (t == "object") { + if (value === null) { + return "null"; + } else if (Array.isArray(value)) { + return "array"; + } else if (Object.prototype.toString.call(value) == "[object ArrayBuffer]") { + return "binary"; + } + } else if (t == "number") { + if (value % 1 == 0) { + return "integer"; + } + } + return t; +} + +// Methods of Context that are used by Schemas.normalize. These methods can be +// overridden at the construction of Context. +const CONTEXT_FOR_VALIDATION = [ + "checkLoadURL", + "hasPermission", + "logError", +]; + +// Methods of Context that are used by Schemas.inject. +// Callers of Schemas.inject should implement all of these methods. +const CONTEXT_FOR_INJECTION = [ + ...CONTEXT_FOR_VALIDATION, + "getImplementation", + "isPermissionRevokable", + "shouldInject", +]; + +/** + * A context for schema validation and error reporting. This class is only used + * internally within Schemas. + */ +class Context { + /** + * @param {object} params Provides the implementation of this class. + * @param {Array} overridableMethods + */ + constructor(params, overridableMethods = CONTEXT_FOR_VALIDATION) { + this.params = params; + + this.path = []; + this.preprocessors = { + localize(value, context) { + return value; + }, + }; + this.postprocessors = POSTPROCESSORS; + this.isChromeCompat = false; + + this.currentChoices = new Set(); + this.choicePathIndex = 0; + + for (let method of overridableMethods) { + if (method in params) { + this[method] = params[method].bind(params); + } + } + + let props = ["preprocessors", "isChromeCompat"]; + for (let prop of props) { + if (prop in params) { + if (prop in this && typeof this[prop] == "object") { + Object.assign(this[prop], params[prop]); + } else { + this[prop] = params[prop]; + } + } + } + } + + get choicePath() { + let path = this.path.slice(this.choicePathIndex); + return path.join("."); + } + + get cloneScope() { + return this.params.cloneScope; + } + + get url() { + return this.params.url; + } + + get principal() { + return this.params.principal || Services.scriptSecurityManager.createNullPrincipal({}); + } + + /** + * Checks whether `url` may be loaded by the extension in this context. + * + * @param {string} url The URL that the extension wished to load. + * @returns {boolean} Whether the context may load `url`. + */ + checkLoadURL(url) { + let ssm = Services.scriptSecurityManager; + try { + ssm.checkLoadURIStrWithPrincipal(this.principal, url, + ssm.DISALLOW_INHERIT_PRINCIPAL); + } catch (e) { + return false; + } + return true; + } + + /** + * Checks whether this context has the given permission. + * + * @param {string} permission + * The name of the permission to check. + * + * @returns {boolean} True if the context has the given permission. + */ + hasPermission(permission) { + return false; + } + + /** + * Checks whether the given permission can be dynamically revoked or + * granted. + * + * @param {string} permission + * The name of the permission to check. + * + * @returns {boolean} True if the given permission is revokable. + */ + isPermissionRevokable(permission) { + return false; + } + + /** + * Returns an error result object with the given message, for return + * by Type normalization functions. + * + * If the context has a `currentTarget` value, this is prepended to + * the message to indicate the location of the error. + * + * @param {string} errorMessage + * The error message which will be displayed when this is the + * only possible matching schema. + * @param {string} choicesMessage + * The message describing the valid what constitutes a valid + * value for this schema, which will be displayed when multiple + * schema choices are available and none match. + * + * A caller may pass `null` to prevent a choice from being + * added, but this should *only* be done from code processing a + * choices type. + * @returns {object} + */ + error(errorMessage, choicesMessage = undefined) { + if (choicesMessage !== null) { + let {choicePath} = this; + if (choicePath) { + choicesMessage = `.${choicePath} must ${choicesMessage}`; + } + + this.currentChoices.add(choicesMessage); + } + + if (this.currentTarget) { + return {error: `Error processing ${this.currentTarget}: ${errorMessage}`}; + } + return {error: errorMessage}; + } + + /** + * Creates an `Error` object belonging to the current unprivileged + * scope. If there is no unprivileged scope associated with this + * context, the message is returned as a string. + * + * If the context has a `currentTarget` value, this is prepended to + * the message, in the same way as for the `error` method. + * + * @param {string} message + * @returns {Error} + */ + makeError(message) { + let {error} = this.error(message); + if (this.cloneScope) { + return new this.cloneScope.Error(error); + } + return error; + } + + /** + * Logs the given error to the console. May be overridden to enable + * custom logging. + * + * @param {Error|string} error + */ + logError(error) { + Cu.reportError(error); + } + + /** + * Returns the name of the value currently being normalized. For a + * nested object, this is usually approximately equivalent to the + * JavaScript property accessor for that property. Given: + * + * { foo: { bar: [{ baz: x }] } } + * + * When processing the value for `x`, the currentTarget is + * 'foo.bar.0.baz' + */ + get currentTarget() { + return this.path.join("."); + } + + /** + * Executes the given callback, and returns an array of choice strings + * passed to {@see #error} during its execution. + * + * @param {function} callback + * @returns {object} + * An object with a `result` property containing the return + * value of the callback, and a `choice` property containing + * an array of choices. + */ + withChoices(callback) { + let {currentChoices, choicePathIndex} = this; + + let choices = new Set(); + this.currentChoices = choices; + this.choicePathIndex = this.path.length; + + try { + let result = callback(); + + return {result, choices: Array.from(choices)}; + } finally { + this.currentChoices = currentChoices; + this.choicePathIndex = choicePathIndex; + + choices = Array.from(choices); + if (choices.length == 1) { + currentChoices.add(choices[0]); + } else if (choices.length) { + let n = choices.length - 1; + choices[n] = `or ${choices[n]}`; + + this.error(null, `must either [${choices.join(", ")}]`); + } + } + } + + /** + * Appends the given component to the `currentTarget` path to indicate + * that it is being processed, calls the given callback function, and + * then restores the original path. + * + * This is used to identify the path of the property being processed + * when reporting type errors. + * + * @param {string} component + * @param {function} callback + * @returns {*} + */ + withPath(component, callback) { + this.path.push(component); + try { + return callback(); + } finally { + this.path.pop(); + } + } +} + +/** + * Represents a schema entry to be injected into an object. Handles the + * injection, revocation, and permissions of said entry. + * + * @param {InjectionContext} context + * The injection context for the entry. + * @param {Entry} entry + * The entry to inject. + * @param {object} parentObject + * The object into which to inject this entry. + * @param {string} name + * The property name at which to inject this entry. + * @param {Array} path + * The full path from the root entry to this entry. + * @param {Entry} parentEntry + * The parent entry for the injected entry. + */ +class InjectionEntry { + constructor(context, entry, parentObj, name, path, parentEntry) { + this.context = context; + this.entry = entry; + this.parentObj = parentObj; + this.name = name; + this.path = path; + this.parentEntry = parentEntry; + + this.injected = null; + this.lazyInjected = null; + } + + /** + * @property {Array} allowedContexts + * The list of allowed contexts into which the entry may be + * injected. + */ + get allowedContexts() { + let {allowedContexts} = this.entry; + if (allowedContexts.length) { + return allowedContexts; + } + return this.parentEntry.defaultContexts; + } + + /** + * @property {boolean} isRevokable + * Returns true if this entry may be dynamically injected or + * revoked based on its permissions. + */ + get isRevokable() { + return (this.entry.permissions && + this.entry.permissions.some(perm => this.context.isPermissionRevokable(perm))); + } + + /** + * @property {boolean} hasPermission + * Returns true if the injection context currently has the + * appropriate permissions to access this entry. + */ + get hasPermission() { + return (!this.entry.permissions || + this.entry.permissions.some(perm => this.context.hasPermission(perm))); + } + + /** + * @property {boolean} shouldInject + * Returns true if this entry should be injected in the given + * context, without respect to permissions. + */ + get shouldInject() { + return this.context.shouldInject(this.path.join("."), this.name, this.allowedContexts); + } + + /** + * Revokes this entry, removing its property from its parent object, + * and invalidating its wrappers. + */ + revoke() { + if (this.lazyInjected) { + this.lazyInjected = false; + } else if (this.injected) { + if (this.injected.revoke) { + this.injected.revoke(); + } + + try { + let unwrapped = Cu.waiveXrays(this.parentObj); + delete unwrapped[this.name]; + } catch (e) { + Cu.reportError(e); + } + + let {value} = this.injected.descriptor; + if (value) { + this.context.revokeChildren(value); + } + + this.injected = null; + } + } + + /** + * Returns a property descriptor object for this entry, if it should + * be injected, or undefined if it should not. + * + * @returns {object?} + * A property descriptor object, or undefined if the property + * should be removed. + */ + getDescriptor() { + this.lazyInjected = false; + + if (this.injected) { + let path = [...this.path, this.name]; + throw new Error(`Attempting to re-inject already injected entry: ${path.join(".")}`); + } + + if (!this.shouldInject) { + return; + } + + if (this.isRevokable) { + this.context.pendingEntries.add(this); + } + + if (!this.hasPermission) { + return; + } + + this.injected = this.entry.getDescriptor(this.path, this.context); + if (!this.injected) { + return undefined; + } + + return this.injected.descriptor; + } + + /** + * Injects a lazy property descriptor into the parent object which + * checks permissions and eligibility for injection the first time it + * is accessed. + */ + lazyInject() { + if (this.lazyInjected || this.injected) { + let path = [...this.path, this.name]; + throw new Error(`Attempting to re-lazy-inject already injected entry: ${path.join(".")}`); + } + + this.lazyInjected = true; + exportLazyProperty(this.parentObj, this.name, () => { + if (this.lazyInjected) { + return this.getDescriptor(); + } + }); + } + + /** + * Injects or revokes this entry if its current state does not match + * the context's current permissions. + */ + permissionsChanged() { + if (this.injected) { + this.maybeRevoke(); + } else { + this.maybeInject(); + } + } + + maybeInject() { + if (!this.injected && !this.lazyInjected) { + this.lazyInject(); + } + } + + maybeRevoke() { + if (this.injected && !this.hasPermission) { + this.revoke(); + } + } +} + +/** + * Holds methods that run the actual implementation of the extension APIs. These + * methods are only called if the extension API invocation matches the signature + * as defined in the schema. Otherwise an error is reported to the context. + */ +class InjectionContext extends Context { + constructor(params) { + super(params, CONTEXT_FOR_INJECTION); + + this.pendingEntries = new Set(); + this.children = new DefaultWeakMap(() => new Map()); + + if (params.setPermissionsChangedCallback) { + params.setPermissionsChangedCallback( + this.permissionsChanged.bind(this)); + } + } + + /** + * Check whether the API should be injected. + * + * @abstract + * @param {string} namespace The namespace of the API. This may contain dots, + * e.g. in the case of "devtools.inspectedWindow". + * @param {string} [name] The name of the property in the namespace. + * `null` if we are checking whether the namespace should be injected. + * @param {Array} allowedContexts A list of additional contexts in which + * this API should be available. May include any of: + * "main" - The main chrome browser process. + * "addon" - An addon process. + * "content" - A content process. + * @returns {boolean} Whether the API should be injected. + */ + shouldInject(namespace, name, allowedContexts) { + throw new Error("Not implemented"); + } + + /** + * Generate the implementation for `namespace`.`name`. + * + * @abstract + * @param {string} namespace The full path to the namespace of the API, minus + * the name of the method or property. E.g. "storage.local". + * @param {string} name The name of the method, property or event. + * @returns {SchemaAPIInterface} The implementation of the API. + */ + getImplementation(namespace, name) { + throw new Error("Not implemented"); + } + + /** + * Updates all injection entries which may need to be updated after a + * permission change, revoking or re-injecting them as necessary. + */ + permissionsChanged() { + for (let entry of this.pendingEntries) { + try { + entry.permissionsChanged(); + } catch (e) { + Cu.reportError(e); + } + } + } + + /** + * Recursively revokes all child injection entries of the given + * object. + * + * @param {object} object + * The object for which to invoke children. + */ + revokeChildren(object) { + if (!this.children.has(object)) { + return; + } + + let children = this.children.get(object); + for (let [name, entry] of children.entries()) { + try { + entry.revoke(); + } catch (e) { + Cu.reportError(e); + } + children.delete(name); + + // When we revoke children for an object, we consider that object + // dead. If the entry is ever reified again, a new object is + // created, with new child entries. + this.pendingEntries.delete(entry); + } + this.children.delete(object); + } + + _getInjectionEntry(entry, dest, name, path, parentEntry) { + let injection = new InjectionEntry(this, entry, dest, name, path, parentEntry); + + this.children.get(dest).set(name, injection); + + return injection; + } + + /** + * Returns the property descriptor for the given entry. + * + * @param {Entry} entry + * The entry instance to return a descriptor for. + * @param {object} dest + * The object into which this entry is being injected. + * @param {string} name + * The property name on the destination object where the entry + * will be injected. + * @param {Array} path + * The full path from the root injection object to this entry. + * @param {Entry} parentEntry + * The parent entry for this entry. + * + * @returns {object?} + * A property descriptor object, or null if the entry should + * not be injected. + */ + getDescriptor(entry, dest, name, path, parentEntry) { + let injection = this._getInjectionEntry(entry, dest, name, path, parentEntry); + + return injection.getDescriptor(); + } + + /** + * Lazily injects the given entry into the given object. + * + * @param {Entry} entry + * The entry instance to lazily inject. + * @param {object} dest + * The object into which to inject this entry. + * @param {string} name + * The property name at which to inject the entry. + * @param {Array} path + * The full path from the root injection object to this entry. + * @param {Entry} parentEntry + * The parent entry for this entry. + */ + injectInto(entry, dest, name, path, parentEntry) { + let injection = this._getInjectionEntry(entry, dest, name, path, parentEntry); + + injection.lazyInject(); + } +} + +/** + * The methods in this singleton represent the "format" specifier for + * JSON Schema string types. + * + * Each method either returns a normalized version of the original + * value, or throws an error if the value is not valid for the given + * format. + */ +const FORMATS = { + url(string, context) { + let url = new URL(string).href; + + if (!context.checkLoadURL(url)) { + throw new Error(`Access denied for URL ${url}`); + } + return url; + }, + + relativeUrl(string, context) { + if (!context.url) { + // If there's no context URL, return relative URLs unresolved, and + // skip security checks for them. + try { + new URL(string); + } catch (e) { + return string; + } + } + + let url = new URL(string, context.url).href; + + if (!context.checkLoadURL(url)) { + throw new Error(`Access denied for URL ${url}`); + } + return url; + }, + + strictRelativeUrl(string, context) { + // Do not accept a string which resolves as an absolute URL, or any + // protocol-relative URL. + if (!string.startsWith("//")) { + try { + new URL(string); + } catch (e) { + return FORMATS.relativeUrl(string, context); + } + } + + throw new SyntaxError(`String ${JSON.stringify(string)} must be a relative URL`); + }, + + contentSecurityPolicy(string, context) { + let error = contentPolicyService.validateAddonCSP(string); + if (error != null) { + throw new SyntaxError(error); + } + return string; + }, + + date(string, context) { + // A valid ISO 8601 timestamp. + const PATTERN = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?(Z|([-+]\d{2}:?\d{2})))?$/; + if (!PATTERN.test(string)) { + throw new Error(`Invalid date string ${string}`); + } + // Our pattern just checks the format, we could still have invalid + // values (e.g., month=99 or month=02 and day=31). Let the Date + // constructor do the dirty work of validating. + if (isNaN(new Date(string))) { + throw new Error(`Invalid date string ${string}`); + } + return string; + }, +}; + +// Schema files contain namespaces, and each namespace contains types, +// properties, functions, and events. An Entry is a base class for +// types, properties, functions, and events. +class Entry { + constructor(schema = {}) { + /** + * If set to any value which evaluates as true, this entry is + * deprecated, and any access to it will result in a deprecation + * warning being logged to the browser console. + * + * If the value is a string, it will be appended to the deprecation + * message. If it contains the substring "${value}", it will be + * replaced with a string representation of the value being + * processed. + * + * If the value is any other truthy value, a generic deprecation + * message will be emitted. + */ + this.deprecated = false; + if ("deprecated" in schema) { + this.deprecated = schema.deprecated; + } + + /** + * @property {string} [preprocessor] + * If set to a string value, and a preprocessor of the same is + * defined in the validation context, it will be applied to this + * value prior to any normalization. + */ + this.preprocessor = schema.preprocess || null; + + /** + * @property {string} [postprocessor] + * If set to a string value, and a postprocessor of the same is + * defined in the validation context, it will be applied to this + * value after any normalization. + */ + this.postprocessor = schema.postprocess || null; + + /** + * @property {Array} allowedContexts A list of allowed contexts + * to consider before generating the API. + * These are not parsed by the schema, but passed to `shouldInject`. + */ + this.allowedContexts = schema.allowedContexts || []; + } + + /** + * Preprocess the given value with the preprocessor declared in + * `preprocessor`. + * + * @param {*} value + * @param {Context} context + * @returns {*} + */ + preprocess(value, context) { + if (this.preprocessor) { + return context.preprocessors[this.preprocessor](value, context); + } + return value; + } + + /** + * Postprocess the given result with the postprocessor declared in + * `postprocessor`. + * + * @param {object} result + * @param {Context} context + * @returns {object} + */ + postprocess(result, context) { + if (result.error || !this.postprocessor) { + return result; + } + + let value = context.postprocessors[this.postprocessor](result.value, context); + return {value}; + } + + /** + * Logs a deprecation warning for this entry, based on the value of + * its `deprecated` property. + * + * @param {Context} context + * @param {value} [value] + */ + logDeprecation(context, value = null) { + let message = "This property is deprecated"; + if (typeof(this.deprecated) == "string") { + message = this.deprecated; + if (message.includes("${value}")) { + try { + value = JSON.stringify(value); + } catch (e) { + value = String(value); + } + message = message.replace(/\$\{value\}/g, () => value); + } + } + + context.logError(context.makeError(message)); + } + + /** + * Checks whether the entry is deprecated and, if so, logs a + * deprecation message. + * + * @param {Context} context + * @param {value} [value] + */ + checkDeprecated(context, value = null) { + if (this.deprecated) { + this.logDeprecation(context, value); + } + } + + /** + * Returns an object containing property descriptor for use when + * injecting this entry into an API object. + * + * @param {Array} path The API path, e.g. `["storage", "local"]`. + * @param {InjectionContext} context + * + * @returns {object?} + * An object containing a `descriptor` property, specifying the + * entry's property descriptor, and an optional `revoke` + * method, to be called when the entry is being revoked. + */ + getDescriptor(path, context) { + return undefined; + } +} + +// Corresponds either to a type declared in the "types" section of the +// schema or else to any type object used throughout the schema. +class Type extends Entry { + /** + * @property {Array} EXTRA_PROPERTIES + * An array of extra properties which may be present for + * schemas of this type. + */ + static get EXTRA_PROPERTIES() { + return ["description", "deprecated", "preprocess", "postprocess", "allowedContexts"]; + } + + /** + * Parses the given schema object and returns an instance of this + * class which corresponds to its properties. + * + * @param {object} schema + * A JSON schema object which corresponds to a definition of + * this type. + * @param {Array} path + * The path to this schema object from the root schema, + * corresponding to the property names and array indices + * traversed during parsing in order to arrive at this schema + * object. + * @param {Array} [extraProperties] + * An array of extra property names which are valid for this + * schema in the current context. + * @returns {Type} + * An instance of this type which corresponds to the given + * schema object. + * @static + */ + static parseSchema(schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + return new this(schema); + } + + /** + * Checks that all of the properties present in the given schema + * object are valid properties for this type, and throws if invalid. + * + * @param {object} schema + * A JSON schema object. + * @param {Array} path + * The path to this schema object from the root schema, + * corresponding to the property names and array indices + * traversed during parsing in order to arrive at this schema + * object. + * @param {Array} [extra] + * An array of extra property names which are valid for this + * schema in the current context. + * @throws {Error} + * An error describing the first invalid property found in the + * schema object. + */ + static checkSchemaProperties(schema, path, extra = []) { + if (DEBUG) { + let allowedSet = new Set([...this.EXTRA_PROPERTIES, ...extra]); + + for (let prop of Object.keys(schema)) { + if (!allowedSet.has(prop)) { + throw new Error(`Internal error: Namespace ${path.join(".")} has ` + + `invalid type property "${prop}" ` + + `in type "${schema.id || JSON.stringify(schema)}"`); + } + } + } + } + + // Takes a value, checks that it has the correct type, and returns a + // "normalized" version of the value. The normalized version will + // include "nulls" in place of omitted optional properties. The + // result of this function is either {error: "Some type error"} or + // {value: }. + normalize(value, context) { + return context.error("invalid type"); + } + + // Unlike normalize, this function does a shallow check to see if + // |baseType| (one of the possible getValueBaseType results) is + // valid for this type. It returns true or false. It's used to fill + // in optional arguments to functions before actually type checking + + checkBaseType(baseType) { + return false; + } + + // Helper method that simply relies on checkBaseType to implement + // normalize. Subclasses can choose to use it or not. + normalizeBase(type, value, context) { + if (this.checkBaseType(getValueBaseType(value))) { + this.checkDeprecated(context, value); + return {value: this.preprocess(value, context)}; + } + + let choice; + if (/^[aeiou]/.test(type)) { + choice = `be an ${type} value`; + } else { + choice = `be a ${type} value`; + } + + return context.error(`Expected ${type} instead of ${JSON.stringify(value)}`, + choice); + } +} + +// Type that allows any value. +class AnyType extends Type { + normalize(value, context) { + this.checkDeprecated(context, value); + return this.postprocess({value}, context); + } + + checkBaseType(baseType) { + return true; + } +} + +// An untagged union type. +class ChoiceType extends Type { + static get EXTRA_PROPERTIES() { + return ["choices", ...super.EXTRA_PROPERTIES]; + } + + static parseSchema(schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let choices = schema.choices.map(t => Schemas.parseSchema(t, path)); + return new this(schema, choices); + } + + constructor(schema, choices) { + super(schema); + this.choices = choices; + } + + extend(type) { + this.choices.push(...type.choices); + + return this; + } + + normalize(value, context) { + this.checkDeprecated(context, value); + + let error; + let {choices, result} = context.withChoices(() => { + for (let choice of this.choices) { + let r = choice.normalize(value, context); + if (!r.error) { + return r; + } + + error = r; + } + }); + + if (result) { + return result; + } + if (choices.length <= 1) { + return error; + } + + let n = choices.length - 1; + choices[n] = `or ${choices[n]}`; + + let message; + if (typeof value === "object") { + message = `Value must either: ${choices.join(", ")}`; + } else { + message = `Value ${JSON.stringify(value)} must either: ${choices.join(", ")}`; + } + + return context.error(message, null); + } + + checkBaseType(baseType) { + return this.choices.some(t => t.checkBaseType(baseType)); + } +} + +// This is a reference to another type--essentially a typedef. +class RefType extends Type { + static get EXTRA_PROPERTIES() { + return ["$ref", ...super.EXTRA_PROPERTIES]; + } + + static parseSchema(schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let ref = schema.$ref; + let ns = path[0]; + if (ref.includes(".")) { + [ns, ref] = ref.split("."); + } + return new this(schema, ns, ref); + } + + // For a reference to a type named T declared in namespace NS, + // namespaceName will be NS and reference will be T. + constructor(schema, namespaceName, reference) { + super(schema); + this.namespaceName = namespaceName; + this.reference = reference; + } + + get targetType() { + let ns = Schemas.getNamespace(this.namespaceName); + let type = ns.get(this.reference); + if (!type) { + throw new Error(`Internal error: Type ${this.reference} not found`); + } + return type; + } + + normalize(value, context) { + this.checkDeprecated(context, value); + return this.targetType.normalize(value, context); + } + + checkBaseType(baseType) { + return this.targetType.checkBaseType(baseType); + } +} + +class StringType extends Type { + static get EXTRA_PROPERTIES() { + return ["enum", "minLength", "maxLength", "pattern", "format", + ...super.EXTRA_PROPERTIES]; + } + + static parseSchema(schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let enumeration = schema.enum || null; + if (enumeration) { + // The "enum" property is either a list of strings that are + // valid values or else a list of {name, description} objects, + // where the .name values are the valid values. + enumeration = enumeration.map(e => { + if (typeof(e) == "object") { + return e.name; + } + return e; + }); + } + + let pattern = null; + if (schema.pattern) { + try { + pattern = parsePattern(schema.pattern); + } catch (e) { + throw new Error(`Internal error: Invalid pattern ${JSON.stringify(schema.pattern)}`); + } + } + + let format = null; + if (schema.format) { + if (!(schema.format in FORMATS)) { + throw new Error(`Internal error: Invalid string format ${schema.format}`); + } + format = FORMATS[schema.format]; + } + return new this(schema, + schema.id || undefined, + enumeration, + schema.minLength || 0, + schema.maxLength || Infinity, + pattern, + format); + } + + constructor(schema, name, enumeration, minLength, maxLength, pattern, format) { + super(schema); + this.name = name; + this.enumeration = enumeration; + this.minLength = minLength; + this.maxLength = maxLength; + this.pattern = pattern; + this.format = format; + } + + normalize(value, context) { + let r = this.normalizeBase("string", value, context); + if (r.error) { + return r; + } + value = r.value; + + if (this.enumeration) { + if (this.enumeration.includes(value)) { + return this.postprocess({value}, context); + } + + let choices = this.enumeration.map(JSON.stringify).join(", "); + + return context.error(`Invalid enumeration value ${JSON.stringify(value)}`, + `be one of [${choices}]`); + } + + if (value.length < this.minLength) { + return context.error(`String ${JSON.stringify(value)} is too short (must be ${this.minLength})`, + `be longer than ${this.minLength}`); + } + if (value.length > this.maxLength) { + return context.error(`String ${JSON.stringify(value)} is too long (must be ${this.maxLength})`, + `be shorter than ${this.maxLength}`); + } + + if (this.pattern && !this.pattern.test(value)) { + return context.error(`String ${JSON.stringify(value)} must match ${this.pattern}`, + `match the pattern ${this.pattern.toSource()}`); + } + + if (this.format) { + try { + r.value = this.format(r.value, context); + } catch (e) { + return context.error(String(e), `match the format "${this.format.name}"`); + } + } + + return r; + } + + checkBaseType(baseType) { + return baseType == "string"; + } + + getDescriptor(path, context) { + if (this.enumeration) { + let obj = Cu.createObjectIn(context.cloneScope); + + for (let e of this.enumeration) { + obj[e.toUpperCase()] = e; + } + + return { + descriptor: {value: obj}, + }; + } + } +} + +let FunctionEntry; +let SubModuleType; + +class ObjectType extends Type { + static get EXTRA_PROPERTIES() { + return ["properties", "patternProperties", ...super.EXTRA_PROPERTIES]; + } + + static parseSchema(schema, path, extraProperties = []) { + if ("functions" in schema) { + return SubModuleType.parseSchema(schema, path, extraProperties); + } + + if (DEBUG && !("$extend" in schema)) { + // Only allow extending "properties" and "patternProperties". + extraProperties = ["additionalProperties", "isInstanceOf", ...extraProperties]; + } + this.checkSchemaProperties(schema, path, extraProperties); + + let parseProperty = (schema, extraProps = []) => { + return { + type: Schemas.parseSchema(schema, path, + DEBUG && ["unsupported", "onError", "permissions", "default", ...extraProps]), + optional: schema.optional || false, + unsupported: schema.unsupported || false, + onError: schema.onError || null, + default: schema.default === undefined ? null : schema.default, + }; + }; + + // Parse explicit "properties" object. + let properties = Object.create(null); + for (let propName of Object.keys(schema.properties || {})) { + properties[propName] = parseProperty(schema.properties[propName], ["optional"]); + } + + // Parse regexp properties from "patternProperties" object. + let patternProperties = []; + for (let propName of Object.keys(schema.patternProperties || {})) { + let pattern; + try { + pattern = parsePattern(propName); + } catch (e) { + throw new Error(`Internal error: Invalid property pattern ${JSON.stringify(propName)}`); + } + + patternProperties.push({ + pattern, + type: parseProperty(schema.patternProperties[propName]), + }); + } + + // Parse "additionalProperties" schema. + let additionalProperties = null; + if (schema.additionalProperties) { + let type = schema.additionalProperties; + if (type === true) { + type = {"type": "any"}; + } + + additionalProperties = Schemas.parseSchema(type, path); + } + + return new this(schema, properties, additionalProperties, patternProperties, schema.isInstanceOf || null); + } + + constructor(schema, properties, additionalProperties, patternProperties, isInstanceOf) { + super(schema); + this.properties = properties; + this.additionalProperties = additionalProperties; + this.patternProperties = patternProperties; + this.isInstanceOf = isInstanceOf; + } + + extend(type) { + for (let key of Object.keys(type.properties)) { + if (key in this.properties) { + throw new Error(`InternalError: Attempt to extend an object with conflicting property "${key}"`); + } + this.properties[key] = type.properties[key]; + } + + this.patternProperties.push(...type.patternProperties); + + return this; + } + + checkBaseType(baseType) { + return baseType == "object"; + } + + /** + * Extracts the enumerable properties of the given object, including + * function properties which would normally be omitted by X-ray + * wrappers. + * + * @param {object} value + * @param {Context} context + * The current parse context. + * @returns {object} + * An object with an `error` or `value` property. + */ + extractProperties(value, context) { + // |value| should be a JS Xray wrapping an object in the + // extension compartment. This works well except when we need to + // access callable properties on |value| since JS Xrays don't + // support those. To work around the problem, we verify that + // |value| is a plain JS object (i.e., not anything scary like a + // Proxy). Then we copy the properties out of it into a normal + // object using a waiver wrapper. + + let klass = Cu.getClassName(value, true); + if (klass != "Object") { + throw context.error(`Expected a plain JavaScript object, got a ${klass}`, + `be a plain JavaScript object`); + } + + let properties = Object.create(null); + + let waived = Cu.waiveXrays(value); + for (let prop of Object.getOwnPropertyNames(waived)) { + let desc = Object.getOwnPropertyDescriptor(waived, prop); + if (desc.get || desc.set) { + throw context.error("Objects cannot have getters or setters on properties", + "contain no getter or setter properties"); + } + // Chrome ignores non-enumerable properties. + if (desc.enumerable) { + properties[prop] = Cu.unwaiveXrays(desc.value); + } + } + + return properties; + } + + checkProperty(context, prop, propType, result, properties, remainingProps) { + let {type, optional, unsupported, onError} = propType; + let error = null; + + if (unsupported) { + if (prop in properties) { + error = context.error(`Property "${prop}" is unsupported by Firefox`, + `not contain an unsupported "${prop}" property`); + } + } else if (prop in properties) { + if (optional && (properties[prop] === null || properties[prop] === undefined)) { + result[prop] = propType.default; + } else { + let r = context.withPath(prop, () => type.normalize(properties[prop], context)); + if (r.error) { + error = r; + } else { + result[prop] = r.value; + properties[prop] = r.value; + } + } + remainingProps.delete(prop); + } else if (!optional) { + error = context.error(`Property "${prop}" is required`, + `contain the required "${prop}" property`); + } else if (optional !== "omit-key-if-missing") { + result[prop] = propType.default; + } + + if (error) { + if (onError == "warn") { + context.logError(error.error); + } else if (onError != "ignore") { + throw error; + } + + result[prop] = propType.default; + } + } + + normalize(value, context) { + try { + let v = this.normalizeBase("object", value, context); + if (v.error) { + return v; + } + value = v.value; + + if (this.isInstanceOf) { + if (DEBUG) { + if (Object.keys(this.properties).length || + this.patternProperties.length || + !(this.additionalProperties instanceof AnyType)) { + throw new Error("InternalError: isInstanceOf can only be used " + + "with objects that are otherwise unrestricted"); + } + } + + if (!instanceOf(value, this.isInstanceOf)) { + return context.error(`Object must be an instance of ${this.isInstanceOf}`, + `be an instance of ${this.isInstanceOf}`); + } + + // This is kind of a hack, but we can't normalize things that + // aren't JSON, so we just return them. + return this.postprocess({value}, context); + } + + let properties = this.extractProperties(value, context); + let remainingProps = new Set(Object.keys(properties)); + + let result = {}; + for (let prop of Object.keys(this.properties)) { + this.checkProperty(context, prop, this.properties[prop], result, + properties, remainingProps); + } + + for (let prop of Object.keys(properties)) { + for (let {pattern, type} of this.patternProperties) { + if (pattern.test(prop)) { + this.checkProperty(context, prop, type, result, + properties, remainingProps); + } + } + } + + if (this.additionalProperties) { + for (let prop of remainingProps) { + let type = this.additionalProperties; + let r = context.withPath(prop, () => type.normalize(properties[prop], context)); + if (r.error) { + return r; + } + result[prop] = r.value; + } + } else if (remainingProps.size == 1) { + return context.error(`Unexpected property "${[...remainingProps]}"`, + `not contain an unexpected "${[...remainingProps]}" property`); + } else if (remainingProps.size) { + let props = [...remainingProps].sort().join(", "); + return context.error(`Unexpected properties: ${props}`, + `not contain the unexpected properties [${props}]`); + } + + return this.postprocess({value: result}, context); + } catch (e) { + if (e.error) { + return e; + } + throw e; + } + } +} + +// This type is just a placeholder to be referred to by +// SubModuleProperty. No value is ever expected to have this type. +SubModuleType = class SubModuleType extends Type { + static get EXTRA_PROPERTIES() { + return ["functions", "events", "properties", ...super.EXTRA_PROPERTIES]; + } + + static parseSchema(schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + // The path we pass in here is only used for error messages. + path = [...path, schema.id]; + let functions = schema.functions.filter(fun => !fun.unsupported) + .map(fun => FunctionEntry.parseSchema(fun, path)); + + return new this(functions); + } + + constructor(functions) { + super(); + this.functions = functions; + } +}; + +class NumberType extends Type { + normalize(value, context) { + let r = this.normalizeBase("number", value, context); + if (r.error) { + return r; + } + + if (isNaN(r.value) || !Number.isFinite(r.value)) { + return context.error("NaN and infinity are not valid", + "be a finite number"); + } + + return r; + } + + checkBaseType(baseType) { + return baseType == "number" || baseType == "integer"; + } +} + +class IntegerType extends Type { + static get EXTRA_PROPERTIES() { + return ["minimum", "maximum", ...super.EXTRA_PROPERTIES]; + } + + static parseSchema(schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + return new this(schema, schema.minimum || -Infinity, schema.maximum || Infinity); + } + + constructor(schema, minimum, maximum) { + super(schema); + this.minimum = minimum; + this.maximum = maximum; + } + + normalize(value, context) { + let r = this.normalizeBase("integer", value, context); + if (r.error) { + return r; + } + value = r.value; + + // Ensure it's between -2**31 and 2**31-1 + if (!Number.isSafeInteger(value)) { + return context.error("Integer is out of range", + "be a valid 32 bit signed integer"); + } + + if (value < this.minimum) { + return context.error(`Integer ${value} is too small (must be at least ${this.minimum})`, + `be at least ${this.minimum}`); + } + if (value > this.maximum) { + return context.error(`Integer ${value} is too big (must be at most ${this.maximum})`, + `be no greater than ${this.maximum}`); + } + + return this.postprocess(r, context); + } + + checkBaseType(baseType) { + return baseType == "integer"; + } +} + +class BooleanType extends Type { + normalize(value, context) { + return this.normalizeBase("boolean", value, context); + } + + checkBaseType(baseType) { + return baseType == "boolean"; + } +} + +class ArrayType extends Type { + static get EXTRA_PROPERTIES() { + return ["items", "minItems", "maxItems", ...super.EXTRA_PROPERTIES]; + } + + static parseSchema(schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let items = Schemas.parseSchema(schema.items, path, ["onError"]); + + return new this(schema, items, schema.minItems || 0, schema.maxItems || Infinity); + } + + constructor(schema, itemType, minItems, maxItems) { + super(schema); + this.itemType = itemType; + this.minItems = minItems; + this.maxItems = maxItems; + this.onError = schema.items.onError || null; + } + + normalize(value, context) { + let v = this.normalizeBase("array", value, context); + if (v.error) { + return v; + } + value = v.value; + + let result = []; + for (let [i, element] of value.entries()) { + element = context.withPath(String(i), () => this.itemType.normalize(element, context)); + if (element.error) { + if (this.onError == "warn") { + context.logError(element.error); + } else if (this.onError != "ignore") { + return element; + } + continue; + } + result.push(element.value); + } + + if (result.length < this.minItems) { + return context.error(`Array requires at least ${this.minItems} items; you have ${result.length}`, + `have at least ${this.minItems} items`); + } + + if (result.length > this.maxItems) { + return context.error(`Array requires at most ${this.maxItems} items; you have ${result.length}`, + `have at most ${this.maxItems} items`); + } + + return this.postprocess({value: result}, context); + } + + checkBaseType(baseType) { + return baseType == "array"; + } +} + +class FunctionType extends Type { + static get EXTRA_PROPERTIES() { + return ["parameters", "async", "returns", ...super.EXTRA_PROPERTIES]; + } + + static parseSchema(schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let isAsync = !!schema.async; + let isExpectingCallback = typeof schema.async === "string"; + let parameters = null; + if ("parameters" in schema) { + parameters = []; + for (let param of schema.parameters) { + // Callbacks default to optional for now, because of promise + // handling. + let isCallback = isAsync && param.name == schema.async; + if (isCallback) { + isExpectingCallback = false; + } + + parameters.push({ + type: Schemas.parseSchema(param, path, ["name", "optional", "default"]), + name: param.name, + optional: param.optional == null ? isCallback : param.optional, + default: param.default == undefined ? null : param.default, + }); + } + } + let hasAsyncCallback = false; + if (isAsync) { + hasAsyncCallback = (parameters && + parameters.length && + parameters[parameters.length - 1].name == schema.async); + } + + if (DEBUG) { + if (isExpectingCallback) { + throw new Error(`Internal error: Expected a callback parameter ` + + `with name ${schema.async}`); + } + + if (isAsync && schema.returns) { + throw new Error("Internal error: Async functions must not " + + "have return values."); + } + if (isAsync && schema.allowAmbiguousOptionalArguments && !hasAsyncCallback) { + throw new Error("Internal error: Async functions with ambiguous " + + "arguments must declare the callback as the last parameter"); + } + } + + + return new this(schema, parameters, isAsync, hasAsyncCallback); + } + + constructor(schema, parameters, isAsync, hasAsyncCallback) { + super(schema); + this.parameters = parameters; + this.isAsync = isAsync; + this.hasAsyncCallback = hasAsyncCallback; + } + + normalize(value, context) { + return this.normalizeBase("function", value, context); + } + + checkBaseType(baseType) { + return baseType == "function"; + } +} + +// Represents a "property" defined in a schema namespace with a +// particular value. Essentially this is a constant. +class ValueProperty extends Entry { + constructor(schema, name, value) { + super(schema); + this.name = name; + this.value = value; + } + + getDescriptor(path, context) { + return { + descriptor: {value: this.value}, + }; + } +} + +// Represents a "property" defined in a schema namespace that is not a +// constant. +class TypeProperty extends Entry { + constructor(schema, path, name, type, writable, permissions) { + super(schema); + this.path = path; + this.name = name; + this.type = type; + this.writable = writable; + this.permissions = permissions; + } + + throwError(context, msg) { + throw context.makeError(`${msg} for ${this.path.join(".")}.${this.name}.`); + } + + getDescriptor(path, context) { + if (this.unsupported) { + return; + } + + let apiImpl = context.getImplementation(path.join("."), this.name); + + let getStub = () => { + this.checkDeprecated(context); + return apiImpl.getProperty(); + }; + + let descriptor = { + get: Cu.exportFunction(getStub, context.cloneScope), + }; + + if (this.writable) { + let setStub = (value) => { + let normalized = this.type.normalize(value, context); + if (normalized.error) { + this.throwError(context, normalized.error); + } + + apiImpl.setProperty(normalized.value); + }; + + descriptor.set = Cu.exportFunction(setStub, context.cloneScope); + } + + return { + descriptor, + revoke() { + apiImpl.revoke(); + apiImpl = null; + }, + }; + } +} + +class SubModuleProperty extends Entry { + // A SubModuleProperty represents a tree of objects and properties + // to expose to an extension. Currently we support only a limited + // form of sub-module properties, where "$ref" points to a + // SubModuleType containing a list of functions and "properties" is + // a list of additional simple properties. + // + // name: Name of the property stuff is being added to. + // namespaceName: Namespace in which the property lives. + // reference: Name of the type defining the functions to add to the property. + // properties: Additional properties to add to the module (unsupported). + constructor(schema, path, name, reference, properties, permissions) { + super(schema); + this.name = name; + this.path = path; + this.namespaceName = path.join("."); + this.reference = reference; + this.properties = properties; + this.permissions = permissions; + } + + getDescriptor(path, context) { + let obj = Cu.createObjectIn(context.cloneScope); + + let ns = Schemas.getNamespace(this.namespaceName); + let type = ns.get(this.reference); + if (!type && this.reference.includes(".")) { + let [namespaceName, ref] = this.reference.split("."); + ns = Schemas.getNamespace(namespaceName); + type = ns.get(ref); + } + + if (DEBUG) { + if (!type || !(type instanceof SubModuleType)) { + throw new Error(`Internal error: ${this.namespaceName}.${this.reference} ` + + `is not a sub-module`); + } + } + let subpath = [...path, this.name]; + + let functions = type.functions; + for (let fun of functions) { + context.injectInto(fun, obj, fun.name, subpath, ns); + } + + // TODO: Inject this.properties. + + return { + descriptor: {value: obj}, + revoke() { + let unwrapped = Cu.waiveXrays(obj); + for (let fun of functions) { + try { + delete unwrapped[fun.name]; + } catch (e) { + Cu.reportError(e); + } + } + }, + }; + } +} + +// This class is a base class for FunctionEntrys and Events. It takes +// care of validating parameter lists (i.e., handling of optional +// parameters and parameter type checking). +class CallEntry extends Entry { + constructor(schema, path, name, parameters, allowAmbiguousOptionalArguments) { + super(schema); + this.path = path; + this.name = name; + this.parameters = parameters; + this.allowAmbiguousOptionalArguments = allowAmbiguousOptionalArguments; + } + + throwError(context, msg) { + throw context.makeError(`${msg} for ${this.path.join(".")}.${this.name}.`); + } + + checkParameters(args, context) { + let fixedArgs = []; + + // First we create a new array, fixedArgs, that is the same as + // |args| but with default values in place of omitted optional parameters. + let check = (parameterIndex, argIndex) => { + if (parameterIndex == this.parameters.length) { + if (argIndex == args.length) { + return true; + } + return false; + } + + let parameter = this.parameters[parameterIndex]; + if (parameter.optional) { + // Try skipping it. + fixedArgs[parameterIndex] = parameter.default; + if (check(parameterIndex + 1, argIndex)) { + return true; + } + } + + if (argIndex == args.length) { + return false; + } + + let arg = args[argIndex]; + if (!parameter.type.checkBaseType(getValueBaseType(arg))) { + // For Chrome compatibility, use the default value if null or undefined + // is explicitly passed but is not a valid argument in this position. + if (parameter.optional && (arg === null || arg === undefined)) { + fixedArgs[parameterIndex] = Cu.cloneInto(parameter.default, global); + } else { + return false; + } + } else { + fixedArgs[parameterIndex] = arg; + } + + return check(parameterIndex + 1, argIndex + 1); + }; + + if (this.allowAmbiguousOptionalArguments) { + // When this option is set, it's up to the implementation to + // parse arguments. + // The last argument for asynchronous methods is either a function or null. + // This is specifically done for runtime.sendMessage. + if (this.hasAsyncCallback && typeof(args[args.length - 1]) != "function") { + args.push(null); + } + return args; + } + let success = check(0, 0); + if (!success) { + this.throwError(context, "Incorrect argument types"); + } + + // Now we normalize (and fully type check) all non-omitted arguments. + fixedArgs = fixedArgs.map((arg, parameterIndex) => { + if (arg === null) { + return null; + } + let parameter = this.parameters[parameterIndex]; + let r = parameter.type.normalize(arg, context); + if (r.error) { + this.throwError(context, `Type error for parameter ${parameter.name} (${r.error})`); + } + return r.value; + }); + + return fixedArgs; + } +} + +// Represents a "function" defined in a schema namespace. +FunctionEntry = class FunctionEntry extends CallEntry { + static parseSchema(schema, path) { + return new this(schema, path, schema.name, + Schemas.parseSchema(schema, path, + ["name", "unsupported", "returns", + "permissions", + "allowAmbiguousOptionalArguments"]), + schema.unsupported || false, + schema.allowAmbiguousOptionalArguments || false, + schema.returns || null, + schema.permissions || null); + } + + constructor(schema, path, name, type, unsupported, allowAmbiguousOptionalArguments, returns, permissions) { + super(schema, path, name, type.parameters, allowAmbiguousOptionalArguments); + this.unsupported = unsupported; + this.returns = returns; + this.permissions = permissions; + + this.isAsync = type.isAsync; + this.hasAsyncCallback = type.hasAsyncCallback; + } + + getDescriptor(path, context) { + let apiImpl = context.getImplementation(path.join("."), this.name); + + let stub; + if (this.isAsync) { + stub = (...args) => { + this.checkDeprecated(context); + let actuals = this.checkParameters(args, context); + let callback = null; + if (this.hasAsyncCallback) { + callback = actuals.pop(); + } + if (callback === null && context.isChromeCompat) { + // We pass an empty stub function as a default callback for + // the `chrome` API, so promise objects are not returned, + // and lastError values are reported immediately. + callback = () => {}; + } + return apiImpl.callAsyncFunction(actuals, callback); + }; + } else if (!this.returns) { + stub = (...args) => { + this.checkDeprecated(context); + let actuals = this.checkParameters(args, context); + return apiImpl.callFunctionNoReturn(actuals); + }; + } else { + stub = (...args) => { + this.checkDeprecated(context); + let actuals = this.checkParameters(args, context); + return apiImpl.callFunction(actuals); + }; + } + + return { + descriptor: {value: Cu.exportFunction(stub, context.cloneScope)}, + revoke() { + apiImpl.revoke(); + apiImpl = null; + }, + }; + } +}; + +// Represents an "event" defined in a schema namespace. +class Event extends CallEntry { + static parseSchema(event, path) { + let extraParameters = Array.from(event.extraParameters || [], param => ({ + type: Schemas.parseSchema(param, path, ["name", "optional", "default"]), + name: param.name, + optional: param.optional || false, + default: param.default == undefined ? null : param.default, + })); + + let extraProperties = ["name", "unsupported", "permissions", "extraParameters", + // We ignore these properties for now. + "returns", "filters"]; + + return new this(event, path, event.name, + Schemas.parseSchema(event, path, extraProperties), + extraParameters, + event.unsupported || false, + event.permissions || null); + } + + constructor(schema, path, name, type, extraParameters, unsupported, permissions) { + super(schema, path, name, extraParameters); + this.type = type; + this.unsupported = unsupported; + this.permissions = permissions; + } + + checkListener(listener, context) { + let r = this.type.normalize(listener, context); + if (r.error) { + this.throwError(context, "Invalid listener"); + } + return r.value; + } + + getDescriptor(path, context) { + let apiImpl = context.getImplementation(path.join("."), this.name); + + let addStub = (listener, ...args) => { + listener = this.checkListener(listener, context); + let actuals = this.checkParameters(args, context); + apiImpl.addListener(listener, actuals); + }; + + let removeStub = (listener) => { + listener = this.checkListener(listener, context); + apiImpl.removeListener(listener); + }; + + let hasStub = (listener) => { + listener = this.checkListener(listener, context); + return apiImpl.hasListener(listener); + }; + + let obj = Cu.createObjectIn(context.cloneScope); + + Cu.exportFunction(addStub, obj, {defineAs: "addListener"}); + Cu.exportFunction(removeStub, obj, {defineAs: "removeListener"}); + Cu.exportFunction(hasStub, obj, {defineAs: "hasListener"}); + + return { + descriptor: {value: obj}, + revoke() { + apiImpl.revoke(); + apiImpl = null; + + let unwrapped = Cu.waiveXrays(obj); + delete unwrapped.addListener; + delete unwrapped.removeListener; + delete unwrapped.hasListener; + }, + }; + } +} + +const TYPES = Object.freeze(Object.assign(Object.create(null), { + any: AnyType, + array: ArrayType, + boolean: BooleanType, + function: FunctionType, + integer: IntegerType, + number: NumberType, + object: ObjectType, + string: StringType, +})); + +const LOADERS = { + events: "loadEvent", + functions: "loadFunction", + properties: "loadProperty", + types: "loadType", +}; + +class Namespace extends Map { + constructor(name, path) { + super(); + + this._lazySchemas = []; + this.initialized = false; + + this.name = name; + this.path = name ? [...path, name] : [...path]; + + this.superNamespace = null; + + this.permissions = null; + this.allowedContexts = []; + this.defaultContexts = []; + } + + /** + * Adds a JSON Schema object to the set of schemas that represent this + * namespace. + * + * @param {object} schema + * A JSON schema object which partially describes this + * namespace. + */ + addSchema(schema) { + this._lazySchemas.push(schema); + + for (let prop of ["permissions", "allowedContexts", "defaultContexts"]) { + if (schema[prop]) { + this[prop] = schema[prop]; + } + } + + if (schema.$import) { + this.superNamespace = Schemas.getNamespace(schema.$import); + } + } + + /** + * Initializes the keys of this namespace based on the schema objects + * added via previous `addSchema` calls. + */ + init() { + if (this.initialized) { + return; + } + + if (this.superNamespace) { + this._lazySchemas.unshift(...this.superNamespace._lazySchemas); + } + + for (let type of Object.keys(LOADERS)) { + this[type] = new DefaultMap(() => []); + } + + for (let schema of this._lazySchemas) { + for (let type of schema.types || []) { + if (!type.unsupported) { + this.types.get(type.$extend || type.id).push(type); + } + } + + for (let [name, prop] of Object.entries(schema.properties || {})) { + if (!prop.unsupported) { + this.properties.get(name).push(prop); + } + } + + for (let fun of schema.functions || []) { + if (!fun.unsupported) { + this.functions.get(fun.name).push(fun); + } + } + + for (let event of schema.events || []) { + if (!event.unsupported) { + this.events.get(event.name).push(event); + } + } + } + + // For each type of top-level property in the schema object, iterate + // over all properties of that type, and create a temporary key for + // each property pointing to its type. Those temporary properties + // are later used to instantiate an Entry object based on the actual + // schema object. + for (let type of Object.keys(LOADERS)) { + for (let key of this[type].keys()) { + this.set(key, type); + } + } + + this.initialized = true; + + if (DEBUG) { + for (let key of this.keys()) { + this.get(key); + } + } + } + + /** + * Initializes the value of a given key, by parsing the schema object + * associated with it and replacing its temporary value with an `Entry` + * instance. + * + * @param {string} key + * The name of the property to initialize. + * @param {string} type + * The type of property the key represents. Must have a + * corresponding entry in the `LOADERS` object, pointing to the + * initialization method for that type. + * + * @returns {Entry} + */ + initKey(key, type) { + let loader = LOADERS[type]; + + for (let schema of this[type].get(key)) { + this.set(key, this[loader](key, schema)); + } + + return this.get(key); + } + + loadType(name, type) { + if ("$extend" in type) { + return this.extendType(type); + } + return Schemas.parseSchema(type, this.path, ["id"]); + } + + extendType(type) { + let targetType = this.get(type.$extend); + + // Only allow extending object and choices types for now. + if (targetType instanceof ObjectType) { + type.type = "object"; + } else if (DEBUG) { + if (!targetType) { + throw new Error(`Internal error: Attempt to extend a nonexistant type ${type.$extend}`); + } else if (!(targetType instanceof ChoiceType)) { + throw new Error(`Internal error: Attempt to extend a non-extensible type ${type.$extend}`); + } + } + + let parsed = Schemas.parseSchema(type, this.path, ["$extend"]); + + if (DEBUG && parsed.constructor !== targetType.constructor) { + throw new Error(`Internal error: Bad attempt to extend ${type.$extend}`); + } + + targetType.extend(parsed); + + return targetType; + } + + loadProperty(name, prop) { + if ("$ref" in prop) { + if (!prop.unsupported) { + return new SubModuleProperty(prop, this.path, name, + prop.$ref, prop.properties || {}, + prop.permissions || null); + } + } else if ("value" in prop) { + return new ValueProperty(prop, name, prop.value); + } else { + // We ignore the "optional" attribute on properties since we + // don't inject anything here anyway. + let type = Schemas.parseSchema(prop, [this.name], ["optional", "permissions", "writable"]); + return new TypeProperty(prop, this.path, name, type, prop.writable || false, + prop.permissions || null); + } + } + + loadFunction(name, fun) { + return FunctionEntry.parseSchema(fun, this.path); + } + + loadEvent(name, event) { + return Event.parseSchema(event, this.path); + } + + /** + * Injects the properties of this namespace into the given object. + * + * @param {object} dest + * The object into which to inject the namespace properties. + * @param {InjectionContext} context + * The injection context with which to inject the properties. + */ + injectInto(dest, context) { + for (let name of this.keys()) { + exportLazyProperty(dest, name, () => { + let entry = this.get(name); + + return context.getDescriptor(entry, dest, name, this.path, this); + }); + } + } + + getDescriptor(path, context) { + let obj = Cu.createObjectIn(context.cloneScope); + + this.injectInto(obj, context); + + // Only inject the namespace object if it isn't empty. + if (Object.keys(obj).length) { + return { + descriptor: {value: obj}, + }; + } + } + + keys() { + this.init(); + return super.keys(); + } + + * entries() { + for (let key of this.keys()) { + yield [key, this.get(key)]; + } + } + + get(key) { + this.init(); + let value = super.get(key); + + // The initial values of lazily-initialized schema properties are + // strings, pointing to the type of property, corresponding to one + // of the entries in the `LOADERS` object. + if (typeof value === "string") { + value = this.initKey(key, value); + } + + return value; + } + + /** + * Returns a Namespace object for the given namespace name. If a + * namespace object with this name does not already exist, it is + * created. If the name contains any '.' characters, namespaces are + * recursively created, for each dot-separated component. + * + * @param {string} name + * The name of the sub-namespace to retrieve. + * + * @returns {Namespace} + */ + getNamespace(name) { + let subName; + + let idx = name.indexOf("."); + if (idx > 0) { + subName = name.slice(idx + 1); + name = name.slice(0, idx); + } + + let ns = super.get(name); + if (!ns) { + ns = new Namespace(name, this.path); + this.set(name, ns); + } + + if (subName) { + return ns.getNamespace(subName); + } + return ns; + } + + has(key) { + this.init(); + return super.has(key); + } +} + +this.Schemas = { + initialized: false, + + REVOKE: Symbol("@@revoke"), + + // Maps a schema URL to the JSON contained in that schema file. This + // is useful for sending the JSON across processes. + schemaJSON: new Map(), + + // Map[ -> Map[ -> Entry]] + // This keeps track of all the schemas that have been loaded so far. + rootNamespace: new Namespace("", []), + + getNamespace(name) { + return this.rootNamespace.getNamespace(name); + }, + + parseSchema(schema, path, extraProperties = []) { + let allowedProperties = DEBUG && new Set(extraProperties); + + if ("choices" in schema) { + return ChoiceType.parseSchema(schema, path, allowedProperties); + } else if ("$ref" in schema) { + return RefType.parseSchema(schema, path, allowedProperties); + } + + let type = TYPES[schema.type]; + + if (DEBUG) { + allowedProperties.add("type"); + + if (!("type" in schema)) { + throw new Error(`Unexpected value for type: ${JSON.stringify(schema)}`); + } + + if (!type) { + throw new Error(`Unexpected type ${schema.type}`); + } + } + + return type.parseSchema(schema, path, allowedProperties); + }, + + init() { + if (this.initialized) { + return; + } + this.initialized = true; + + if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) { + let data = Services.cpmm.initialProcessData; + let schemas = data["Extension:Schemas"]; + if (schemas) { + this.schemaJSON = schemas; + } + + Services.cpmm.addMessageListener("Schema:Add", this); + } + + this.flushSchemas(); + }, + + receiveMessage(msg) { + switch (msg.name) { + case "Schema:Add": + this.schemaJSON.set(msg.data.url, msg.data.schema); + this.flushSchemas(); + break; + + case "Schema:Delete": + this.schemaJSON.delete(msg.data.url); + this.flushSchemas(); + break; + } + }, + + _needFlush: true, + flushSchemas() { + if (this._needFlush) { + this._needFlush = false; + XPCOMUtils.defineLazyGetter(this, "rootNamespace", + () => this.parseSchemas()); + } + }, + + parseSchemas() { + this._needFlush = true; + + Object.defineProperty(this, "rootNamespace", { + enumerable: true, + configurable: true, + value: new Namespace("", []), + }); + + for (let json of this.schemaJSON.values()) { + try { + this.loadSchema(json); + } catch (e) { + Cu.reportError(e); + } + } + + return this.rootNamespace; + }, + + loadSchema(json) { + for (let namespace of json) { + this.getNamespace(namespace.namespace) + .addSchema(namespace); + } + }, + + _loadCachedSchemasPromise: null, + loadCachedSchemas() { + if (!this._loadCachedSchemasPromise) { + this._loadCachedSchemasPromise = StartupCache.schemas.getAll().then(results => { + return results; + }); + } + + return this._loadCachedSchemasPromise; + }, + + addSchema(url, json) { + this.schemaJSON.set(url, json); + + let data = Services.ppmm.initialProcessData; + data["Extension:Schemas"] = this.schemaJSON; + + Services.ppmm.broadcastAsyncMessage("Schema:Add", {url, schema: json}); + + this.flushSchemas(); + }, + + async load(url) { + if (!isParentProcess) { + return; + } + + let schemaCache = await this.loadCachedSchemas(); + + let json = (schemaCache.get(url) || + await StartupCache.schemas.get(url, readJSON)); + + if (!this.schemaJSON.has(url)) { + this.addSchema(url, json); + } + }, + + unload(url) { + this.schemaJSON.delete(url); + + let data = Services.ppmm.initialProcessData; + data["Extension:Schemas"] = this.schemaJSON; + + Services.ppmm.broadcastAsyncMessage("Schema:Delete", {url}); + + this.flushSchemas(); + }, + + /** + * Checks whether a given object has the necessary permissions to + * expose the given namespace. + * + * @param {string} namespace + * The top-level namespace to check permissions for. + * @param {object} wrapperFuncs + * Wrapper functions for the given context. + * @param {function} wrapperFuncs.hasPermission + * A function which, when given a string argument, returns true + * if the context has the given permission. + * @returns {boolean} + * True if the context has permission for the given namespace. + */ + checkPermissions(namespace, wrapperFuncs) { + if (!this.initialized) { + this.init(); + } + + let ns = this.getNamespace(namespace); + if (ns && ns.permissions) { + return ns.permissions.some(perm => wrapperFuncs.hasPermission(perm)); + } + return true; + }, + + exportLazyGetter, + + /** + * Inject registered extension APIs into `dest`. + * + * @param {object} dest The root namespace for the APIs. + * This object is usually exposed to extensions as "chrome" or "browser". + * @param {object} wrapperFuncs An implementation of the InjectionContext + * interface, which runs the actual functionality of the generated API. + */ + inject(dest, wrapperFuncs) { + if (!this.initialized) { + this.init(); + } + + let context = new InjectionContext(wrapperFuncs); + + this.rootNamespace.injectInto(dest, context); + }, + + /** + * Normalize `obj` according to the loaded schema for `typeName`. + * + * @param {object} obj The object to normalize against the schema. + * @param {string} typeName The name in the format namespace.propertyname + * @param {object} context An implementation of Context. Any validation errors + * are reported to the given context. + * @returns {object} The normalized object. + */ + normalize(obj, typeName, context) { + if (!this.initialized) { + this.init(); + } + + let [namespaceName, prop] = typeName.split("."); + let ns = this.getNamespace(namespaceName); + let type = ns.get(prop); + + return type.normalize(obj, new Context(context)); + }, +}; diff --git a/toolkit/components/extensions/WebExtensionContentScript.h b/toolkit/components/extensions/WebExtensionContentScript.h new file mode 100644 index 0000000000..912e10b748 --- /dev/null +++ b/toolkit/components/extensions/WebExtensionContentScript.h @@ -0,0 +1,191 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#ifndef mozilla_extensions_WebExtensionContentScript_h +#define mozilla_extensions_WebExtensionContentScript_h + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/WebExtensionContentScriptBinding.h" + +#include "jspubtd.h" + +#include "mozilla/Maybe.h" +#include "mozilla/Variant.h" +#include "mozilla/extensions/MatchGlob.h" +#include "mozilla/extensions/MatchPattern.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +class nsILoadInfo; +class nsPIDOMWindowOuter; + +namespace mozilla { +namespace extensions { + +using dom::Nullable; +using ContentScriptInit = dom::WebExtensionContentScriptInit; + +class WebExtensionPolicy; + +class MOZ_STACK_CLASS DocInfo final +{ +public: + DocInfo(const URLInfo& aURL, nsILoadInfo* aLoadInfo); + + MOZ_IMPLICIT DocInfo(nsPIDOMWindowOuter* aWindow); + + const URLInfo& URL() const { return mURL; } + + // The principal of the document, or the expected principal of a request. + // May be null for non-DOMWindow DocInfo objects unless + // URL().InheritsPrincipal() is true. + nsIPrincipal* Principal() const; + + // Returns the URL of the document's principal. Note that this must *only* + // be called for codebase principals. + const URLInfo& PrincipalURL() const; + + bool IsTopLevel() const; + + uint64_t FrameID() const; + + nsPIDOMWindowOuter* GetWindow() const + { + if (mObj.is()) { + return mObj.as(); + } + return nullptr; + } + +private: + void SetURL(const URLInfo& aURL); + + const URLInfo mURL; + mutable Maybe mPrincipalURL; + + mutable Maybe mIsTopLevel; + mutable Maybe> mPrincipal; + mutable Maybe mFrameID; + + using Window = nsPIDOMWindowOuter*; + using LoadInfo = nsILoadInfo*; + + const Variant mObj; +}; + + +class WebExtensionContentScript final : public nsISupports + , public nsWrapperCache +{ + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(WebExtensionContentScript) + + + using MatchGlobArray = nsTArray>; + using RunAtEnum = dom::ContentScriptRunAt; + + static already_AddRefed + Constructor(dom::GlobalObject& aGlobal, + WebExtensionPolicy& aExtension, + const ContentScriptInit& aInit, + ErrorResult& aRv); + + + bool Matches(const DocInfo& aDoc) const; + bool MatchesURI(const URLInfo& aURL) const; + + bool MatchesLoadInfo(const URLInfo& aURL, nsILoadInfo* aLoadInfo) const + { + return Matches({aURL, aLoadInfo}); + } + bool MatchesWindow(nsPIDOMWindowOuter* aWindow) const + { + return Matches(aWindow); + } + + + WebExtensionPolicy* Extension() { return mExtension; } + const WebExtensionPolicy* Extension() const { return mExtension; } + + bool AllFrames() const { return mAllFrames; } + bool MatchAboutBlank() const { return mMatchAboutBlank; } + RunAtEnum RunAt() const { return mRunAt; } + + Nullable GetFrameID() const { return mFrameID; } + + MatchPatternSet* Matches() { return mMatches; } + const MatchPatternSet* GetMatches() const { return mMatches; } + + MatchPatternSet* GetExcludeMatches() { return mExcludeMatches; } + const MatchPatternSet* GetExcludeMatches() const { return mExcludeMatches; } + + void GetIncludeGlobs(Nullable& aGlobs) + { + ToNullable(mExcludeGlobs, aGlobs); + } + void GetExcludeGlobs(Nullable& aGlobs) + { + ToNullable(mExcludeGlobs, aGlobs); + } + + void GetCssPaths(nsTArray& aPaths) const + { + aPaths.AppendElements(mCssPaths); + } + void GetJsPaths(nsTArray& aPaths) const + { + aPaths.AppendElements(mJsPaths); + } + + + WebExtensionPolicy* GetParentObject() const { return mExtension; } + + virtual JSObject* WrapObject(JSContext* aCx, JS::HandleObject aGivenProto) override; + +protected: + friend class WebExtensionPolicy; + + virtual ~WebExtensionContentScript() = default; + + WebExtensionContentScript(WebExtensionPolicy& aExtension, + const ContentScriptInit& aInit, + ErrorResult& aRv); + +private: + RefPtr mExtension; + + RefPtr mMatches; + RefPtr mExcludeMatches; + + Nullable mIncludeGlobs; + Nullable mExcludeGlobs; + + nsTArray mCssPaths; + nsTArray mJsPaths; + + RunAtEnum mRunAt; + + bool mAllFrames; + Nullable mFrameID; + bool mMatchAboutBlank; + + template + void + ToNullable(const Nullable& aInput, Nullable& aOutput) + { + if (aInput.IsNull()) { + aOutput.SetNull(); + } else { + aOutput.SetValue(aInput.Value()); + } + } +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_WebExtensionContentScript_h diff --git a/toolkit/components/extensions/WebExtensionPolicy.cpp b/toolkit/components/extensions/WebExtensionPolicy.cpp new file mode 100644 index 0000000000..7f1fdb221e --- /dev/null +++ b/toolkit/components/extensions/WebExtensionPolicy.cpp @@ -0,0 +1,533 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#include "mozilla/ExtensionPolicyService.h" +#include "mozilla/AddonManagerWebAPI.h" +#include "mozilla/extensions/WebExtensionContentScript.h" +#include "mozilla/extensions/WebExtensionPolicy.h" + +#include "mozilla/Result.h" +#include "mozilla/Unused.h" +#include "nsEscape.h" +#include "nsISubstitutingProtocolHandler.h" +#include "nsNetUtil.h" +#include "nsPrintfCString.h" + +namespace mozilla { +namespace extensions { + +using namespace dom; + +static inline Result +WrapNSResult(PRStatus aRv) +{ + if (aRv != PR_SUCCESS) { + return mozilla::MakeGenericErrorResult(NS_ERROR_FAILURE); + } + return Ok(); +} + +static inline Result +WrapNSResult(nsresult aRv) +{ + if (NS_FAILED(aRv)) { + return mozilla::MakeGenericErrorResult(aRv); + } + return Ok(); +} + +#define NS_TRY(expr) MOZ_TRY(WrapNSResult(expr)) + +static const char kProto[] = "moz-extension"; + +static const char kBackgroundPageHTMLStart[] = "\n\ +\n\ + \n\ + "; + +static const char kBackgroundPageHTMLScript[] = "\n\ + "; + +static const char kBackgroundPageHTMLEnd[] = "\n\ + \n\ +"; + +class EscapeHTML final : public nsAdoptingCString +{ +public: + explicit EscapeHTML(const nsACString& str) + : nsAdoptingCString(nsEscapeHTML(str.BeginReading())) + {} +}; + + +static inline ExtensionPolicyService& +EPS() +{ + return ExtensionPolicyService::GetSingleton(); +} + +static nsISubstitutingProtocolHandler* +Proto() +{ + static nsCOMPtr sHandler; + + if (MOZ_UNLIKELY(!sHandler)) { + nsCOMPtr ios = do_GetIOService(); + MOZ_RELEASE_ASSERT(ios); + + nsCOMPtr handler; + ios->GetProtocolHandler(kProto, getter_AddRefs(handler)); + + sHandler = do_QueryInterface(handler); + MOZ_RELEASE_ASSERT(sHandler); + + ClearOnShutdown(&sHandler); + } + + return sHandler; +} + + +/***************************************************************************** + * WebExtensionPolicy + *****************************************************************************/ + +WebExtensionPolicy::WebExtensionPolicy(GlobalObject& aGlobal, + const WebExtensionInit& aInit, + ErrorResult& aRv) + : mId(NS_AtomizeMainThread(aInit.mId)) + , mHostname(aInit.mMozExtensionHostname) + , mContentSecurityPolicy(aInit.mContentSecurityPolicy) + , mLocalizeCallback(aInit.mLocalizeCallback) + , mPermissions(new AtomSet(aInit.mPermissions)) + , mHostPermissions(aInit.mAllowedOrigins) +{ + mWebAccessiblePaths.AppendElements(aInit.mWebAccessibleResources); + + if (!aInit.mBackgroundScripts.IsNull()) { + mBackgroundScripts.SetValue().AppendElements(aInit.mBackgroundScripts.Value()); + } + + if (mContentSecurityPolicy.IsVoid()) { + EPS().DefaultCSP(mContentSecurityPolicy); + } + + mContentScripts.SetCapacity(aInit.mContentScripts.Length()); + for (const auto& scriptInit : aInit.mContentScripts) { + RefPtr contentScript = + new WebExtensionContentScript(*this, scriptInit, aRv); + if (aRv.Failed()) { + return; + } + mContentScripts.AppendElement(Move(contentScript)); + } + + nsresult rv = NS_NewURI(getter_AddRefs(mBaseURI), aInit.mBaseURL); + if (NS_FAILED(rv)) { + aRv.Throw(rv); + } +} + +already_AddRefed +WebExtensionPolicy::Constructor(GlobalObject& aGlobal, + const WebExtensionInit& aInit, + ErrorResult& aRv) +{ + RefPtr policy = new WebExtensionPolicy(aGlobal, aInit, aRv); + if (aRv.Failed()) { + return nullptr; + } + return policy.forget(); +} + + +/* static */ void +WebExtensionPolicy::GetActiveExtensions(dom::GlobalObject& aGlobal, + nsTArray>& aResults) +{ + EPS().GetAll(aResults); +} + +/* static */ already_AddRefed +WebExtensionPolicy::GetByID(dom::GlobalObject& aGlobal, const nsAString& aID) +{ + return do_AddRef(EPS().GetByID(aID)); +} + +/* static */ already_AddRefed +WebExtensionPolicy::GetByHostname(dom::GlobalObject& aGlobal, const nsACString& aHostname) +{ + return do_AddRef(EPS().GetByHost(aHostname)); +} + +/* static */ already_AddRefed +WebExtensionPolicy::GetByURI(dom::GlobalObject& aGlobal, nsIURI* aURI) +{ + return do_AddRef(EPS().GetByURL(aURI)); +} + + +void +WebExtensionPolicy::SetActive(bool aActive, ErrorResult& aRv) +{ + if (aActive == mActive) { + return; + } + + bool ok = aActive ? Enable() : Disable(); + + if (!ok) { + aRv.Throw(NS_ERROR_UNEXPECTED); + } +} + +bool +WebExtensionPolicy::Enable() +{ + MOZ_ASSERT(!mActive); + + if (!EPS().RegisterExtension(*this)) { + return false; + } + + Unused << Proto()->SetSubstitution(MozExtensionHostname(), mBaseURI); + + mActive = true; + return true; +} + +bool +WebExtensionPolicy::Disable() +{ + MOZ_ASSERT(mActive); + MOZ_ASSERT(EPS().GetByID(Id()) == this); + + if (!EPS().UnregisterExtension(*this)) { + return false; + } + + Unused << Proto()->SetSubstitution(MozExtensionHostname(), nullptr); + + mActive = false; + return true; +} + +void +WebExtensionPolicy::GetURL(const nsAString& aPath, + nsAString& aResult, + ErrorResult& aRv) const +{ + auto result = GetURL(aPath); + if (result.isOk()) { + aResult = result.unwrap(); + } else { + aRv.Throw(result.unwrapErr()); + } +} + +Result +WebExtensionPolicy::GetURL(const nsAString& aPath) const +{ + nsPrintfCString spec("%s://%s/", kProto, mHostname.get()); + + nsCOMPtr uri; + NS_TRY(NS_NewURI(getter_AddRefs(uri), spec)); + + NS_TRY(uri->Resolve(NS_ConvertUTF16toUTF8(aPath), spec)); + + return NS_ConvertUTF8toUTF16(spec); +} + +/* static */ bool +WebExtensionPolicy::IsExtensionProcess(GlobalObject& aGlobal) +{ + return EPS().IsExtensionProcess(); +} + +nsCString +WebExtensionPolicy::BackgroundPageHTML() const +{ + nsAutoCString result; + + if (mBackgroundScripts.IsNull()) { + result.SetIsVoid(true); + return result; + } + + result.AppendLiteral(kBackgroundPageHTMLStart); + + for (auto& script : mBackgroundScripts.Value()) { + EscapeHTML escaped{NS_ConvertUTF16toUTF8(script)}; + + result.AppendPrintf(kBackgroundPageHTMLScript, escaped.get()); + } + + result.AppendLiteral(kBackgroundPageHTMLEnd); + return result; +} + +void +WebExtensionPolicy::Localize(const nsAString& aInput, nsString& aOutput) const +{ + mLocalizeCallback->Call(aInput, aOutput); +} + + +JSObject* +WebExtensionPolicy::WrapObject(JSContext* aCx, JS::HandleObject aGivenProto) +{ + return WebExtensionPolicyBinding::Wrap(aCx, this, aGivenProto); +} + +void +WebExtensionPolicy::GetContentScripts(nsTArray>& aScripts) const +{ + aScripts.AppendElements(mContentScripts); +} + + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(WebExtensionPolicy, mParent, + mLocalizeCallback, + mHostPermissions, + mWebAccessiblePaths, + mContentScripts) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(WebExtensionPolicy) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(WebExtensionPolicy) +NS_IMPL_CYCLE_COLLECTING_RELEASE(WebExtensionPolicy) + + +/***************************************************************************** + * WebExtensionContentScript + *****************************************************************************/ + +/* static */ already_AddRefed +WebExtensionContentScript::Constructor(GlobalObject& aGlobal, + WebExtensionPolicy& aExtension, + const ContentScriptInit& aInit, + ErrorResult& aRv) +{ + RefPtr script = new WebExtensionContentScript(aExtension, aInit, aRv); + if (aRv.Failed()) { + return nullptr; + } + return script.forget(); +} + +WebExtensionContentScript::WebExtensionContentScript(WebExtensionPolicy& aExtension, + const ContentScriptInit& aInit, + ErrorResult& aRv) + : mExtension(&aExtension) + , mMatches(aInit.mMatches) + , mExcludeMatches(aInit.mExcludeMatches) + , mCssPaths(aInit.mCssPaths) + , mJsPaths(aInit.mJsPaths) + , mRunAt(aInit.mRunAt) + , mAllFrames(aInit.mAllFrames) + , mFrameID(aInit.mFrameID) + , mMatchAboutBlank(aInit.mMatchAboutBlank) +{ + if (!aInit.mIncludeGlobs.IsNull()) { + mIncludeGlobs.SetValue().AppendElements(aInit.mIncludeGlobs.Value()); + } + + if (!aInit.mExcludeGlobs.IsNull()) { + mExcludeGlobs.SetValue().AppendElements(aInit.mExcludeGlobs.Value()); + } +} + + +bool +WebExtensionContentScript::Matches(const DocInfo& aDoc) const +{ + if (!mFrameID.IsNull()) { + if (aDoc.FrameID() != mFrameID.Value()) { + return false; + } + } else { + if (!mAllFrames && !aDoc.IsTopLevel()) { + return false; + } + } + + if (!mMatchAboutBlank && aDoc.URL().InheritsPrincipal()) { + return false; + } + + // Top-level about:blank is a special case. We treat it as a match if + // matchAboutBlank is true and it has the null principal. In all other + // cases, we test the URL of the principal that it inherits. + if (mMatchAboutBlank && aDoc.IsTopLevel() && + aDoc.URL().Spec().EqualsLiteral("about:blank") && + aDoc.Principal() && aDoc.Principal()->GetIsNullPrincipal()) { + return true; + } + + // With the exception of top-level about:blank documents with null + // principals, we never match documents that have non-codebase principals, + // including those with null principals or system principals. + if (aDoc.Principal() && !aDoc.Principal()->GetIsCodebasePrincipal()) { + return false; + } + + return MatchesURI(aDoc.PrincipalURL()); +} + +bool +WebExtensionContentScript::MatchesURI(const URLInfo& aURL) const +{ + if (!mMatches->Matches(aURL)) { + return false; + } + + if (mExcludeMatches && mExcludeMatches->Matches(aURL)) { + return false; + } + + if (!mIncludeGlobs.IsNull() && !mIncludeGlobs.Value().Matches(aURL.Spec())) { + return false; + } + + if (!mExcludeGlobs.IsNull() && mExcludeGlobs.Value().Matches(aURL.Spec())) { + return false; + } + + if (AddonManagerWebAPI::IsValidSite(aURL.URI())) { + return false; + } + + return true; +} + + +JSObject* +WebExtensionContentScript::WrapObject(JSContext* aCx, JS::HandleObject aGivenProto) +{ + return WebExtensionContentScriptBinding::Wrap(aCx, this, aGivenProto); +} + + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(WebExtensionContentScript, + mMatches, mExcludeMatches, + mIncludeGlobs, mExcludeGlobs, + mExtension) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(WebExtensionContentScript) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(WebExtensionContentScript) +NS_IMPL_CYCLE_COLLECTING_RELEASE(WebExtensionContentScript) + + +/***************************************************************************** + * DocInfo + *****************************************************************************/ + +DocInfo::DocInfo(const URLInfo& aURL, nsILoadInfo* aLoadInfo) + : mURL(aURL) + , mObj(AsVariant(aLoadInfo)) +{} + +DocInfo::DocInfo(nsPIDOMWindowOuter* aWindow) + : mURL(aWindow->GetDocumentURI()) + , mObj(AsVariant(aWindow)) +{} + +bool +DocInfo::IsTopLevel() const +{ + if (mIsTopLevel.isNothing()) { + struct Matcher + { + bool match(Window aWin) + { + return aWin == aWin->GetScriptableTop(); + } + bool match(LoadInfo aLoadInfo) { return aLoadInfo->GetIsTopLevelLoad(); } + }; + mIsTopLevel.emplace(mObj.match(Matcher())); + } + return mIsTopLevel.ref(); +} + +uint64_t +DocInfo::FrameID() const +{ + if (mFrameID.isNothing()) { + if (IsTopLevel()) { + mFrameID.emplace(0); + } else { + struct Matcher + { + uint64_t match(Window aWin) { return aWin->WindowID(); } + uint64_t match(LoadInfo aLoadInfo) { return aLoadInfo->GetOuterWindowID(); } + }; + mFrameID.emplace(mObj.match(Matcher())); + } + } + return mFrameID.ref(); +} + +nsIPrincipal* +DocInfo::Principal() const +{ + if (mPrincipal.isNothing()) { + struct Matcher + { + explicit Matcher(const DocInfo& aThis) : mThis(aThis) {} + const DocInfo& mThis; + + nsIPrincipal* match(Window aWin) + { + nsCOMPtr doc = aWin->GetDoc(); + return doc->NodePrincipal(); + } + nsIPrincipal* match(LoadInfo aLoadInfo) + { + if (!(mThis.URL().InheritsPrincipal() || aLoadInfo->GetForceInheritPrincipal())) { + return nullptr; + } + if (auto principal = aLoadInfo->PrincipalToInherit()) { + return principal; + } + return aLoadInfo->TriggeringPrincipal(); + } + }; + mPrincipal.emplace(mObj.match(Matcher(*this))); + } + return mPrincipal.ref(); +} + +const URLInfo& +DocInfo::PrincipalURL() const +{ + if (!URL().InheritsPrincipal() || + !(Principal() && Principal()->GetIsCodebasePrincipal())) { + return URL(); + } + + if (mPrincipalURL.isNothing()) { + nsIPrincipal* prin = Principal(); + nsCOMPtr uri; + if (NS_SUCCEEDED(prin->GetURI(getter_AddRefs(uri)))) { + MOZ_DIAGNOSTIC_ASSERT(uri); + mPrincipalURL.emplace(uri); + } else { + mPrincipalURL.emplace(URL()); + } + } + + return mPrincipalURL.ref(); +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/WebExtensionPolicy.h b/toolkit/components/extensions/WebExtensionPolicy.h new file mode 100644 index 0000000000..167cab8f59 --- /dev/null +++ b/toolkit/components/extensions/WebExtensionPolicy.h @@ -0,0 +1,174 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#ifndef mozilla_extensions_WebExtensionPolicy_h +#define mozilla_extensions_WebExtensionPolicy_h + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/WebExtensionPolicyBinding.h" +#include "mozilla/extensions/MatchPattern.h" + +#include "jspubtd.h" + +#include "mozilla/Result.h" +#include "mozilla/WeakPtr.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +namespace mozilla { +namespace extensions { + +using dom::WebExtensionInit; +using dom::WebExtensionLocalizeCallback; + +class WebExtensionContentScript; + +class WebExtensionPolicy final : public nsISupports + , public nsWrapperCache + , public SupportsWeakPtr +{ +public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(WebExtensionPolicy) + MOZ_DECLARE_WEAKREFERENCE_TYPENAME(WebExtensionPolicy) + + using ScriptArray = nsTArray>; + + static already_AddRefed + Constructor(dom::GlobalObject& aGlobal, const WebExtensionInit& aInit, ErrorResult& aRv); + + nsIAtom* Id() const { return mId; } + void GetId(nsAString& aId) const { aId = nsDependentAtomString(mId); }; + + const nsCString& MozExtensionHostname() const { return mHostname; } + void GetMozExtensionHostname(nsACString& aHostname) const + { + aHostname = MozExtensionHostname(); + } + + void GetBaseURL(nsACString& aBaseURL) const + { + MOZ_ALWAYS_SUCCEEDS(mBaseURI->GetSpec(aBaseURL)); + } + + void GetURL(const nsAString& aPath, nsAString& aURL, ErrorResult& aRv) const; + + Result GetURL(const nsAString& aPath) const; + + bool CanAccessURI(nsIURI* aURI, bool aExplicit = false) const + { + return mHostPermissions && mHostPermissions->Matches(aURI, aExplicit); + } + + bool IsPathWebAccessible(const nsAString& aPath) const + { + return mWebAccessiblePaths.Matches(aPath); + } + + bool HasPermission(const nsIAtom* aPermission) const + { + return mPermissions->Contains(aPermission); + } + bool HasPermission(const nsAString& aPermission) const + { + return mPermissions->Contains(aPermission); + } + + nsCString BackgroundPageHTML() const; + + void Localize(const nsAString& aInput, nsString& aResult) const; + + const nsString& ContentSecurityPolicy() const + { + return mContentSecurityPolicy; + } + void GetContentSecurityPolicy(nsAString& aCSP) const + { + aCSP = mContentSecurityPolicy; + } + + + already_AddRefed AllowedOrigins() + { + return do_AddRef(mHostPermissions); + } + void SetAllowedOrigins(MatchPatternSet& aAllowedOrigins) + { + mHostPermissions = &aAllowedOrigins; + } + + void GetPermissions(nsTArray& aResult) const + { + mPermissions->Get(aResult); + } + void SetPermissions(const nsTArray& aPermissions) + { + mPermissions = new AtomSet(aPermissions); + } + + void GetContentScripts(ScriptArray& aScripts) const; + const ScriptArray& ContentScripts() const { return mContentScripts; } + + + bool Active() const { return mActive; } + void SetActive(bool aActive, ErrorResult& aRv); + + + static void + GetActiveExtensions(dom::GlobalObject& aGlobal, nsTArray>& aResults); + + static already_AddRefed + GetByID(dom::GlobalObject& aGlobal, const nsAString& aID); + + static already_AddRefed + GetByHostname(dom::GlobalObject& aGlobal, const nsACString& aHostname); + + static already_AddRefed + GetByURI(dom::GlobalObject& aGlobal, nsIURI* aURI); + + + static bool IsExtensionProcess(dom::GlobalObject& aGlobal); + + + nsISupports* GetParentObject() const { return mParent; } + + virtual JSObject* WrapObject(JSContext* aCx, JS::HandleObject aGivenProto) override; + +protected: + virtual ~WebExtensionPolicy() = default; + +private: + WebExtensionPolicy(dom::GlobalObject& aGlobal, const WebExtensionInit& aInit, ErrorResult& aRv); + + bool Enable(); + bool Disable(); + + nsCOMPtr mParent; + + nsCOMPtr mId; + nsCString mHostname; + nsCOMPtr mBaseURI; + + nsString mContentSecurityPolicy; + + bool mActive = false; + + RefPtr mLocalizeCallback; + + RefPtr mPermissions; + RefPtr mHostPermissions; + MatchGlobSet mWebAccessiblePaths; + + Nullable> mBackgroundScripts; + + nsTArray> mContentScripts; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_WebExtensionPolicy_h diff --git a/toolkit/components/extensions/ext-alarms.js b/toolkit/components/extensions/ext-alarms.js new file mode 100644 index 0000000000..e466ee95d2 --- /dev/null +++ b/toolkit/components/extensions/ext-alarms.js @@ -0,0 +1,152 @@ +"use strict"; + +// The ext-* files are imported into the same scopes. +/* import-globals-from ext-toolkit.js */ + +// WeakMap[Extension -> Map[name -> Alarm]] +let alarmsMap = new WeakMap(); + +// WeakMap[Extension -> Set[callback]] +let alarmCallbacksMap = new WeakMap(); + +// Manages an alarm created by the extension (alarms API). +function Alarm(extension, name, alarmInfo) { + this.extension = extension; + this.name = name; + this.when = alarmInfo.when; + this.delayInMinutes = alarmInfo.delayInMinutes; + this.periodInMinutes = alarmInfo.periodInMinutes; + this.canceled = false; + + let delay, scheduledTime; + if (this.when) { + scheduledTime = this.when; + delay = this.when - Date.now(); + } else { + if (!this.delayInMinutes) { + this.delayInMinutes = this.periodInMinutes; + } + delay = this.delayInMinutes * 60 * 1000; + scheduledTime = Date.now() + delay; + } + + this.scheduledTime = scheduledTime; + + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init(this, delay, Ci.nsITimer.TYPE_ONE_SHOT); + this.timer = timer; +} + +Alarm.prototype = { + clear() { + this.timer.cancel(); + alarmsMap.get(this.extension).delete(this.name); + this.canceled = true; + }, + + observe(subject, topic, data) { + if (this.canceled) { + return; + } + + for (let callback of alarmCallbacksMap.get(this.extension)) { + callback(this); + } + + if (!this.periodInMinutes) { + this.clear(); + return; + } + + let delay = this.periodInMinutes * 60 * 1000; + this.scheduledTime = Date.now() + delay; + this.timer.init(this, delay, Ci.nsITimer.TYPE_ONE_SHOT); + }, + + get data() { + return { + name: this.name, + scheduledTime: this.scheduledTime, + periodInMinutes: this.periodInMinutes, + }; + }, +}; + +this.alarms = class extends ExtensionAPI { + onShutdown() { + let {extension} = this; + + if (alarmsMap.has(extension)) { + for (let alarm of alarmsMap.get(extension).values()) { + alarm.clear(); + } + alarmsMap.delete(extension); + alarmCallbacksMap.delete(extension); + } + } + + getAPI(context) { + let {extension} = context; + + alarmsMap.set(extension, new Map()); + alarmCallbacksMap.set(extension, new Set()); + + return { + alarms: { + create: function(name, alarmInfo) { + name = name || ""; + let alarms = alarmsMap.get(extension); + if (alarms.has(name)) { + alarms.get(name).clear(); + } + let alarm = new Alarm(extension, name, alarmInfo); + alarms.set(alarm.name, alarm); + }, + + get: function(name) { + name = name || ""; + let alarms = alarmsMap.get(extension); + if (alarms.has(name)) { + return Promise.resolve(alarms.get(name).data); + } + return Promise.resolve(); + }, + + getAll: function() { + let result = Array.from(alarmsMap.get(extension).values(), alarm => alarm.data); + return Promise.resolve(result); + }, + + clear: function(name) { + name = name || ""; + let alarms = alarmsMap.get(extension); + if (alarms.has(name)) { + alarms.get(name).clear(); + return Promise.resolve(true); + } + return Promise.resolve(false); + }, + + clearAll: function() { + let cleared = false; + for (let alarm of alarmsMap.get(extension).values()) { + alarm.clear(); + cleared = true; + } + return Promise.resolve(cleared); + }, + + onAlarm: new SingletonEventManager(context, "alarms.onAlarm", fire => { + let callback = alarm => { + fire.sync(alarm.data); + }; + + alarmCallbacksMap.get(extension).add(callback); + return () => { + alarmCallbacksMap.get(extension).delete(callback); + }; + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-backgroundPage.js b/toolkit/components/extensions/ext-backgroundPage.js new file mode 100644 index 0000000000..26a87530ae --- /dev/null +++ b/toolkit/components/extensions/ext-backgroundPage.js @@ -0,0 +1,74 @@ +"use strict"; + +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TelemetryStopwatch", + "resource://gre/modules/TelemetryStopwatch.jsm"); + +Cu.import("resource://gre/modules/ExtensionParent.jsm"); +var { + HiddenExtensionPage, + promiseExtensionViewLoaded, +} = ExtensionParent; + +// Responsible for the background_page section of the manifest. +class BackgroundPage extends HiddenExtensionPage { + constructor(extension, options) { + super(extension, "background"); + + this.page = options.page || null; + this.isGenerated = !!options.scripts; + + if (this.page) { + this.url = this.extension.baseURI.resolve(this.page); + } else if (this.isGenerated) { + this.url = this.extension.baseURI.resolve("_generated_background_page.html"); + } + + if (!this.extension.isExtensionURL(this.url)) { + this.extension.manifestError("Background page must be a file within the extension"); + this.url = this.extension.baseURI.resolve("_blank.html"); + } + } + + async build() { + TelemetryStopwatch.start("WEBEXT_BACKGROUND_PAGE_LOAD_MS", this); + await this.createBrowserElement(); + + extensions.emit("extension-browser-inserted", this.browser); + + this.browser.loadURI(this.url); + + let context = await promiseExtensionViewLoaded(this.browser); + TelemetryStopwatch.finish("WEBEXT_BACKGROUND_PAGE_LOAD_MS", this); + + if (context) { + // Wait until all event listeners registered by the script so far + // to be handled. + await Promise.all(context.listenerPromises); + context.listenerPromises = null; + } + + this.extension.emit("startup"); + } + + shutdown() { + super.shutdown(); + } +} + +this.backgroundPage = class extends ExtensionAPI { + onManifestEntry(entryName) { + let {manifest} = this.extension; + + this.bgPage = new BackgroundPage(this.extension, manifest.background); + + return this.bgPage.build(); + } + + onShutdown() { + this.bgPage.shutdown(); + } +}; diff --git a/toolkit/components/extensions/ext-browser-content.js b/toolkit/components/extensions/ext-browser-content.js new file mode 100644 index 0000000000..71ba12d1ac --- /dev/null +++ b/toolkit/components/extensions/ext-browser-content.js @@ -0,0 +1,294 @@ +/* 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/. */ +"use strict"; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "clearTimeout", + "resource://gre/modules/Timer.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionCommon", + "resource://gre/modules/ExtensionCommon.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "require", + "resource://devtools/shared/Loader.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "setTimeout", + "resource://gre/modules/Timer.jsm"); + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); +const { + getWinUtils, +} = ExtensionUtils; + +/* eslint-env mozilla/frame-script */ + +// Minimum time between two resizes. +const RESIZE_TIMEOUT = 100; + +/** + * Check if the provided color is fully opaque. + * + * @param {string} color + * Any valid CSS color. + * @returns {boolean} true if the color is opaque. + */ +const isOpaque = function(color) { + try { + if (/(rgba|hsla)/i.test(color)) { + // Match .123456, 123.456, 123456 with an optional % sign. + let numberRe = /(\.\d+|\d+\.?\d*)%?/g; + // hsla/rgba, opacity is the last number in the color string (can be a percentage). + let opacity = color.match(numberRe)[3]; + + // Convert to [0, 1] space if the opacity was expressed as a percentage. + if (opacity.includes("%")) { + opacity = opacity.slice(0, -1); + opacity = opacity / 100; + } + + return opacity * 1 >= 1; + } else if (/^#[a-f0-9]{4}$/i.test(color)) { + // Hex color with 4 characters, opacity is one if last character is F + return color.toUpperCase().endsWith("F"); + } else if (/^#[a-f0-9]{8}$/i.test(color)) { + // Hex color with 8 characters, opacity is one if last 2 characters are FF + return color.toUpperCase().endsWith("FF"); + } + } catch (e) { + // Invalid color. + } + return true; +}; + +const BrowserListener = { + init({allowScriptsToClose, blockParser, fixedWidth, maxHeight, maxWidth, stylesheets, isInline}) { + this.fixedWidth = fixedWidth; + this.stylesheets = stylesheets || []; + + this.isInline = isInline; + this.maxWidth = maxWidth; + this.maxHeight = maxHeight; + + this.blockParser = blockParser; + this.needsResize = fixedWidth || maxHeight || maxWidth; + + this.oldBackground = null; + + if (allowScriptsToClose) { + getWinUtils(content).allowScriptsToClose(); + } + + // Force external links to open in tabs. + docShell.isAppTab = true; + + if (this.blockParser) { + this.blockingPromise = new Promise(resolve => { + this.unblockParser = resolve; + }); + addEventListener("DOMDocElementInserted", this, true); + } + + addEventListener("load", this, true); + addEventListener("DOMWindowCreated", this, true); + addEventListener("DOMContentLoaded", this, true); + addEventListener("DOMWindowClose", this, true); + addEventListener("MozScrolledAreaChanged", this, true); + }, + + destroy() { + if (this.blockParser) { + removeEventListener("DOMDocElementInserted", this, true); + } + + removeEventListener("load", this, true); + removeEventListener("DOMWindowCreated", this, true); + removeEventListener("DOMContentLoaded", this, true); + removeEventListener("DOMWindowClose", this, true); + removeEventListener("MozScrolledAreaChanged", this, true); + }, + + receiveMessage({name, data}) { + if (name === "Extension:InitBrowser") { + this.init(data); + } else if (name === "Extension:UnblockParser") { + if (this.unblockParser) { + this.unblockParser(); + this.blockingPromise = null; + } + } + }, + + loadStylesheets() { + let winUtils = getWinUtils(content); + + for (let url of this.stylesheets) { + winUtils.addSheet(ExtensionCommon.stylesheetMap.get(url), winUtils.AGENT_SHEET); + } + }, + + handleEvent(event) { + switch (event.type) { + case "DOMDocElementInserted": + if (this.blockingPromise) { + event.target.blockParsing(this.blockingPromise); + } + break; + + case "DOMWindowCreated": + if (event.target === content.document) { + this.loadStylesheets(); + } + break; + + case "DOMWindowClose": + if (event.target === content) { + event.preventDefault(); + + sendAsyncMessage("Extension:DOMWindowClose"); + } + break; + + case "DOMContentLoaded": + if (event.target === content.document) { + sendAsyncMessage("Extension:BrowserContentLoaded", {url: content.location.href}); + + if (this.needsResize) { + this.handleDOMChange(true); + } + } + break; + + case "load": + if (event.target.contentWindow === content) { + // For about:addons inline s, we currently receive a load + // event on the element, but no load or DOMContentLoaded + // events from the content window. + + // Inline browsers don't receive the "DOMWindowCreated" event, so this + // is a workaround to load the stylesheets. + if (this.isInline) { + this.loadStylesheets(); + } + sendAsyncMessage("Extension:BrowserContentLoaded", {url: content.location.href}); + } else if (event.target !== content.document) { + break; + } + + if (!this.needsResize) { + break; + } + + // We use a capturing listener, so we get this event earlier than any + // load listeners in the content page. Resizing after a timeout ensures + // that we calculate the size after the entire event cycle has completed + // (unless someone spins the event loop, anyway), and hopefully after + // the content has made any modifications. + Promise.resolve().then(() => { + this.handleDOMChange(true); + }); + + // Mutation observer to make sure the panel shrinks when the content does. + new content.MutationObserver(this.handleDOMChange.bind(this)).observe( + content.document.documentElement, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }); + break; + + case "MozScrolledAreaChanged": + if (this.needsResize) { + this.handleDOMChange(); + } + break; + } + }, + + // Resizes the browser to match the preferred size of the content (debounced). + handleDOMChange(ignoreThrottling = false) { + if (ignoreThrottling && this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = null; + } + + if (this.resizeTimeout == null) { + this.resizeTimeout = setTimeout(() => { + try { + if (content) { + this._handleDOMChange("delayed"); + } + } finally { + this.resizeTimeout = null; + } + }, RESIZE_TIMEOUT); + + this._handleDOMChange(); + } + }, + + _handleDOMChange(detail) { + let doc = content.document; + + let body = doc.body; + if (!body || doc.compatMode === "BackCompat") { + // In quirks mode, the root element is used as the scroll frame, and the + // body lies about its scroll geometry, and returns the values for the + // root instead. + body = doc.documentElement; + } + + + let result; + if (this.fixedWidth) { + // If we're in a fixed-width area (namely a slide-in subview of the main + // menu panel), we need to calculate the view height based on the + // preferred height of the content document's root scrollable element at the + // current width, rather than the complete preferred dimensions of the + // content window. + + // Compensate for any offsets (margin, padding, ...) between the scroll + // area of the body and the outer height of the document. + let getHeight = elem => elem.getBoundingClientRect(elem).height; + let bodyPadding = getHeight(doc.documentElement) - getHeight(body); + + let height = Math.ceil(body.scrollHeight + bodyPadding); + + result = {height, detail}; + } else { + let background = doc.defaultView.getComputedStyle(body).backgroundColor; + if (!isOpaque(background)) { + // Ignore non-opaque backgrounds. + background = null; + } + + if (background !== this.oldBackground) { + sendAsyncMessage("Extension:BrowserBackgroundChanged", {background}); + } + this.oldBackground = background; + + + // Adjust the size of the browser based on its content's preferred size. + let {contentViewer} = docShell; + let ratio = content.devicePixelRatio; + + let w = {}, h = {}; + contentViewer.getContentSizeConstrained(this.maxWidth * ratio, + this.maxHeight * ratio, + w, h); + + let width = Math.ceil(w.value / ratio); + let height = Math.ceil(h.value / ratio); + + result = {width, height, detail}; + } + + sendAsyncMessage("Extension:BrowserResized", result); + }, +}; + +addMessageListener("Extension:InitBrowser", BrowserListener); +addMessageListener("Extension:UnblockParser", BrowserListener); diff --git a/toolkit/components/extensions/ext-c-backgroundPage.js b/toolkit/components/extensions/ext-c-backgroundPage.js new file mode 100644 index 0000000000..42945309f4 --- /dev/null +++ b/toolkit/components/extensions/ext-c-backgroundPage.js @@ -0,0 +1,25 @@ +"use strict"; + +this.backgroundPage = class extends ExtensionAPI { + getAPI(context) { + function getBackgroundPage() { + for (let view of context.extension.views) { + if (view.viewType == "background" && context.principal.subsumes(view.principal)) { + return view.contentWindow; + } + } + return null; + } + return { + extension: { + getBackgroundPage, + }, + + runtime: { + getBackgroundPage() { + return context.cloneScope.Promise.resolve(getBackgroundPage()); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-c-extension.js b/toolkit/components/extensions/ext-c-extension.js new file mode 100644 index 0000000000..dfcd4db3ad --- /dev/null +++ b/toolkit/components/extensions/ext-c-extension.js @@ -0,0 +1,57 @@ +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); + +this.extension = class extends ExtensionAPI { + getAPI(context) { + let api = { + getURL(url) { + return context.extension.baseURI.resolve(url); + }, + + get lastError() { + return context.lastError; + }, + + get inIncognitoContext() { + return context.incognito; + }, + }; + + if (context.envType === "addon_child") { + api.getViews = function(fetchProperties) { + let result = Cu.cloneInto([], context.cloneScope); + + for (let view of context.extension.views) { + if (!view.active) { + continue; + } + if (!context.principal.subsumes(view.principal)) { + continue; + } + + if (fetchProperties !== null) { + if (fetchProperties.type !== null && view.viewType != fetchProperties.type) { + continue; + } + + if (fetchProperties.windowId !== null && view.windowId != fetchProperties.windowId) { + continue; + } + + if (fetchProperties.tabId !== null && view.tabId != fetchProperties.tabId) { + continue; + } + } + + result.push(view.contentWindow); + } + + return result; + }; + } + + return {extension: api}; + } +}; diff --git a/toolkit/components/extensions/ext-c-identity.js b/toolkit/components/extensions/ext-c-identity.js new file mode 100644 index 0000000000..af12c2f98f --- /dev/null +++ b/toolkit/components/extensions/ext-c-identity.js @@ -0,0 +1,157 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +var {Constructor: CC} = Components; + +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils", + "resource://services-common/utils.js"); +XPCOMUtils.defineLazyPreferenceGetter(this, "redirectDomain", + "extensions.webextensions.identity.redirectDomain"); + +let CryptoHash = CC("@mozilla.org/security/hash;1", "nsICryptoHash", "initWithString"); + +Cu.importGlobalProperties(["URL", "XMLHttpRequest", "TextEncoder"]); + +var { + promiseDocumentLoaded, +} = ExtensionUtils; + +const computeHash = str => { + let byteArr = new TextEncoder().encode(str); + let hash = new CryptoHash("sha1"); + hash.update(byteArr, byteArr.length); + return CommonUtils.bytesAsHex(hash.finish(false)); +}; + +const checkRedirected = (url, redirectURI) => { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.open("HEAD", url); + // We expect this if the user has not authenticated. + xhr.onload = () => { + reject(0); + }; + // An unexpected error happened, log for extension authors. + xhr.onerror = () => { + reject(xhr.status); + }; + // Catch redirect to our redirect_uri before a new request is made. + xhr.channel.notificationCallbacks = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIInterfaceRequestor, Ci.nsIChannelEventSync]), + + getInterface: XPCOMUtils.generateQI([Ci.nsIChannelEventSink]), + + asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) { + let responseURL = newChannel.URI.spec; + if (responseURL.startsWith(redirectURI)) { + resolve(responseURL); + // Cancel the redirect. + callback.onRedirectVerifyCallback(Components.results.NS_BINDING_ABORTED); + return; + } + callback.onRedirectVerifyCallback(Components.results.NS_OK); + }, + }; + xhr.send(); + }); +}; + +const openOAuthWindow = (details, redirectURI) => { + let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); + let supportsStringPrefURL = Cc["@mozilla.org/supports-string;1"] + .createInstance(Ci.nsISupportsString); + supportsStringPrefURL.data = details.url; + args.appendElement(supportsStringPrefURL); + + let window = Services.ww.openWindow(null, + Services.prefs.getCharPref("browser.chromeURL"), + "launchWebAuthFlow_dialog", + "chrome,location=yes,centerscreen,dialog=no,resizable=yes", + args); + + return new Promise((resolve, reject) => { + let wpl; + + // If the user just closes the window we need to reject + function unloadlistener() { + window.removeEventListener("unload", unloadlistener); + window.gBrowser.removeTabsProgressListener(wpl); + reject({message: "User cancelled or denied access."}); + } + + wpl = { + onLocationChange(browser, webProgress, request, locationURI) { + if (locationURI.spec.startsWith(redirectURI)) { + resolve(locationURI.spec); + window.removeEventListener("unload", unloadlistener); + window.gBrowser.removeTabsProgressListener(wpl); + window.close(); + } + }, + onProgressChange() {}, + onStatusChange() {}, + onSecurityChange() {}, + }; + + promiseDocumentLoaded(window.document).then(() => { + window.gBrowser.addTabsProgressListener(wpl); + window.addEventListener("unload", unloadlistener); + }); + }); +}; + +this.identity = class extends ExtensionAPI { + getAPI(context) { + let {extension} = context; + return { + identity: { + launchWebAuthFlow: function(details) { + // In OAuth2 the url should have a redirect_uri param, parse the url and grab it + let url, redirectURI; + try { + url = new URL(details.url); + } catch (e) { + return Promise.reject({message: "details.url is invalid"}); + } + try { + redirectURI = new URL(url.searchParams.get("redirect_uri")); + if (!redirectURI) { + return Promise.reject({message: "redirect_uri is missing"}); + } + } catch (e) { + return Promise.reject({message: "redirect_uri is invalid"}); + } + if (!redirectURI.href.startsWith(this.getRedirectURL())) { + // Any url will work, but we suggest addons use getRedirectURL. + Services.console.logStringMessage("WebExtensions: redirect_uri should use browser.identity.getRedirectURL"); + } + + // If the request is automatically redirected the user has already + // authorized and we do not want to show the window. + return checkRedirected(details.url, redirectURI).catch((requestError) => { + // requestError is zero or xhr.status + if (requestError !== 0) { + Cu.reportError(`browser.identity auth check failed with ${requestError}`); + return Promise.reject({message: "Invalid request"}); + } + if (!details.interactive) { + return Promise.reject({message: `Requires user interaction`}); + } + + return openOAuthWindow(details, redirectURI); + }); + }, + + getRedirectURL: function(path = "") { + let hash = computeHash(extension.id); + let url = new URL(`https://${hash}.${redirectDomain}/`); + url.pathname = path; + return url.href; + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-c-permissions.js b/toolkit/components/extensions/ext-c-permissions.js new file mode 100644 index 0000000000..487acc73d9 --- /dev/null +++ b/toolkit/components/extensions/ext-c-permissions.js @@ -0,0 +1,22 @@ +"use strict"; + +var { + ExtensionError, +} = ExtensionUtils; + +this.permissions = class extends ExtensionAPI { + getAPI(context) { + return { + permissions: { + async request(perms) { + let winUtils = context.contentWindow.getInterface(Ci.nsIDOMWindowUtils); + if (!winUtils.isHandlingUserInput) { + throw new ExtensionError("May only request permissions from a user input handler"); + } + + return context.childManager.callParentAsyncFunction("permissions.request_parent", [perms]); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-c-runtime.js b/toolkit/components/extensions/ext-c-runtime.js new file mode 100644 index 0000000000..192d3c5aec --- /dev/null +++ b/toolkit/components/extensions/ext-c-runtime.js @@ -0,0 +1,129 @@ +"use strict"; + +this.runtime = class extends ExtensionAPI { + getAPI(context) { + let {extension} = context; + + return { + runtime: { + onConnect: context.messenger.onConnect("runtime.onConnect"), + + onMessage: context.messenger.onMessage("runtime.onMessage"), + + onConnectExternal: context.messenger.onConnectExternal("runtime.onConnectExternal"), + + onMessageExternal: context.messenger.onMessageExternal("runtime.onMessageExternal"), + + connect: function(extensionId, connectInfo) { + let name = (connectInfo !== null && connectInfo.name) || ""; + extensionId = extensionId || extension.id; + let recipient = {extensionId}; + + return context.messenger.connect(context.messageManager, name, recipient); + }, + + sendMessage(...args) { + let extensionId, message, options, responseCallback; + + if (typeof args[args.length - 1] === "function") { + responseCallback = args.pop(); + } + + function checkOptions(options) { + let toProxyScript = false; + if (typeof options !== "object") { + return [false, "runtime.sendMessage's options argument is invalid"]; + } + + for (let key of Object.keys(options)) { + if (key === "toProxyScript") { + let value = options[key]; + if (typeof value !== "boolean") { + return [false, "runtime.sendMessage's options.toProxyScript argument is invalid"]; + } + toProxyScript = value; + } else { + return [false, `Unexpected property ${key}`]; + } + } + + return [true, {toProxyScript}]; + } + + if (!args.length) { + return Promise.reject({message: "runtime.sendMessage's message argument is missing"}); + } else if (args.length === 1) { + message = args[0]; + } else if (args.length === 2) { + // With two optional arguments, this is the ambiguous case, + // particularly sendMessage("string", {} or null) + // Given that sending a message within the extension is generally + // more common than sending the empty object to another extension, + // we prefer that conclusion, as long as the second argument looks + // like valid options object, or is null/undefined. + let [validOpts] = checkOptions(args[1]); + if (validOpts || args[1] == null) { + [message, options] = args; + } else { + [extensionId, message] = args; + } + } else if (args.length === 3 || (args.length === 4 && args[3] == null)) { + [extensionId, message, options] = args; + } else if (args.length === 4 && !responseCallback) { + return Promise.reject({message: "runtime.sendMessage's last argument is not a function"}); + } else { + return Promise.reject({message: "runtime.sendMessage received too many arguments"}); + } + + if (extensionId != null && typeof extensionId !== "string") { + return Promise.reject({message: "runtime.sendMessage's extensionId argument is invalid"}); + } + + extensionId = extensionId || extension.id; + let recipient = {extensionId}; + + if (options != null) { + let [valid, arg] = checkOptions(options); + if (!valid) { + return Promise.reject({message: arg}); + } + Object.assign(recipient, arg); + } + + return context.messenger.sendMessage(context.messageManager, message, recipient, responseCallback); + }, + + connectNative(application) { + let recipient = { + childId: context.childManager.id, + toNativeApp: application, + }; + + return context.messenger.connectNative(context.messageManager, "", recipient); + }, + + sendNativeMessage(application, message) { + let recipient = { + childId: context.childManager.id, + toNativeApp: application, + }; + return context.messenger.sendNativeMessage(context.messageManager, message, recipient); + }, + + get lastError() { + return context.lastError; + }, + + getManifest() { + return Cu.cloneInto(extension.manifest, context.cloneScope); + }, + + id: extension.id, + + getURL: function(url) { + return extension.baseURI.resolve(url); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-c-storage.js b/toolkit/components/extensions/ext-c-storage.js new file mode 100644 index 0000000000..e18c170bf9 --- /dev/null +++ b/toolkit/components/extensions/ext-c-storage.js @@ -0,0 +1,62 @@ +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorage", + "resource://gre/modules/ExtensionStorage.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +this.storage = class extends ExtensionAPI { + getAPI(context) { + function sanitize(items) { + // The schema validator already takes care of arrays (which are only allowed + // to contain strings). Strings and null are safe values. + if (typeof items != "object" || items === null || Array.isArray(items)) { + return items; + } + // If we got here, then `items` is an object generated by `ObjectType`'s + // `normalize` method from Schemas.jsm. The object returned by `normalize` + // lives in this compartment, while the values live in compartment of + // `context.contentWindow`. The `sanitize` method runs with the principal + // of `context`, so we cannot just use `ExtensionStorage.sanitize` because + // it is not allowed to access properties of `items`. + // So we enumerate all properties and sanitize each value individually. + let sanitized = {}; + for (let [key, value] of Object.entries(items)) { + sanitized[key] = ExtensionStorage.sanitize(value, context); + } + return sanitized; + } + return { + storage: { + local: { + get: function(keys) { + keys = sanitize(keys); + return context.childManager.callParentAsyncFunction("storage.local.get", [ + keys, + ]); + }, + set: function(items) { + items = sanitize(items); + return context.childManager.callParentAsyncFunction("storage.local.set", [ + items, + ]); + }, + }, + + sync: { + get: function(keys) { + keys = sanitize(keys); + return context.childManager.callParentAsyncFunction("storage.sync.get", [ + keys, + ]); + }, + set: function(items) { + items = sanitize(items); + return context.childManager.callParentAsyncFunction("storage.sync.set", [ + items, + ]); + }, + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-c-test.js b/toolkit/components/extensions/ext-c-test.js new file mode 100644 index 0000000000..f63761d3d9 --- /dev/null +++ b/toolkit/components/extensions/ext-c-test.js @@ -0,0 +1,184 @@ +"use strict"; + +// The ext-c-* files are imported into the same scopes. +/* import-globals-from ext-c-toolkit.js */ + +/** + * Checks whether the given error matches the given expectations. + * + * @param {*} error + * The error to check. + * @param {string|RegExp|function|null} expectedError + * The expectation to check against. If this parameter is: + * + * - a string, the error message must exactly equal the string. + * - a regular expression, it must match the error message. + * - a function, it is called with the error object and its + * return value is returned. + * - null, the function always returns true. + * @param {BaseContext} context + * + * @returns {boolean} + * True if the error matches the expected error. + */ +const errorMatches = (error, expectedError, context) => { + if (expectedError === null) { + return true; + } + + if (typeof expectedError === "function") { + return context.runSafeWithoutClone(expectedError, error); + } + + if (typeof error !== "object" || error == null || + typeof error.message !== "string") { + return false; + } + + if (typeof expectedError === "string") { + return error.message === expectedError; + } + + try { + return expectedError.test(error.message); + } catch (e) { + Cu.reportError(e); + } + + return false; +}; + +/** + * Calls .toSource() on the given value, but handles null, undefined, + * and errors. + * + * @param {*} value + * @returns {string} + */ +const toSource = value => { + if (value === null) { + return "null"; + } + if (value === undefined) { + return "undefined"; + } + if (typeof value === "string") { + return JSON.stringify(value); + } + + try { + return String(value.toSource()); + } catch (e) { + return ""; + } +}; + +this.test = class extends ExtensionAPI { + getAPI(context) { + const {extension} = context; + + function getStack() { + return new context.cloneScope.Error().stack.replace(/^/gm, " "); + } + + function assertTrue(value, msg) { + extension.emit("test-result", Boolean(value), String(msg), getStack()); + } + + return { + test: { + sendMessage(...args) { + extension.emit("test-message", ...args); + }, + + notifyPass(msg) { + extension.emit("test-done", true, msg, getStack()); + }, + + notifyFail(msg) { + extension.emit("test-done", false, msg, getStack()); + }, + + log(msg) { + extension.emit("test-log", true, msg, getStack()); + }, + + fail(msg) { + assertTrue(false, msg); + }, + + succeed(msg) { + assertTrue(true, msg); + }, + + assertTrue(value, msg) { + assertTrue(value, msg); + }, + + assertFalse(value, msg) { + assertTrue(!value, msg); + }, + + assertEq(expected, actual, msg) { + let equal = expected === actual; + + expected = String(expected); + actual = String(actual); + + if (!equal && expected === actual) { + actual += " (different)"; + } + extension.emit("test-eq", equal, String(msg), expected, actual, getStack()); + }, + + assertRejects(promise, expectedError, msg) { + // Wrap in a native promise for consistency. + promise = Promise.resolve(promise); + + if (msg) { + msg = `: ${msg}`; + } + + return promise.then(result => { + assertTrue(false, `Promise resolved, expected rejection${msg}`); + }, error => { + let errorMessage = toSource(error && error.message); + + assertTrue(errorMatches(error, expectedError, context), + `Promise rejected, expecting rejection to match ${toSource(expectedError)}, ` + + `got ${errorMessage}${msg}`); + }); + }, + + assertThrows(func, expectedError, msg) { + if (msg) { + msg = `: ${msg}`; + } + + try { + func(); + + assertTrue(false, `Function did not throw, expected error${msg}`); + } catch (error) { + let errorMessage = toSource(error && error.message); + + assertTrue(errorMatches(error, expectedError, context), + `Function threw, expecting error to match ${toSource(expectedError)}` + + `got ${errorMessage}${msg}`); + } + }, + + onMessage: new SingletonEventManager(context, "test.onMessage", fire => { + let handler = (event, ...args) => { + fire.async(...args); + }; + + extension.on("test-harness-message", handler); + return () => { + extension.off("test-harness-message", handler); + }; + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-c-toolkit.js b/toolkit/components/extensions/ext-c-toolkit.js new file mode 100644 index 0000000000..3100f25ce5 --- /dev/null +++ b/toolkit/components/extensions/ext-c-toolkit.js @@ -0,0 +1,98 @@ +"use strict"; + +Cu.import("resource://gre/modules/ExtensionCommon.jsm"); + +// These are defined on "global" which is used for the same scopes as the other +// ext-c-*.js files. +/* exported SingletonEventManager */ +/* global SingletonEventManager: false */ + +global.SingletonEventManager = ExtensionCommon.SingletonEventManager; + +global.initializeBackgroundPage = (contentWindow) => { + // Override the `alert()` method inside background windows; + // we alias it to console.log(). + // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1203394 + let alertDisplayedWarning = false; + let alertOverwrite = text => { + if (!alertDisplayedWarning) { + require("devtools/client/framework/devtools-browser"); + + let hudservice = require("devtools/client/webconsole/hudservice"); + hudservice.openBrowserConsoleOrFocus(); + + contentWindow.console.warn("alert() is not supported in background windows; please use console.log instead."); + + alertDisplayedWarning = true; + } + + contentWindow.console.log(text); + }; + Cu.exportFunction(alertOverwrite, contentWindow, {defineAs: "alert"}); +}; + +extensions.registerModules({ + backgroundPage: { + url: "chrome://extensions/content/ext-c-backgroundPage.js", + scopes: ["addon_child"], + manifest: ["background"], + paths: [ + ["extension", "getBackgroundPage"], + ["runtime", "getBackgroundPage"], + ], + }, + extension: { + url: "chrome://extensions/content/ext-c-extension.js", + scopes: ["addon_child", "content_child", "devtools_child", "proxy_script"], + paths: [ + ["extension"], + ], + }, + i18n: { + url: "chrome://extensions/content/ext-i18n.js", + scopes: ["addon_child", "content_child", "devtools_child", "proxy_script"], + paths: [ + ["i18n"], + ], + }, + permissions: { + url: "chrome://extensions/content/ext-c-permissions.js", + scopes: ["addon_child", "content_child", "devtools_child", "proxy_script"], + paths: [ + ["permissions"], + ], + }, + runtime: { + url: "chrome://extensions/content/ext-c-runtime.js", + scopes: ["addon_child", "content_child", "devtools_child", "proxy_script"], + paths: [ + ["runtime"], + ], + }, + storage: { + url: "chrome://extensions/content/ext-c-storage.js", + scopes: ["addon_child", "content_child", "devtools_child", "proxy_script"], + paths: [ + ["storage"], + ], + }, + test: { + url: "chrome://extensions/content/ext-c-test.js", + scopes: ["addon_child", "content_child", "devtools_child", "proxy_script"], + paths: [ + ["test"], + ], + }, +}); + +if (AppConstants.MOZ_BUILD_APP === "browser") { + extensions.registerModules({ + identity: { + url: "chrome://extensions/content/ext-c-identity.js", + scopes: ["addon_child"], + paths: [ + ["identity"], + ], + }, + }); +} diff --git a/toolkit/components/extensions/ext-contextualIdentities.js b/toolkit/components/extensions/ext-contextualIdentities.js new file mode 100644 index 0000000000..a8db6944e4 --- /dev/null +++ b/toolkit/components/extensions/ext-contextualIdentities.js @@ -0,0 +1,134 @@ +"use strict"; + +// The ext-* files are imported into the same scopes. +/* import-globals-from ext-toolkit.js */ + +XPCOMUtils.defineLazyModuleGetter(this, "ContextualIdentityService", + "resource://gre/modules/ContextualIdentityService.jsm"); +XPCOMUtils.defineLazyPreferenceGetter(this, "containersEnabled", + "privacy.userContext.enabled"); + +const convertIdentity = identity => { + let result = { + name: ContextualIdentityService.getUserContextLabel(identity.userContextId), + icon: identity.icon, + color: identity.color, + cookieStoreId: getCookieStoreIdForContainer(identity.userContextId), + }; + + return result; +}; + +this.contextualIdentities = class extends ExtensionAPI { + getAPI(context) { + let self = { + contextualIdentities: { + get(cookieStoreId) { + if (!containersEnabled) { + return Promise.resolve(false); + } + + let containerId = getContainerForCookieStoreId(cookieStoreId); + if (!containerId) { + return Promise.resolve(null); + } + + let identity = ContextualIdentityService.getPublicIdentityFromId(containerId); + return Promise.resolve(convertIdentity(identity)); + }, + + query(details) { + if (!containersEnabled) { + return Promise.resolve(false); + } + + let identities = []; + ContextualIdentityService.getPublicIdentities().forEach(identity => { + if (details.name && + ContextualIdentityService.getUserContextLabel(identity.userContextId) != details.name) { + return; + } + + identities.push(convertIdentity(identity)); + }); + + return Promise.resolve(identities); + }, + + create(details) { + if (!containersEnabled) { + return Promise.resolve(false); + } + + let identity = ContextualIdentityService.create(details.name, + details.icon, + details.color); + return Promise.resolve(convertIdentity(identity)); + }, + + update(cookieStoreId, details) { + if (!containersEnabled) { + return Promise.resolve(false); + } + + let containerId = getContainerForCookieStoreId(cookieStoreId); + if (!containerId) { + return Promise.resolve(null); + } + + let identity = ContextualIdentityService.getPublicIdentityFromId(containerId); + if (!identity) { + return Promise.resolve(null); + } + + if (details.name !== null) { + identity.name = details.name; + } + + if (details.color !== null) { + identity.color = details.color; + } + + if (details.icon !== null) { + identity.icon = details.icon; + } + + if (!ContextualIdentityService.update(identity.userContextId, + identity.name, identity.icon, + identity.color)) { + return Promise.resolve(null); + } + + return Promise.resolve(convertIdentity(identity)); + }, + + remove(cookieStoreId) { + if (!containersEnabled) { + return Promise.resolve(false); + } + + let containerId = getContainerForCookieStoreId(cookieStoreId); + if (!containerId) { + return Promise.resolve(null); + } + + let identity = ContextualIdentityService.getPublicIdentityFromId(containerId); + if (!identity) { + return Promise.resolve(null); + } + + // We have to create the identity object before removing it. + let convertedIdentity = convertIdentity(identity); + + if (!ContextualIdentityService.remove(identity.userContextId)) { + return Promise.resolve(null); + } + + return Promise.resolve(convertedIdentity); + }, + }, + }; + + return self; + } +}; diff --git a/toolkit/components/extensions/ext-cookies.js b/toolkit/components/extensions/ext-cookies.js new file mode 100644 index 0000000000..852a19857f --- /dev/null +++ b/toolkit/components/extensions/ext-cookies.js @@ -0,0 +1,434 @@ +"use strict"; + +// The ext-* files are imported into the same scopes. +/* import-globals-from ext-toolkit.js */ + +XPCOMUtils.defineLazyModuleGetter(this, "ContextualIdentityService", + "resource://gre/modules/ContextualIdentityService.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); + +/* globals DEFAULT_STORE, PRIVATE_STORE */ + +const convertCookie = ({cookie, isPrivate}) => { + let result = { + name: cookie.name, + value: cookie.value, + domain: cookie.host, + hostOnly: !cookie.isDomain, + path: cookie.path, + secure: cookie.isSecure, + httpOnly: cookie.isHttpOnly, + session: cookie.isSession, + }; + + if (!cookie.isSession) { + result.expirationDate = cookie.expiry; + } + + if (cookie.originAttributes.userContextId) { + result.storeId = getCookieStoreIdForContainer(cookie.originAttributes.userContextId); + } else if (cookie.originAttributes.privateBrowsingId || isPrivate) { + result.storeId = PRIVATE_STORE; + } else { + result.storeId = DEFAULT_STORE; + } + + return result; +}; + +const isSubdomain = (otherDomain, baseDomain) => { + return otherDomain == baseDomain || otherDomain.endsWith("." + baseDomain); +}; + +// Checks that the given extension has permission to set the given cookie for +// the given URI. +const checkSetCookiePermissions = (extension, uri, cookie) => { + // Permission checks: + // + // - If the extension does not have permissions for the specified + // URL, it cannot set cookies for it. + // + // - If the specified URL could not set the given cookie, neither can + // the extension. + // + // Ideally, we would just have the cookie service make the latter + // determination, but that turns out to be quite complicated. At the + // moment, it requires constructing a cookie string and creating a + // dummy channel, both of which can be problematic. It also triggers + // a whole set of additional permission and preference checks, which + // may or may not be desirable. + // + // So instead, we do a similar set of checks here. Exactly what + // cookies a given URL should be able to set is not well-documented, + // and is not standardized in any standard that anyone actually + // follows. So instead, we follow the rules used by the cookie + // service. + // + // See source/netwerk/cookie/nsCookieService.cpp, in particular + // CheckDomain() and SetCookieInternal(). + + if (uri.scheme != "http" && uri.scheme != "https") { + return false; + } + + if (!extension.whiteListedHosts.matches(uri)) { + return false; + } + + if (!cookie.host) { + // If no explicit host is specified, this becomes a host-only cookie. + cookie.host = uri.host; + return true; + } + + // A leading "." is not expected, but is tolerated if it's not the only + // character in the host. If there is one, start by stripping it off. We'll + // add a new one on success. + if (cookie.host.length > 1) { + cookie.host = cookie.host.replace(/^\./, ""); + } + cookie.host = cookie.host.toLowerCase(); + + if (cookie.host != uri.host) { + // Not an exact match, so check for a valid subdomain. + let baseDomain; + try { + baseDomain = Services.eTLD.getBaseDomain(uri); + } catch (e) { + if (e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS || + e.result == Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS) { + // The cookie service uses these to determine whether the domain + // requires an exact match. We already know we don't have an exact + // match, so return false. In all other cases, re-raise the error. + return false; + } + throw e; + } + + // The cookie domain must be a subdomain of the base domain. This prevents + // us from setting cookies for domains like ".co.uk". + // The domain of the requesting URL must likewise be a subdomain of the + // cookie domain. This prevents us from setting cookies for entirely + // unrelated domains. + if (!isSubdomain(cookie.host, baseDomain) || + !isSubdomain(uri.host, cookie.host)) { + return false; + } + + // RFC2109 suggests that we may only add cookies for sub-domains 1-level + // below us, but enforcing that would break the web, so we don't. + } + + // An explicit domain was passed, so add a leading "." to make this a + // domain cookie. + cookie.host = "." + cookie.host; + + // We don't do any significant checking of path permissions. RFC2109 + // suggests we only allow sites to add cookies for sub-paths, similar to + // same origin policy enforcement, but no-one implements this. + + return true; +}; + +const query = function* (detailsIn, props, context) { + // Different callers want to filter on different properties. |props| + // tells us which ones they're interested in. + let details = {}; + props.forEach(property => { + if (detailsIn[property] !== null) { + details[property] = detailsIn[property]; + } + }); + + if ("domain" in details) { + details.domain = details.domain.toLowerCase().replace(/^\./, ""); + } + + let userContextId = 0; + let isPrivate = context.incognito; + if (details.storeId) { + if (!isValidCookieStoreId(details.storeId)) { + return; + } + + if (isDefaultCookieStoreId(details.storeId)) { + isPrivate = false; + } else if (isPrivateCookieStoreId(details.storeId)) { + isPrivate = true; + } else if (isContainerCookieStoreId(details.storeId)) { + isPrivate = false; + userContextId = getContainerForCookieStoreId(details.storeId); + if (!userContextId) { + return; + } + } + } + + let storeId = DEFAULT_STORE; + if (isPrivate) { + storeId = PRIVATE_STORE; + } else if ("storeId" in details) { + storeId = details.storeId; + } + + // We can use getCookiesFromHost for faster searching. + let enumerator; + let uri; + if ("url" in details) { + try { + uri = NetUtil.newURI(details.url).QueryInterface(Ci.nsIURL); + Services.cookies.usePrivateMode(isPrivate, () => { + enumerator = Services.cookies.getCookiesFromHost(uri.host, {userContextId}); + }); + } catch (ex) { + // This often happens for about: URLs + return; + } + } else if ("domain" in details) { + Services.cookies.usePrivateMode(isPrivate, () => { + enumerator = Services.cookies.getCookiesFromHost(details.domain, {userContextId}); + }); + } else { + Services.cookies.usePrivateMode(isPrivate, () => { + enumerator = Services.cookies.enumerator; + }); + } + + // Based on nsCookieService::GetCookieStringInternal + function matches(cookie) { + function domainMatches(host) { + return cookie.rawHost == host || (cookie.isDomain && host.endsWith(cookie.host)); + } + + function pathMatches(path) { + let cookiePath = cookie.path.replace(/\/$/, ""); + + if (!path.startsWith(cookiePath)) { + return false; + } + + // path == cookiePath, but without the redundant string compare. + if (path.length == cookiePath.length) { + return true; + } + + // URL path is a substring of the cookie path, so it matches if, and + // only if, the next character is a path delimiter. + let pathDelimiters = ["/", "?", "#", ";"]; + return pathDelimiters.includes(path[cookiePath.length]); + } + + // "Restricts the retrieved cookies to those that would match the given URL." + if (uri) { + if (!domainMatches(uri.host)) { + return false; + } + + if (cookie.isSecure && uri.scheme != "https") { + return false; + } + + if (!pathMatches(uri.path)) { + return false; + } + } + + if ("name" in details && details.name != cookie.name) { + return false; + } + + if (userContextId != cookie.originAttributes.userContextId) { + return false; + } + + // "Restricts the retrieved cookies to those whose domains match or are subdomains of this one." + if ("domain" in details && !isSubdomain(cookie.rawHost, details.domain)) { + return false; + } + + // "Restricts the retrieved cookies to those whose path exactly matches this string."" + if ("path" in details && details.path != cookie.path) { + return false; + } + + if ("secure" in details && details.secure != cookie.isSecure) { + return false; + } + + if ("session" in details && details.session != cookie.isSession) { + return false; + } + + // Check that the extension has permissions for this host. + if (!context.extension.whiteListedHosts.matchesCookie(cookie)) { + return false; + } + + return true; + } + + while (enumerator.hasMoreElements()) { + let cookie = enumerator.getNext().QueryInterface(Ci.nsICookie2); + if (matches(cookie)) { + yield {cookie, isPrivate, storeId}; + } + } +}; + +this.cookies = class extends ExtensionAPI { + getAPI(context) { + let {extension} = context; + let self = { + cookies: { + get: function(details) { + // FIXME: We don't sort by length of path and creation time. + for (let cookie of query(details, ["url", "name", "storeId"], context)) { + return Promise.resolve(convertCookie(cookie)); + } + + // Found no match. + return Promise.resolve(null); + }, + + getAll: function(details) { + let allowed = ["url", "name", "domain", "path", "secure", "session", "storeId"]; + let result = Array.from(query(details, allowed, context), convertCookie); + + return Promise.resolve(result); + }, + + set: function(details) { + let uri = NetUtil.newURI(details.url).QueryInterface(Ci.nsIURL); + + let path; + if (details.path !== null) { + path = details.path; + } else { + // This interface essentially emulates the behavior of the + // Set-Cookie header. In the case of an omitted path, the cookie + // service uses the directory path of the requesting URL, ignoring + // any filename or query parameters. + path = uri.directory; + } + + let name = details.name !== null ? details.name : ""; + let value = details.value !== null ? details.value : ""; + let secure = details.secure !== null ? details.secure : false; + let httpOnly = details.httpOnly !== null ? details.httpOnly : false; + let isSession = details.expirationDate === null; + let expiry = isSession ? Number.MAX_SAFE_INTEGER : details.expirationDate; + let isPrivate = context.incognito; + let userContextId = 0; + if (isDefaultCookieStoreId(details.storeId)) { + isPrivate = false; + } else if (isPrivateCookieStoreId(details.storeId)) { + isPrivate = true; + } else if (isContainerCookieStoreId(details.storeId)) { + let containerId = getContainerForCookieStoreId(details.storeId); + if (containerId === null) { + return Promise.reject({message: `Illegal storeId: ${details.storeId}`}); + } + isPrivate = false; + userContextId = containerId; + } else if (details.storeId !== null) { + return Promise.reject({message: "Unknown storeId"}); + } + + let cookieAttrs = {host: details.domain, path: path, isSecure: secure}; + if (!checkSetCookiePermissions(extension, uri, cookieAttrs)) { + return Promise.reject({message: `Permission denied to set cookie ${JSON.stringify(details)}`}); + } + + // The permission check may have modified the domain, so use + // the new value instead. + Services.cookies.usePrivateMode(isPrivate, () => { + Services.cookies.add(cookieAttrs.host, path, name, value, + secure, httpOnly, isSession, expiry, {userContextId}); + }); + + return self.cookies.get(details); + }, + + remove: function(details) { + for (let {cookie, isPrivate, storeId} of query(details, ["url", "name", "storeId"], context)) { + Services.cookies.usePrivateMode(isPrivate, () => { + Services.cookies.remove(cookie.host, cookie.name, cookie.path, false, cookie.originAttributes); + }); + + // Todo: could there be multiple per subdomain? + return Promise.resolve({ + url: details.url, + name: details.name, + storeId, + }); + } + + return Promise.resolve(null); + }, + + getAllCookieStores: function() { + let data = {}; + for (let tab of extension.tabManager.query()) { + if (!(tab.cookieStoreId in data)) { + data[tab.cookieStoreId] = []; + } + data[tab.cookieStoreId].push(tab.id); + } + + let result = []; + for (let key in data) { + result.push({id: key, tabIds: data[key], incognito: key == PRIVATE_STORE}); + } + return Promise.resolve(result); + }, + + onChanged: new SingletonEventManager(context, "cookies.onChanged", fire => { + let observer = (subject, topic, data) => { + let notify = (removed, cookie, cause) => { + cookie.QueryInterface(Ci.nsICookie2); + + if (extension.whiteListedHosts.matchesCookie(cookie)) { + fire.async({removed, cookie: convertCookie({cookie, isPrivate: topic == "private-cookie-changed"}), cause}); + } + }; + + // We do our best effort here to map the incompatible states. + switch (data) { + case "deleted": + notify(true, subject, "explicit"); + break; + case "added": + notify(false, subject, "explicit"); + break; + case "changed": + notify(true, subject, "overwrite"); + notify(false, subject, "explicit"); + break; + case "batch-deleted": + subject.QueryInterface(Ci.nsIArray); + for (let i = 0; i < subject.length; i++) { + let cookie = subject.queryElementAt(i, Ci.nsICookie2); + if (!cookie.isSession && cookie.expiry * 1000 <= Date.now()) { + notify(true, cookie, "expired"); + } else { + notify(true, cookie, "evicted"); + } + } + break; + } + }; + + Services.obs.addObserver(observer, "cookie-changed"); + Services.obs.addObserver(observer, "private-cookie-changed"); + return () => { + Services.obs.removeObserver(observer, "cookie-changed"); + Services.obs.removeObserver(observer, "private-cookie-changed"); + }; + }).api(), + }, + }; + + return self; + } +}; diff --git a/toolkit/components/extensions/ext-downloads.js b/toolkit/components/extensions/ext-downloads.js new file mode 100644 index 0000000000..50fe147edf --- /dev/null +++ b/toolkit/components/extensions/ext-downloads.js @@ -0,0 +1,801 @@ +"use strict"; + +// The ext-* files are imported into the same scopes. +/* import-globals-from ext-toolkit.js */ + +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Downloads", + "resource://gre/modules/Downloads.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "DownloadPaths", + "resource://gre/modules/DownloadPaths.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter", + "resource://gre/modules/EventEmitter.jsm"); + +var { + normalizeTime, +} = ExtensionUtils; + +var { + ignoreEvent, +} = ExtensionCommon; + +const DOWNLOAD_ITEM_FIELDS = ["id", "url", "referrer", "filename", "incognito", + "danger", "mime", "startTime", "endTime", + "estimatedEndTime", "state", + "paused", "canResume", "error", + "bytesReceived", "totalBytes", + "fileSize", "exists", + "byExtensionId", "byExtensionName"]; + +// Fields that we generate onChanged events for. +const DOWNLOAD_ITEM_CHANGE_FIELDS = ["endTime", "state", "paused", "canResume", + "error", "exists"]; + +// From https://fetch.spec.whatwg.org/#forbidden-header-name +const FORBIDDEN_HEADERS = ["ACCEPT-CHARSET", "ACCEPT-ENCODING", + "ACCESS-CONTROL-REQUEST-HEADERS", "ACCESS-CONTROL-REQUEST-METHOD", + "CONNECTION", "CONTENT-LENGTH", "COOKIE", "COOKIE2", "DATE", "DNT", + "EXPECT", "HOST", "KEEP-ALIVE", "ORIGIN", "REFERER", "TE", "TRAILER", + "TRANSFER-ENCODING", "UPGRADE", "VIA"]; + +const FORBIDDEN_PREFIXES = /^PROXY-|^SEC-/i; + +class DownloadItem { + constructor(id, download, extension) { + this.id = id; + this.download = download; + this.extension = extension; + this.prechange = {}; + } + + get url() { return this.download.source.url; } + get referrer() { return this.download.source.referrer; } + get filename() { return this.download.target.path; } + get incognito() { return this.download.source.isPrivate; } + get danger() { return "safe"; } // TODO + get mime() { return this.download.contentType; } + get startTime() { return this.download.startTime; } + get endTime() { return null; } // TODO + get estimatedEndTime() { return null; } // TODO + get state() { + if (this.download.succeeded) { + return "complete"; + } + if (this.download.canceled) { + return "interrupted"; + } + return "in_progress"; + } + get paused() { + return this.download.canceled && this.download.hasPartialData && !this.download.error; + } + get canResume() { + return (this.download.stopped || this.download.canceled) && + this.download.hasPartialData && !this.download.error; + } + get error() { + if (!this.download.startTime || !this.download.stopped || this.download.succeeded) { + return null; + } + // TODO store this instead of calculating it + + if (this.download.error) { + if (this.download.error.becauseSourceFailed) { + return "NETWORK_FAILED"; // TODO + } + if (this.download.error.becauseTargetFailed) { + return "FILE_FAILED"; // TODO + } + return "CRASH"; + } + return "USER_CANCELED"; + } + get bytesReceived() { + return this.download.currentBytes; + } + get totalBytes() { + return this.download.hasProgress ? this.download.totalBytes : -1; + } + get fileSize() { + // todo: this is supposed to be post-compression + return this.download.succeeded ? this.download.target.size : -1; + } + get exists() { return this.download.target.exists; } + get byExtensionId() { return this.extension ? this.extension.id : undefined; } + get byExtensionName() { return this.extension ? this.extension.name : undefined; } + + /** + * Create a cloneable version of this object by pulling all the + * fields into simple properties (instead of getters). + * + * @returns {object} A DownloadItem with flat properties, + * suitable for cloning. + */ + serialize() { + let obj = {}; + for (let field of DOWNLOAD_ITEM_FIELDS) { + obj[field] = this[field]; + } + if (obj.startTime) { + obj.startTime = obj.startTime.toISOString(); + } + return obj; + } + + // When a change event fires, handlers can look at how an individual + // field changed by comparing item.fieldname with item.prechange.fieldname. + // After all handlers have been invoked, this gets called to store the + // current values of all fields ahead of the next event. + _storePrechange() { + for (let field of DOWNLOAD_ITEM_CHANGE_FIELDS) { + this.prechange[field] = this[field]; + } + } +} + + +// DownloadMap maps back and forth betwen the numeric identifiers used in +// the downloads WebExtension API and a Download object from the Downloads jsm. +// todo: make id and extension info persistent (bug 1247794) +const DownloadMap = { + currentId: 0, + loadPromise: null, + + // Maps numeric id -> DownloadItem + byId: new Map(), + + // Maps Download object -> DownloadItem + byDownload: new WeakMap(), + + lazyInit() { + if (this.loadPromise == null) { + EventEmitter.decorate(this); + this.loadPromise = Downloads.getList(Downloads.ALL).then(list => { + let self = this; + return list.addView({ + onDownloadAdded(download) { + const item = self.newFromDownload(download, null); + self.emit("create", item); + item._storePrechange(); + }, + + onDownloadRemoved(download) { + const item = self.byDownload.get(download); + if (item != null) { + self.emit("erase", item); + self.byDownload.delete(download); + self.byId.delete(item.id); + } + }, + + onDownloadChanged(download) { + const item = self.byDownload.get(download); + if (item == null) { + Cu.reportError("Got onDownloadChanged for unknown download object"); + } else { + self.emit("change", item); + item._storePrechange(); + } + }, + }).then(() => list.getAll()) + .then(downloads => { + downloads.forEach(download => { + this.newFromDownload(download, null); + }); + }) + .then(() => list); + }); + } + return this.loadPromise; + }, + + getDownloadList() { + return this.lazyInit(); + }, + + getAll() { + return this.lazyInit().then(() => this.byId.values()); + }, + + fromId(id) { + const download = this.byId.get(id); + if (!download) { + throw new Error(`Invalid download id ${id}`); + } + return download; + }, + + newFromDownload(download, extension) { + if (this.byDownload.has(download)) { + return this.byDownload.get(download); + } + + const id = ++this.currentId; + let item = new DownloadItem(id, download, extension); + this.byId.set(id, item); + this.byDownload.set(download, item); + return item; + }, + + erase(item) { + // This will need to get more complicated for bug 1255507 but for now we + // only work with downloads in the DownloadList from getAll() + return this.getDownloadList().then(list => { + list.remove(item.download); + }); + }, +}; + +// Create a callable function that filters a DownloadItem based on a +// query object of the type passed to search() or erase(). +const downloadQuery = query => { + let queryTerms = []; + let queryNegativeTerms = []; + if (query.query != null) { + for (let term of query.query) { + if (term[0] == "-") { + queryNegativeTerms.push(term.slice(1).toLowerCase()); + } else { + queryTerms.push(term.toLowerCase()); + } + } + } + + function normalizeDownloadTime(arg, before) { + if (arg == null) { + return before ? Number.MAX_VALUE : 0; + } + return normalizeTime(arg).getTime(); + } + + const startedBefore = normalizeDownloadTime(query.startedBefore, true); + const startedAfter = normalizeDownloadTime(query.startedAfter, false); + // const endedBefore = normalizeDownloadTime(query.endedBefore, true); + // const endedAfter = normalizeDownloadTime(query.endedAfter, false); + + const totalBytesGreater = query.totalBytesGreater || 0; + const totalBytesLess = (query.totalBytesLess != null) + ? query.totalBytesLess : Number.MAX_VALUE; + + // Handle options for which we can have a regular expression and/or + // an explicit value to match. + function makeMatch(regex, value, field) { + if (value == null && regex == null) { + return input => true; + } + + let re; + try { + re = new RegExp(regex || "", "i"); + } catch (err) { + throw new Error(`Invalid ${field}Regex: ${err.message}`); + } + if (value == null) { + return input => re.test(input); + } + + value = value.toLowerCase(); + if (re.test(value)) { + return input => (value == input); + } + return input => false; + } + + const matchFilename = makeMatch(query.filenameRegex, query.filename, "filename"); + const matchUrl = makeMatch(query.urlRegex, query.url, "url"); + + return function(item) { + const url = item.url.toLowerCase(); + const filename = item.filename.toLowerCase(); + + if (!queryTerms.every(term => url.includes(term) || filename.includes(term))) { + return false; + } + + if (queryNegativeTerms.some(term => url.includes(term) || filename.includes(term))) { + return false; + } + + if (!matchFilename(filename) || !matchUrl(url)) { + return false; + } + + if (!item.startTime) { + if (query.startedBefore != null || query.startedAfter != null) { + return false; + } + } else if (item.startTime > startedBefore || item.startTime < startedAfter) { + return false; + } + + // todo endedBefore, endedAfter + + if (item.totalBytes == -1) { + if (query.totalBytesGreater != null || query.totalBytesLess != null) { + return false; + } + } else if (item.totalBytes <= totalBytesGreater || item.totalBytes >= totalBytesLess) { + return false; + } + + // todo: include danger + const SIMPLE_ITEMS = ["id", "mime", "startTime", "endTime", "state", + "paused", "error", + "bytesReceived", "totalBytes", "fileSize", "exists"]; + for (let field of SIMPLE_ITEMS) { + if (query[field] != null && item[field] != query[field]) { + return false; + } + } + + return true; + }; +}; + +const queryHelper = query => { + let matchFn; + try { + matchFn = downloadQuery(query); + } catch (err) { + return Promise.reject({message: err.message}); + } + + let compareFn; + if (query.orderBy != null) { + const fields = query.orderBy.map(field => field[0] == "-" + ? {reverse: true, name: field.slice(1)} + : {reverse: false, name: field}); + + for (let field of fields) { + if (!DOWNLOAD_ITEM_FIELDS.includes(field.name)) { + return Promise.reject({message: `Invalid orderBy field ${field.name}`}); + } + } + + compareFn = (dl1, dl2) => { + for (let field of fields) { + const val1 = dl1[field.name]; + const val2 = dl2[field.name]; + + if (val1 < val2) { + return field.reverse ? 1 : -1; + } else if (val1 > val2) { + return field.reverse ? -1 : 1; + } + } + return 0; + }; + } + + return DownloadMap.getAll().then(downloads => { + if (compareFn) { + downloads = Array.from(downloads); + downloads.sort(compareFn); + } + let results = []; + for (let download of downloads) { + if (query.limit && results.length >= query.limit) { + break; + } + if (matchFn(download)) { + results.push(download); + } + } + return results; + }); +}; + +this.downloads = class extends ExtensionAPI { + getAPI(context) { + let {extension} = context; + return { + downloads: { + download(options) { + let {filename} = options; + if (filename && AppConstants.platform === "win") { + // cross platform javascript code uses "/" + filename = filename.replace(/\//g, "\\"); + } + + if (filename != null) { + if (filename.length == 0) { + return Promise.reject({message: "filename must not be empty"}); + } + + let path = OS.Path.split(filename); + if (path.absolute) { + return Promise.reject({message: "filename must not be an absolute path"}); + } + + if (path.components.some(component => component == "..")) { + return Promise.reject({message: "filename must not contain back-references (..)"}); + } + } + + if (options.conflictAction == "prompt") { + // TODO + return Promise.reject({message: "conflictAction prompt not yet implemented"}); + } + + if (options.headers) { + for (let {name} of options.headers) { + if (FORBIDDEN_HEADERS.includes(name.toUpperCase()) || name.match(FORBIDDEN_PREFIXES)) { + return Promise.reject({message: "Forbidden request header name"}); + } + } + } + + // Handle method, headers and body options. + function adjustChannel(channel) { + if (channel instanceof Ci.nsIHttpChannel) { + const method = options.method || "GET"; + channel.requestMethod = method; + + if (options.headers) { + for (let {name, value} of options.headers) { + channel.setRequestHeader(name, value, false); + } + } + + if (options.body != null) { + const stream = Cc["@mozilla.org/io/string-input-stream;1"] + .createInstance(Ci.nsIStringInputStream); + stream.setData(options.body, options.body.length); + + channel.QueryInterface(Ci.nsIUploadChannel2); + channel.explicitSetUploadStream(stream, null, -1, method, false); + } + } + return Promise.resolve(); + } + + async function createTarget(downloadsDir) { + let target; + if (filename) { + target = OS.Path.join(downloadsDir, filename); + } else { + let uri = NetUtil.newURI(options.url); + + let remote = "download"; + if (uri instanceof Ci.nsIURL) { + remote = uri.fileName; + } + target = OS.Path.join(downloadsDir, remote); + } + + // Create any needed subdirectories if required by filename. + const dir = OS.Path.dirname(target); + await OS.File.makeDir(dir, {from: downloadsDir}); + + if (await OS.File.exists(target)) { + // This has a race, something else could come along and create + // the file between this test and them time the download code + // creates the target file. But we can't easily fix it without + // modifying DownloadCore so we live with it for now. + switch (options.conflictAction) { + case "uniquify": + default: + target = DownloadPaths.createNiceUniqueFile(new FileUtils.File(target)).path; + if (options.saveAs) { + // createNiceUniqueFile actually creates the file, which + // is premature if we need to show a SaveAs dialog. + await OS.File.remove(target); + } + break; + + case "overwrite": + break; + } + } + + if (!options.saveAs) { + return target; + } + + // Setup the file picker Save As dialog. + const picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + const window = Services.wm.getMostRecentWindow("navigator:browser"); + picker.init(window, null, Ci.nsIFilePicker.modeSave); + picker.displayDirectory = new FileUtils.File(dir); + picker.appendFilters(Ci.nsIFilePicker.filterAll); + picker.defaultString = OS.Path.basename(target); + + // Open the dialog and resolve/reject with the result. + return new Promise((resolve, reject) => { + picker.open(result => { + if (result === Ci.nsIFilePicker.returnCancel) { + reject({message: "Download canceled by the user"}); + } else { + resolve(picker.file.path); + } + }); + }); + } + + let download; + return Downloads.getPreferredDownloadsDirectory() + .then(downloadsDir => createTarget(downloadsDir)) + .then(target => { + const source = { + url: options.url, + }; + + if (options.method || options.headers || options.body) { + source.adjustChannel = adjustChannel; + } + + return Downloads.createDownload({ + source, + target: { + path: target, + partFilePath: target + ".part", + }, + }); + }).then(dl => { + download = dl; + return DownloadMap.getDownloadList(); + }).then(list => { + list.add(download); + + // This is necessary to make pause/resume work. + download.tryToKeepPartialData = true; + download.start(); + + const item = DownloadMap.newFromDownload(download, extension); + return item.id; + }); + }, + + removeFile(id) { + return DownloadMap.lazyInit().then(() => { + let item; + try { + item = DownloadMap.fromId(id); + } catch (err) { + return Promise.reject({message: `Invalid download id ${id}`}); + } + if (item.state !== "complete") { + return Promise.reject({message: `Cannot remove incomplete download id ${id}`}); + } + return OS.File.remove(item.filename, {ignoreAbsent: false}).catch((err) => { + return Promise.reject({message: `Could not remove download id ${item.id} because the file doesn't exist`}); + }); + }); + }, + + search(query) { + return queryHelper(query) + .then(items => items.map(item => item.serialize())); + }, + + pause(id) { + return DownloadMap.lazyInit().then(() => { + let item; + try { + item = DownloadMap.fromId(id); + } catch (err) { + return Promise.reject({message: `Invalid download id ${id}`}); + } + if (item.state != "in_progress") { + return Promise.reject({message: `Download ${id} cannot be paused since it is in state ${item.state}`}); + } + + return item.download.cancel(); + }); + }, + + resume(id) { + return DownloadMap.lazyInit().then(() => { + let item; + try { + item = DownloadMap.fromId(id); + } catch (err) { + return Promise.reject({message: `Invalid download id ${id}`}); + } + if (!item.canResume) { + return Promise.reject({message: `Download ${id} cannot be resumed`}); + } + + return item.download.start(); + }); + }, + + cancel(id) { + return DownloadMap.lazyInit().then(() => { + let item; + try { + item = DownloadMap.fromId(id); + } catch (err) { + return Promise.reject({message: `Invalid download id ${id}`}); + } + if (item.download.succeeded) { + return Promise.reject({message: `Download ${id} is already complete`}); + } + return item.download.finalize(true); + }); + }, + + showDefaultFolder() { + Downloads.getPreferredDownloadsDirectory().then(dir => { + let dirobj = new FileUtils.File(dir); + if (dirobj.isDirectory()) { + dirobj.launch(); + } else { + throw new Error(`Download directory ${dirobj.path} is not actually a directory`); + } + }).catch(Cu.reportError); + }, + + erase(query) { + return queryHelper(query).then(items => { + let results = []; + let promises = []; + for (let item of items) { + promises.push(DownloadMap.erase(item)); + results.push(item.id); + } + return Promise.all(promises).then(() => results); + }); + }, + + open(downloadId) { + return DownloadMap.lazyInit().then(() => { + let download = DownloadMap.fromId(downloadId).download; + if (download.succeeded) { + return download.launch(); + } + return Promise.reject({message: "Download has not completed."}); + }).catch((error) => { + return Promise.reject({message: error.message}); + }); + }, + + show(downloadId) { + return DownloadMap.lazyInit().then(() => { + let download = DownloadMap.fromId(downloadId); + return download.download.showContainingDirectory(); + }).then(() => { + return true; + }).catch(error => { + return Promise.reject({message: error.message}); + }); + }, + + getFileIcon(downloadId, options) { + return DownloadMap.lazyInit().then(() => { + let size = options && options.size ? options.size : 32; + let download = DownloadMap.fromId(downloadId).download; + let pathPrefix = ""; + let path; + + if (download.succeeded) { + let file = FileUtils.File(download.target.path); + path = Services.io.newFileURI(file).spec; + } else { + path = OS.Path.basename(download.target.path); + pathPrefix = "//"; + } + + return new Promise((resolve, reject) => { + let chromeWebNav = Services.appShell.createWindowlessBrowser(true); + chromeWebNav + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + .createAboutBlankContentViewer(Services.scriptSecurityManager.getSystemPrincipal()); + + let img = chromeWebNav.document.createElement("img"); + img.width = size; + img.height = size; + + let handleLoad; + let handleError; + const cleanup = () => { + img.removeEventListener("load", handleLoad); + img.removeEventListener("error", handleError); + chromeWebNav.close(); + chromeWebNav = null; + }; + + handleLoad = () => { + let canvas = chromeWebNav.document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + let context = canvas.getContext("2d"); + context.drawImage(img, 0, 0, size, size); + let dataURL = canvas.toDataURL("image/png"); + cleanup(); + resolve(dataURL); + }; + + handleError = (error) => { + Cu.reportError(error); + cleanup(); + reject(new Error("An unexpected error occurred")); + }; + + img.addEventListener("load", handleLoad); + img.addEventListener("error", handleError); + img.src = `moz-icon:${pathPrefix}${path}?size=${size}`; + }); + }).catch((error) => { + return Promise.reject({message: error.message}); + }); + }, + + // When we do setShelfEnabled(), check for additional "downloads.shelf" permission. + // i.e.: + // setShelfEnabled(enabled) { + // if (!extension.hasPermission("downloads.shelf")) { + // throw new context.cloneScope.Error("Permission denied because 'downloads.shelf' permission is missing."); + // } + // ... + // } + + onChanged: new SingletonEventManager(context, "downloads.onChanged", fire => { + const handler = (what, item) => { + let changes = {}; + const noundef = val => (val === undefined) ? null : val; + DOWNLOAD_ITEM_CHANGE_FIELDS.forEach(fld => { + if (item[fld] != item.prechange[fld]) { + changes[fld] = { + previous: noundef(item.prechange[fld]), + current: noundef(item[fld]), + }; + } + }); + if (Object.keys(changes).length > 0) { + changes.id = item.id; + fire.async(changes); + } + }; + + let registerPromise = DownloadMap.getDownloadList().then(() => { + DownloadMap.on("change", handler); + }); + return () => { + registerPromise.then(() => { + DownloadMap.off("change", handler); + }); + }; + }).api(), + + onCreated: new SingletonEventManager(context, "downloads.onCreated", fire => { + const handler = (what, item) => { + fire.async(item.serialize()); + }; + let registerPromise = DownloadMap.getDownloadList().then(() => { + DownloadMap.on("create", handler); + }); + return () => { + registerPromise.then(() => { + DownloadMap.off("create", handler); + }); + }; + }).api(), + + onErased: new SingletonEventManager(context, "downloads.onErased", fire => { + const handler = (what, item) => { + fire.async(item.id); + }; + let registerPromise = DownloadMap.getDownloadList().then(() => { + DownloadMap.on("erase", handler); + }); + return () => { + registerPromise.then(() => { + DownloadMap.off("erase", handler); + }); + }; + }).api(), + + onDeterminingFilename: ignoreEvent(context, "downloads.onDeterminingFilename"), + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-extension.js b/toolkit/components/extensions/ext-extension.js new file mode 100644 index 0000000000..7afc828d95 --- /dev/null +++ b/toolkit/components/extensions/ext-extension.js @@ -0,0 +1,22 @@ +"use strict"; + +this.extension = class extends ExtensionAPI { + getAPI(context) { + return { + extension: { + get lastError() { + return context.lastError; + }, + + isAllowedIncognitoAccess() { + return Promise.resolve(true); + }, + + isAllowedFileSchemeAccess() { + return Promise.resolve(false); + }, + }, + }; + } +}; + diff --git a/toolkit/components/extensions/ext-geolocation.js b/toolkit/components/extensions/ext-geolocation.js new file mode 100644 index 0000000000..1daf940b3c --- /dev/null +++ b/toolkit/components/extensions/ext-geolocation.js @@ -0,0 +1,31 @@ +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +// If the user has changed the permission on the addon to something other than +// always allow, then we want to preserve that choice. We only set the +// permission if it is not set (unknown_action), and we only remove the +// permission on shutdown if it is always allow. + +this.geolocation = class extends ExtensionAPI { + onStartup() { + let {extension} = this; + + if (extension.hasPermission("geolocation") && + Services.perms.testPermission(extension.principal.URI, "geo") == Services.perms.UNKNOWN_ACTION) { + Services.perms.add(extension.principal.URI, "geo", + Services.perms.ALLOW_ACTION, + Services.perms.EXPIRE_SESSION); + } + } + + onShutdown() { + let {extension} = this; + + if (extension.hasPermission("geolocation") && + Services.perms.testPermission(extension.principal.URI, "geo") == Services.perms.ALLOW_ACTION) { + Services.perms.remove(extension.principal.URI, "geo"); + } + } +}; diff --git a/toolkit/components/extensions/ext-i18n.js b/toolkit/components/extensions/ext-i18n.js new file mode 100644 index 0000000000..a1905432ab --- /dev/null +++ b/toolkit/components/extensions/ext-i18n.js @@ -0,0 +1,39 @@ +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "LanguageDetector", + "resource:///modules/translation/LanguageDetector.jsm"); + + +this.i18n = class extends ExtensionAPI { + getAPI(context) { + let {extension} = context; + return { + i18n: { + getMessage: function(messageName, substitutions) { + return extension.localizeMessage(messageName, substitutions, {cloneScope: context.cloneScope}); + }, + + getAcceptLanguages: function() { + let result = extension.localeData.acceptLanguages; + return Promise.resolve(result); + }, + + getUILanguage: function() { + return extension.localeData.uiLocale; + }, + + detectLanguage: function(text) { + return LanguageDetector.detectLanguage(text).then(result => ({ + isReliable: result.confident, + languages: result.languages.map(lang => { + return { + language: lang.languageCode, + percentage: lang.percent, + }; + }), + })); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-idle.js b/toolkit/components/extensions/ext-idle.js new file mode 100644 index 0000000000..c5ddeb7dd2 --- /dev/null +++ b/toolkit/components/extensions/ext-idle.js @@ -0,0 +1,92 @@ +"use strict"; + +// The ext-* files are imported into the same scopes. +/* import-globals-from ext-toolkit.js */ + +XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter", + "resource://gre/modules/EventEmitter.jsm"); +XPCOMUtils.defineLazyServiceGetter(this, "idleService", + "@mozilla.org/widget/idleservice;1", + "nsIIdleService"); + +// WeakMap[Extension -> Object] +let observersMap = new WeakMap(); + +const getIdleObserverInfo = (extension, context) => { + let observerInfo = observersMap.get(extension); + if (!observerInfo) { + observerInfo = { + observer: null, + detectionInterval: 60, + }; + observersMap.set(extension, observerInfo); + context.callOnClose({ + close: () => { + let {observer, detectionInterval} = observersMap.get(extension); + if (observer) { + idleService.removeIdleObserver(observer, detectionInterval); + } + observersMap.delete(extension); + }, + }); + } + return observerInfo; +}; + +const getIdleObserver = (extension, context) => { + let observerInfo = getIdleObserverInfo(extension, context); + let {observer, detectionInterval} = observerInfo; + if (!observer) { + observer = { + observe: function(subject, topic, data) { + if (topic == "idle" || topic == "active") { + this.emit("stateChanged", topic); + } + }, + }; + EventEmitter.decorate(observer); + idleService.addIdleObserver(observer, detectionInterval); + observerInfo.observer = observer; + observerInfo.detectionInterval = detectionInterval; + } + return observer; +}; + +const setDetectionInterval = (extension, context, newInterval) => { + let observerInfo = getIdleObserverInfo(extension, context); + let {observer, detectionInterval} = observerInfo; + if (observer) { + idleService.removeIdleObserver(observer, detectionInterval); + idleService.addIdleObserver(observer, newInterval); + } + observerInfo.detectionInterval = newInterval; +}; + +this.idle = class extends ExtensionAPI { + getAPI(context) { + let {extension} = context; + return { + idle: { + queryState: function(detectionIntervalInSeconds) { + if (idleService.idleTime < detectionIntervalInSeconds * 1000) { + return Promise.resolve("active"); + } + return Promise.resolve("idle"); + }, + setDetectionInterval: function(detectionIntervalInSeconds) { + setDetectionInterval(extension, context, detectionIntervalInSeconds); + }, + onStateChanged: new SingletonEventManager(context, "idle.onStateChanged", fire => { + let listener = (event, data) => { + fire.sync(data); + }; + + getIdleObserver(extension, context).on("stateChanged", listener); + return () => { + getIdleObserver(extension, context).off("stateChanged", listener); + }; + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-management.js b/toolkit/components/extensions/ext-management.js new file mode 100644 index 0000000000..f061988507 --- /dev/null +++ b/toolkit/components/extensions/ext-management.js @@ -0,0 +1,260 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// The ext-* files are imported into the same scopes. +/* import-globals-from ext-toolkit.js */ + +XPCOMUtils.defineLazyGetter(this, "strBundle", function() { + const stringSvc = Cc["@mozilla.org/intl/stringbundle;1"].getService(Ci.nsIStringBundleService); + return stringSvc.createBundle("chrome://global/locale/extensions.properties"); +}); +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyServiceGetter(this, "promptService", + "@mozilla.org/embedcomp/prompt-service;1", + "nsIPromptService"); +XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter", + "resource://devtools/shared/event-emitter.js"); + +XPCOMUtils.defineLazyGetter(this, "GlobalManager", () => { + const {GlobalManager} = Cu.import("resource://gre/modules/Extension.jsm", {}); + return GlobalManager; +}); + +var { + ExtensionError, +} = ExtensionUtils; + +const _ = (key, ...args) => { + if (args.length) { + return strBundle.formatStringFromName(key, args, args.length); + } + return strBundle.GetStringFromName(key); +}; + +const installType = addon => { + if (addon.temporarilyInstalled) { + return "development"; + } else if (addon.foreignInstall) { + return "sideload"; + } else if (addon.isSystem) { + return "other"; + } + return "normal"; +}; + +const getExtensionInfoForAddon = (extension, addon) => { + let extInfo = { + id: addon.id, + name: addon.name, + description: addon.description || "", + version: addon.version, + mayDisable: !!(addon.permissions & AddonManager.PERM_CAN_DISABLE), + enabled: addon.isActive, + optionsUrl: addon.optionsURL || "", + installType: installType(addon), + type: addon.type, + }; + + if (extension) { + let m = extension.manifest; + + let hostPerms = extension.whiteListedHosts.patterns.map(matcher => matcher.pattern); + + extInfo.permissions = Array.from(extension.permissions).filter(perm => { + return !hostPerms.includes(perm); + }); + extInfo.hostPermissions = hostPerms; + + extInfo.shortName = m.short_name || ""; + if (m.icons) { + extInfo.icons = Object.keys(m.icons).map(key => { + return {size: Number(key), url: m.icons[key]}; + }); + } + } + + if (!addon.isActive) { + extInfo.disabledReason = "unknown"; + } + if (addon.homepageURL) { + extInfo.homepageUrl = addon.homepageURL; + } + if (addon.updateURL) { + extInfo.updateUrl = addon.updateURL; + } + return extInfo; +}; + +const listenerMap = new WeakMap(); +// Some management APIs are intentionally limited. +const allowedTypes = ["theme"]; + +class AddonListener { + constructor() { + AddonManager.addAddonListener(this); + EventEmitter.decorate(this); + } + + release() { + AddonManager.removeAddonListener(this); + } + + getExtensionInfo(addon) { + let ext = addon.isWebExtension && GlobalManager.extensionMap.get(addon.id); + return getExtensionInfoForAddon(ext, addon); + } + + onEnabled(addon) { + if (!allowedTypes.includes(addon.type)) { + return; + } + this.emit("onEnabled", this.getExtensionInfo(addon)); + } + + onDisabled(addon) { + if (!allowedTypes.includes(addon.type)) { + return; + } + this.emit("onDisabled", this.getExtensionInfo(addon)); + } + + onInstalled(addon) { + if (!allowedTypes.includes(addon.type)) { + return; + } + this.emit("onInstalled", this.getExtensionInfo(addon)); + } + + onUninstalled(addon) { + if (!allowedTypes.includes(addon.type)) { + return; + } + this.emit("onUninstalled", this.getExtensionInfo(addon)); + } +} + +let addonListener; + +const getManagementListener = (extension, context) => { + if (!listenerMap.has(extension)) { + if (!addonListener) { + addonListener = new AddonListener(); + } + listenerMap.set(extension, {}); + context.callOnClose({ + close: () => { + listenerMap.delete(extension); + if (listenerMap.length === 0) { + addonListener.release(); + addonListener = null; + } + }, + }); + } + return addonListener; +}; + +this.management = class extends ExtensionAPI { + getAPI(context) { + let {extension} = context; + return { + management: { + async getAll() { + let addons = await AddonManager.getAddonsByTypes(allowedTypes); + return addons.map(addon => { + // If the extension is enabled get it and use it for more data. + let ext = addon.isWebExtension && GlobalManager.extensionMap.get(addon.id); + return getExtensionInfoForAddon(ext, addon); + }); + }, + + async getSelf() { + let addon = await AddonManager.getAddonByID(extension.id); + return getExtensionInfoForAddon(extension, addon); + }, + + async uninstallSelf(options) { + if (options && options.showConfirmDialog) { + let message = _("uninstall.confirmation.message", extension.name); + if (options.dialogMessage) { + message = `${options.dialogMessage}\n${message}`; + } + let title = _("uninstall.confirmation.title", extension.name); + let buttonFlags = promptService.BUTTON_POS_0 * promptService.BUTTON_TITLE_IS_STRING + + promptService.BUTTON_POS_1 * promptService.BUTTON_TITLE_IS_STRING; + let button0Title = _("uninstall.confirmation.button-0.label"); + let button1Title = _("uninstall.confirmation.button-1.label"); + let response = promptService.confirmEx(null, title, message, buttonFlags, button0Title, button1Title, null, null, {value: 0}); + if (response == 1) { + throw new ExtensionError("User cancelled uninstall of extension"); + } + } + let addon = await AddonManager.getAddonByID(extension.id); + let canUninstall = Boolean(addon.permissions & AddonManager.PERM_CAN_UNINSTALL); + if (!canUninstall) { + throw new ExtensionError("The add-on cannot be uninstalled"); + } + addon.uninstall(); + }, + + async setEnabled(id, enabled) { + let addon = await AddonManager.getAddonByID(id); + if (!addon) { + throw new ExtensionError(`No such addon ${id}`); + } + if (!allowedTypes.includes(addon.type)) { + throw new ExtensionError("setEnabled applies only to theme addons"); + } + addon.userDisabled = !enabled; + }, + + onDisabled: new SingletonEventManager(context, "management.onDisabled", fire => { + let listener = (event, data) => { + fire.async(data); + }; + + getManagementListener(extension, context).on("onDisabled", listener); + return () => { + getManagementListener(extension, context).off("onDisabled", listener); + }; + }).api(), + + onEnabled: new SingletonEventManager(context, "management.onEnabled", fire => { + let listener = (event, data) => { + fire.async(data); + }; + + getManagementListener(extension, context).on("onEnabled", listener); + return () => { + getManagementListener(extension, context).off("onEnabled", listener); + }; + }).api(), + + onInstalled: new SingletonEventManager(context, "management.onInstalled", fire => { + let listener = (event, data) => { + fire.async(data); + }; + + getManagementListener(extension, context).on("onInstalled", listener); + return () => { + getManagementListener(extension, context).off("onInstalled", listener); + }; + }).api(), + + onUninstalled: new SingletonEventManager(context, "management.onUninstalled", fire => { + let listener = (event, data) => { + fire.async(data); + }; + + getManagementListener(extension, context).on("onUninstalled", listener); + return () => { + getManagementListener(extension, context).off("onUninstalled", listener); + }; + }).api(), + + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-notifications.js b/toolkit/components/extensions/ext-notifications.js new file mode 100644 index 0000000000..b4032f5313 --- /dev/null +++ b/toolkit/components/extensions/ext-notifications.js @@ -0,0 +1,164 @@ +"use strict"; + +// The ext-* files are imported into the same scopes. +/* import-globals-from ext-toolkit.js */ + +XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter", + "resource://gre/modules/EventEmitter.jsm"); + +var { + ignoreEvent, +} = ExtensionCommon; + +// WeakMap[Extension -> Map[id -> Notification]] +let notificationsMap = new WeakMap(); + +// Manages a notification popup (notifications API) created by the extension. +function Notification(extension, id, options) { + this.extension = extension; + this.id = id; + this.options = options; + + let imageURL; + if (options.iconUrl) { + imageURL = this.extension.baseURI.resolve(options.iconUrl); + } + + try { + let svc = Cc["@mozilla.org/alerts-service;1"].getService(Ci.nsIAlertsService); + svc.showAlertNotification(imageURL, + options.title, + options.message, + true, // textClickable + this.id, + this, + this.id); + } catch (e) { + // This will fail if alerts aren't available on the system. + } +} + +Notification.prototype = { + clear() { + try { + let svc = Cc["@mozilla.org/alerts-service;1"].getService(Ci.nsIAlertsService); + svc.closeAlert(this.id); + } catch (e) { + // This will fail if the OS doesn't support this function. + } + notificationsMap.get(this.extension).delete(this.id); + }, + + observe(subject, topic, data) { + let notifications = notificationsMap.get(this.extension); + + let emitAndDelete = event => { + notifications.emit(event, data); + notifications.delete(this.id); + }; + + // Don't try to emit events if the extension has been unloaded + if (!notifications) { + return; + } + + if (topic === "alertclickcallback") { + emitAndDelete("clicked"); + } + if (topic === "alertfinished") { + emitAndDelete("closed"); + } + }, +}; + +this.notifications = class extends ExtensionAPI { + constructor(extension) { + super(extension); + + this.nextId = 0; + } + + onShutdown() { + let {extension} = this; + + if (notificationsMap.has(extension)) { + for (let notification of notificationsMap.get(extension).values()) { + notification.clear(); + } + notificationsMap.delete(extension); + } + } + + getAPI(context) { + let {extension} = context; + + let map = new Map(); + EventEmitter.decorate(map); + notificationsMap.set(extension, map); + + return { + notifications: { + create: (notificationId, options) => { + if (!notificationId) { + notificationId = String(this.nextId++); + } + + let notifications = notificationsMap.get(extension); + if (notifications.has(notificationId)) { + notifications.get(notificationId).clear(); + } + + // FIXME: Lots of options still aren't supported, especially + // buttons. + let notification = new Notification(extension, notificationId, options); + notificationsMap.get(extension).set(notificationId, notification); + + return Promise.resolve(notificationId); + }, + + clear: function(notificationId) { + let notifications = notificationsMap.get(extension); + if (notifications.has(notificationId)) { + notifications.get(notificationId).clear(); + return Promise.resolve(true); + } + return Promise.resolve(false); + }, + + getAll: function() { + let result = {}; + notificationsMap.get(extension).forEach((value, key) => { + result[key] = value.options; + }); + return Promise.resolve(result); + }, + + onClosed: new SingletonEventManager(context, "notifications.onClosed", fire => { + let listener = (event, notificationId) => { + // FIXME: Support the byUser argument. + fire.async(notificationId, true); + }; + + notificationsMap.get(extension).on("closed", listener); + return () => { + notificationsMap.get(extension).off("closed", listener); + }; + }).api(), + + onClicked: new SingletonEventManager(context, "notifications.onClicked", fire => { + let listener = (event, notificationId) => { + fire.async(notificationId, true); + }; + + notificationsMap.get(extension).on("clicked", listener); + return () => { + notificationsMap.get(extension).off("clicked", listener); + }; + }).api(), + + // Intend to implement this later: https://bugzilla.mozilla.org/show_bug.cgi?id=1190681 + onButtonClicked: ignoreEvent(context, "notifications.onButtonClicked"), + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-permissions.js b/toolkit/components/extensions/ext-permissions.js new file mode 100644 index 0000000000..21dc3a696b --- /dev/null +++ b/toolkit/components/extensions/ext-permissions.js @@ -0,0 +1,94 @@ +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionPermissions", + "resource://gre/modules/ExtensionPermissions.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +var { + ExtensionError, +} = ExtensionUtils; + +XPCOMUtils.defineLazyPreferenceGetter(this, "promptsEnabled", + "extensions.webextOptionalPermissionPrompts"); + +this.permissions = class extends ExtensionAPI { + getAPI(context) { + return { + permissions: { + async request_parent(perms) { + let {permissions, origins} = perms; + + let manifestPermissions = context.extension.manifest.optional_permissions; + for (let perm of permissions) { + if (!manifestPermissions.includes(perm)) { + throw new ExtensionError(`Cannot request permission ${perm} since it was not declared in optional_permissions`); + } + } + + let optionalOrigins = context.extension.optionalOrigins; + for (let origin of origins) { + if (!optionalOrigins.subsumes(new MatchPattern(origin))) { + throw new ExtensionError(`Cannot request origin permission for ${origin} since it was not declared in optional_permissions`); + } + } + + if (promptsEnabled) { + let allow = await new Promise(resolve => { + let subject = { + wrappedJSObject: { + browser: context.xulBrowser, + name: context.extension.name, + icon: context.extension.iconURL, + permissions: {permissions, origins}, + resolve, + }, + }; + Services.obs.notifyObservers(subject, "webextension-optional-permission-prompt"); + }); + if (!allow) { + return false; + } + } + + await ExtensionPermissions.add(context.extension, perms); + return true; + }, + + async getAll() { + let perms = context.extension.userPermissions; + delete perms.apis; + return perms; + }, + + async contains(permissions) { + for (let perm of permissions.permissions) { + if (!context.extension.hasPermission(perm)) { + return false; + } + } + + for (let origin of permissions.origins) { + if (!context.extension.whiteListedHosts.subsumes(new MatchPattern(origin))) { + return false; + } + } + + return true; + }, + + async remove(permissions) { + await ExtensionPermissions.remove(context.extension, permissions); + return true; + }, + }, + }; + } +}; + +/* eslint-disable mozilla/balanced-listeners */ +extensions.on("uninstall", extension => { + ExtensionPermissions.removeAll(extension); +}); diff --git a/toolkit/components/extensions/ext-privacy.js b/toolkit/components/extensions/ext-privacy.js new file mode 100644 index 0000000000..b26b282b17 --- /dev/null +++ b/toolkit/components/extensions/ext-privacy.js @@ -0,0 +1,166 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "Preferences", + "resource://gre/modules/Preferences.jsm"); + +Cu.import("resource://gre/modules/ExtensionPreferencesManager.jsm"); +var { + ExtensionError, +} = ExtensionUtils; + +const checkScope = scope => { + if (scope && scope !== "regular") { + throw new ExtensionError( + `Firefox does not support the ${scope} settings scope.`); + } +}; + +const getPrivacyAPI = (extension, name, callback) => { + return { + async get(details) { + return { + levelOfControl: details.incognito ? + "not_controllable" : + await ExtensionPreferencesManager.getLevelOfControl( + extension, name), + value: await callback(), + }; + }, + async set(details) { + checkScope(details.scope); + return await ExtensionPreferencesManager.setSetting( + extension, name, details.value); + }, + async clear(details) { + checkScope(details.scope); + return await ExtensionPreferencesManager.removeSetting( + extension, name); + }, + }; +}; + +// Add settings objects for supported APIs to the preferences manager. +ExtensionPreferencesManager.addSetting("network.networkPredictionEnabled", { + prefNames: [ + "network.predictor.enabled", + "network.prefetch-next", + "network.http.speculative-parallel-limit", + "network.dns.disablePrefetch", + ], + + setCallback(value) { + return { + "network.http.speculative-parallel-limit": value ? undefined : 0, + "network.dns.disablePrefetch": !value, + "network.predictor.enabled": value, + "network.prefetch-next": value, + }; + }, +}); + +ExtensionPreferencesManager.addSetting("network.peerConnectionEnabled", { + prefNames: [ + "media.peerconnection.enabled", + ], + + setCallback(value) { + return {[this.prefNames[0]]: value}; + }, +}); + +ExtensionPreferencesManager.addSetting("network.webRTCIPHandlingPolicy", { + prefNames: [ + "media.peerconnection.ice.default_address_only", + "media.peerconnection.ice.no_host", + "media.peerconnection.ice.proxy_only", + ], + + setCallback(value) { + let prefs = {}; + // Start with all prefs being reset. + for (let pref of this.prefNames) { + prefs[pref] = undefined; + } + switch (value) { + case "default": + // All prefs are already set to be reset. + break; + + case "default_public_and_private_interfaces": + prefs["media.peerconnection.ice.default_address_only"] = true; + break; + + case "default_public_interface_only": + prefs["media.peerconnection.ice.default_address_only"] = true; + prefs["media.peerconnection.ice.no_host"] = true; + break; + + case "disable_non_proxied_udp": + prefs["media.peerconnection.ice.proxy_only"] = true; + break; + } + return prefs; + }, +}); + +ExtensionPreferencesManager.addSetting("websites.hyperlinkAuditingEnabled", { + prefNames: [ + "browser.send_pings", + ], + + setCallback(value) { + return {[this.prefNames[0]]: value}; + }, +}); + +this.privacy = class extends ExtensionAPI { + getAPI(context) { + let {extension} = context; + return { + privacy: { + network: { + networkPredictionEnabled: getPrivacyAPI(extension, + "network.networkPredictionEnabled", + () => { + return Preferences.get("network.predictor.enabled") && + Preferences.get("network.prefetch-next") && + Preferences.get("network.http.speculative-parallel-limit") > 0 && + !Preferences.get("network.dns.disablePrefetch"); + }), + peerConnectionEnabled: getPrivacyAPI(extension, + "network.peerConnectionEnabled", + () => { + return Preferences.get("media.peerconnection.enabled"); + }), + webRTCIPHandlingPolicy: getPrivacyAPI(extension, + "network.webRTCIPHandlingPolicy", + () => { + if (Preferences.get("media.peerconnection.ice.proxy_only")) { + return "disable_non_proxied_udp"; + } + + let default_address_only = + Preferences.get("media.peerconnection.ice.default_address_only"); + if (default_address_only) { + if (Preferences.get("media.peerconnection.ice.no_host")) { + return "default_public_interface_only"; + } + return "default_public_and_private_interfaces"; + } + + return "default"; + }), + }, + websites: { + hyperlinkAuditingEnabled: getPrivacyAPI(extension, + "websites.hyperlinkAuditingEnabled", + () => { + return Preferences.get("browser.send_pings"); + }), + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-protocolHandlers.js b/toolkit/components/extensions/ext-protocolHandlers.js new file mode 100644 index 0000000000..c19d4f8ea7 --- /dev/null +++ b/toolkit/components/extensions/ext-protocolHandlers.js @@ -0,0 +1,73 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +XPCOMUtils.defineLazyServiceGetter(this, "handlerService", + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService"); +XPCOMUtils.defineLazyServiceGetter(this, "protocolService", + "@mozilla.org/uriloader/external-protocol-service;1", + "nsIExternalProtocolService"); +Cu.importGlobalProperties(["URL"]); + +const hasHandlerApp = handlerConfig => { + let protoInfo = protocolService.getProtocolHandlerInfo(handlerConfig.protocol); + let appHandlers = protoInfo.possibleApplicationHandlers; + for (let i = 0; i < appHandlers.length; i++) { + let handler = appHandlers.queryElementAt(i, Ci.nsISupports); + if (handler instanceof Ci.nsIWebHandlerApp && + handler.uriTemplate === handlerConfig.uriTemplate) { + return true; + } + } + return false; +}; + +this.protocolHandlers = class extends ExtensionAPI { + onManifestEntry(entryName) { + let {extension} = this; + let {manifest} = extension; + + for (let handlerConfig of manifest.protocol_handlers) { + if (hasHandlerApp(handlerConfig)) { + continue; + } + + let handler = Cc["@mozilla.org/uriloader/web-handler-app;1"] + .createInstance(Ci.nsIWebHandlerApp); + handler.name = handlerConfig.name; + handler.uriTemplate = handlerConfig.uriTemplate; + + let protoInfo = protocolService.getProtocolHandlerInfo(handlerConfig.protocol); + protoInfo.possibleApplicationHandlers.appendElement(handler); + handlerService.store(protoInfo); + } + } + + onShutdown(shutdownReason) { + let {extension} = this; + let {manifest} = extension; + + if (shutdownReason === "APP_SHUTDOWN") { + return; + } + + for (let handlerConfig of manifest.protocol_handlers) { + let protoInfo = protocolService.getProtocolHandlerInfo(handlerConfig.protocol); + let appHandlers = protoInfo.possibleApplicationHandlers; + for (let i = 0; i < appHandlers.length; i++) { + let handler = appHandlers.queryElementAt(i, Ci.nsISupports); + if (handler instanceof Ci.nsIWebHandlerApp && + handler.uriTemplate === handlerConfig.uriTemplate) { + appHandlers.removeElementAt(i); + if (protoInfo.preferredApplicationHandler === handler) { + protoInfo.preferredApplicationHandler = null; + protoInfo.alwaysAskBeforeHandling = true; + } + handlerService.store(protoInfo); + break; + } + } + } + } +}; diff --git a/toolkit/components/extensions/ext-proxy.js b/toolkit/components/extensions/ext-proxy.js new file mode 100644 index 0000000000..ca46502491 --- /dev/null +++ b/toolkit/components/extensions/ext-proxy.js @@ -0,0 +1,59 @@ +/* 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/. */ + +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +// The ext-* files are imported into the same scopes. +/* import-globals-from ext-toolkit.js */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "ProxyScriptContext", + "resource://gre/modules/ProxyScriptContext.jsm"); + +// WeakMap[Extension -> ProxyScriptContext] +let proxyScriptContextMap = new WeakMap(); + +this.proxy = class extends ExtensionAPI { + onShutdown() { + let {extension} = this; + + let proxyScriptContext = proxyScriptContextMap.get(extension); + if (proxyScriptContext) { + proxyScriptContext.unload(); + proxyScriptContextMap.delete(extension); + } + } + + getAPI(context) { + let {extension} = context; + return { + proxy: { + registerProxyScript: (url) => { + // Unload the current proxy script if one is loaded. + if (proxyScriptContextMap.has(extension)) { + proxyScriptContextMap.get(extension).unload(); + proxyScriptContextMap.delete(extension); + } + + let proxyScriptContext = new ProxyScriptContext(extension, url); + if (proxyScriptContext.load()) { + proxyScriptContextMap.set(extension, proxyScriptContext); + } + }, + + onProxyError: new SingletonEventManager(context, "proxy.onProxyError", fire => { + let listener = (name, error) => { + fire.async(error); + }; + extension.on("proxy-error", listener); + return () => { + extension.off("proxy-error", listener); + }; + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-runtime.js b/toolkit/components/extensions/ext-runtime.js new file mode 100644 index 0000000000..2e42214fe8 --- /dev/null +++ b/toolkit/components/extensions/ext-runtime.js @@ -0,0 +1,145 @@ +"use strict"; + +// The ext-* files are imported into the same scopes. +/* import-globals-from ext-toolkit.js */ + +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AddonManagerPrivate", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Extension", + "resource://gre/modules/Extension.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionParent", + "resource://gre/modules/ExtensionParent.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); + +this.runtime = class extends ExtensionAPI { + getAPI(context) { + let {extension} = context; + return { + runtime: { + onStartup: new SingletonEventManager(context, "runtime.onStartup", fire => { + if (context.incognito) { + // This event should not fire if we are operating in a private profile. + return () => {}; + } + let listener = () => { + if (extension.startupReason === "APP_STARTUP") { + fire.sync(); + } + }; + extension.on("startup", listener); + return () => { + extension.off("startup", listener); + }; + }).api(), + + onInstalled: new SingletonEventManager(context, "runtime.onInstalled", fire => { + let temporary = !!extension.addonData.temporarilyInstalled; + + let listener = () => { + switch (extension.startupReason) { + case "APP_STARTUP": + if (AddonManagerPrivate.browserUpdated) { + fire.sync({reason: "browser_update", temporary}); + } + break; + case "ADDON_INSTALL": + fire.sync({reason: "install", temporary}); + break; + case "ADDON_UPGRADE": + fire.sync({ + reason: "update", + previousVersion: extension.addonData.oldVersion, + temporary, + }); + break; + } + }; + extension.on("startup", listener); + return () => { + extension.off("startup", listener); + }; + }).api(), + + onUpdateAvailable: new SingletonEventManager(context, "runtime.onUpdateAvailable", fire => { + let instanceID = extension.addonData.instanceID; + AddonManager.addUpgradeListener(instanceID, upgrade => { + extension.upgrade = upgrade; + let details = { + version: upgrade.version, + }; + fire.sync(details); + }); + return () => { + AddonManager.removeUpgradeListener(instanceID).catch(e => { + // This can happen if we try this after shutdown is complete. + }); + }; + }).api(), + + reload: () => { + if (extension.upgrade) { + // If there is a pending update, install it now. + extension.upgrade.install(); + } else { + // Otherwise, reload the current extension. + AddonManager.getAddonByID(extension.id, addon => { + addon.reload(); + }); + } + }, + + get lastError() { + // TODO(robwu): Figure out how to make sure that errors in the parent + // process are propagated to the child process. + // lastError should not be accessed from the parent. + return context.lastError; + }, + + getBrowserInfo: function() { + const {name, vendor, appBuildID} = Services.appinfo; + // Spoof Firefox 128 exclusively for WebExtensions + const info = {name, vendor, version: "128.0", buildID: appBuildID}; + return Promise.resolve(info); + }, + + getPlatformInfo: function() { + return Promise.resolve(ExtensionParent.PlatformInfo); + }, + + openOptionsPage: function() { + if (!extension.manifest.options_ui) { + return Promise.reject({message: "No `options_ui` declared"}); + } + + // This expects openOptionsPage to be defined in the file using this, + // e.g. the browser/ version of ext-runtime.js + /* global openOptionsPage:false */ + return openOptionsPage(extension).then(() => {}); + }, + + setUninstallURL: function(url) { + if (url.length == 0) { + return Promise.resolve(); + } + + let uri; + try { + uri = NetUtil.newURI(url); + } catch (e) { + return Promise.reject({message: `Invalid URL: ${JSON.stringify(url)}`}); + } + + if (uri.scheme != "http" && uri.scheme != "https") { + return Promise.reject({message: "url must have the scheme http or https"}); + } + + extension.uninstallURL = url; + return Promise.resolve(); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-storage.js b/toolkit/components/extensions/ext-storage.js new file mode 100644 index 0000000000..8dbed03b70 --- /dev/null +++ b/toolkit/components/extensions/ext-storage.js @@ -0,0 +1,84 @@ +"use strict"; + +// The ext-* files are imported into the same scopes. +/* import-globals-from ext-toolkit.js */ + +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorage", + "resource://gre/modules/ExtensionStorage.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "extensionStorageSync", + "resource://gre/modules/ExtensionStorageSync.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AddonManagerPrivate", + "resource://gre/modules/AddonManager.jsm"); + +var { + ExtensionError, +} = ExtensionUtils; + +const enforceNoTemporaryAddon = extensionId => { + const EXCEPTION_MESSAGE = + "The storage API will not work with a temporary addon ID. " + + "Please add an explicit addon ID to your manifest. " + + "For more information see https://bugzil.la/1323228."; + if (AddonManagerPrivate.isTemporaryInstallID(extensionId)) { + throw new ExtensionError(EXCEPTION_MESSAGE); + } +}; + +this.storage = class extends ExtensionAPI { + getAPI(context) { + let {extension} = context; + return { + storage: { + local: { + get: function(spec) { + return ExtensionStorage.get(extension.id, spec); + }, + set: function(items) { + return ExtensionStorage.set(extension.id, items); + }, + remove: function(keys) { + return ExtensionStorage.remove(extension.id, keys); + }, + clear: function() { + return ExtensionStorage.clear(extension.id); + }, + }, + + sync: { + get: function(spec) { + enforceNoTemporaryAddon(extension.id); + return extensionStorageSync.get(extension, spec, context); + }, + set: function(items) { + enforceNoTemporaryAddon(extension.id); + return extensionStorageSync.set(extension, items, context); + }, + remove: function(keys) { + enforceNoTemporaryAddon(extension.id); + return extensionStorageSync.remove(extension, keys, context); + }, + clear: function() { + enforceNoTemporaryAddon(extension.id); + return extensionStorageSync.clear(extension, context); + }, + }, + + onChanged: new SingletonEventManager(context, "storage.onChanged", fire => { + let listenerLocal = changes => { + fire.async(changes, "local"); + }; + let listenerSync = changes => { + fire.async(changes, "sync"); + }; + + ExtensionStorage.addOnChangedListener(extension.id, listenerLocal); + extensionStorageSync.addOnChangedListener(extension, listenerSync, context); + return () => { + ExtensionStorage.removeOnChangedListener(extension.id, listenerLocal); + extensionStorageSync.removeOnChangedListener(extension, listenerSync); + }; + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-theme.js b/toolkit/components/extensions/ext-theme.js new file mode 100644 index 0000000000..798e2bd403 --- /dev/null +++ b/toolkit/components/extensions/ext-theme.js @@ -0,0 +1,287 @@ +"use strict"; + +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Preferences", + "resource://gre/modules/Preferences.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager", + "resource://gre/modules/LightweightThemeManager.jsm"); + +XPCOMUtils.defineLazyGetter(this, "gThemesEnabled", () => { + return Preferences.get("extensions.webextensions.themes.enabled"); +}); + +const ICONS = Preferences.get("extensions.webextensions.themes.icons.buttons", "").split(","); + +/** Class representing a theme. */ +class Theme { + /** + * Creates a theme instance. + * + * @param {string} baseURI The base URI of the extension, used to + * resolve relative filepaths. + * @param {Object} logger Reference to the (console) logger that will be used + * to show manifest warnings to the theme author. + */ + constructor(baseURI, logger) { + // A dictionary of light weight theme styles. + this.lwtStyles = { + icons: {}, + }; + this.baseURI = baseURI; + this.logger = logger; + } + + /** + * Loads a theme by reading the properties from the extension's manifest. + * This method will override any currently applied theme. + * + * @param {Object} details Theme part of the manifest. Supported + * properties can be found in the schema under ThemeType. + */ + load(details) { + if (details.colors) { + this.loadColors(details.colors); + } + + if (details.images) { + this.loadImages(details.images); + } + + if (details.icons) { + this.loadIcons(details.icons); + } + + if (details.properties) { + this.loadProperties(details.properties); + } + + // Lightweight themes require all properties to be defined. + if (this.lwtStyles.headerURL && + this.lwtStyles.accentcolor && + this.lwtStyles.textcolor) { + LightweightThemeManager.fallbackThemeData = this.lwtStyles; + Services.obs.notifyObservers(null, + "lightweight-theme-styling-update", + JSON.stringify(this.lwtStyles)); + } + } + + /** + * Helper method for loading colors found in the extension's manifest. + * + * @param {Object} colors Dictionary mapping color properties to values. + */ + loadColors(colors) { + for (let color of Object.keys(colors)) { + let val = colors[color]; + + if (!val) { + continue; + } + + let cssColor = val; + if (Array.isArray(val)) { + cssColor = "rgb" + (val.length > 3 ? "a" : "") + "(" + val.join(",") + ")"; + } + + switch (color) { + case "accentcolor": + case "frame": + this.lwtStyles.accentcolor = cssColor; + break; + case "textcolor": + case "tab_text": + this.lwtStyles.textcolor = cssColor; + break; + } + } + } + + /** + * Helper method for loading images found in the extension's manifest. + * + * @param {Object} images Dictionary mapping image properties to values. + */ + loadImages(images) { + for (let image of Object.keys(images)) { + let val = images[image]; + + if (!val) { + continue; + } + + switch (image) { + case "additional_backgrounds": { + let backgroundImages = val.map(img => this.baseURI.resolve(img)); + this.lwtStyles.additionalBackgrounds = backgroundImages; + break; + } + case "headerURL": + case "theme_frame": { + let resolvedURL = this.baseURI.resolve(val); + this.lwtStyles.headerURL = resolvedURL; + break; + } + } + } + } + + /** + * Helper method for loading icons found in the extension's manifest. + * + * @param {Object} icons Dictionary mapping icon properties to extension URLs. + */ + loadIcons(icons) { + if (!Preferences.get("extensions.webextensions.themes.icons.enabled")) { + // Return early if icons are disabled. + return; + } + + for (let icon of Object.getOwnPropertyNames(icons)) { + let val = icons[icon]; + if (!val || !ICONS.includes(icon)) { + continue; + } + let variableName = `--${icon}-icon`; + let resolvedURL = this.baseURI.resolve(val); + this.lwtStyles.icons[variableName] = resolvedURL; + } + } + + /** + * Helper method for preparing properties found in the extension's manifest. + * Properties are commonly used to specify more advanced behavior of colors, + * images or icons. + * + * @param {Object} properties Dictionary mapping properties to values. + */ + loadProperties(properties) { + let additionalBackgroundsCount = (this.lwtStyles.additionalBackgrounds && + this.lwtStyles.additionalBackgrounds.length) || 0; + const assertValidAdditionalBackgrounds = (property, valueCount) => { + if (!additionalBackgroundsCount) { + this.logger.warn(`The '${property}' property takes effect only when one ` + + `or more additional background images are specified using the 'additional_backgrounds' property.`); + return false; + } + if (additionalBackgroundsCount !== valueCount) { + this.logger.warn(`The amount of values specified for '${property}' ` + + `(${valueCount}) is not equal to the amount of additional background ` + + `images (${additionalBackgroundsCount}), which may lead to unexpected results.`); + } + return true; + }; + + for (let property of Object.getOwnPropertyNames(properties)) { + let val = properties[property]; + + if (!val) { + continue; + } + + switch (property) { + case "additional_backgrounds_alignment": { + if (!assertValidAdditionalBackgrounds(property, val.length)) { + break; + } + + let alignment = []; + if (this.lwtStyles.headerURL) { + alignment.push("right top"); + } + this.lwtStyles.backgroundsAlignment = alignment.concat(val).join(","); + break; + } + case "additional_backgrounds_tiling": { + if (!assertValidAdditionalBackgrounds(property, val.length)) { + break; + } + + let tiling = []; + if (this.lwtStyles.headerURL) { + tiling.push("no-repeat"); + } + for (let i = 0, l = this.lwtStyles.additionalBackgrounds.length; i < l; ++i) { + tiling.push(val[i] || "no-repeat"); + } + this.lwtStyles.backgroundsTiling = tiling.join(","); + break; + } + } + } + } + + /** + * Unloads the currently applied theme. + */ + unload() { + let lwtStyles = { + headerURL: "", + accentcolor: "", + additionalBackgrounds: "", + backgroundsAlignment: "", + backgroundsTiling: "", + textcolor: "", + icons: {}, + }; + + for (let icon of ICONS) { + lwtStyles.icons[`--${icon}--icon`] = ""; + } + LightweightThemeManager.fallbackThemeData = null; + Services.obs.notifyObservers(null, + "lightweight-theme-styling-update", + JSON.stringify(lwtStyles)); + } +} + +this.theme = class extends ExtensionAPI { + onManifestEntry(entryName) { + if (!gThemesEnabled) { + // Return early if themes are disabled. + return; + } + + let {extension} = this; + let {manifest} = extension; + + if (!gThemesEnabled) { + // Return early if themes are disabled. + return; + } + + this.theme = new Theme(extension.baseURI, extension.logger); + this.theme.load(manifest.theme); + } + + onShutdown() { + if (this.theme) { + this.theme.unload(); + } + } + + getAPI(context) { + let {extension} = context; + + return { + theme: { + update: (details) => { + if (!gThemesEnabled) { + // Return early if themes are disabled. + return; + } + + if (!this.theme) { + // WebExtensions using the Theme API will not have a theme defined + // in the manifest. Therefore, we need to initialize the theme the + // first time browser.theme.update is called. + this.theme = new Theme(extension.baseURI, extension.logger); + } + + this.theme.load(details); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-toolkit.js b/toolkit/components/extensions/ext-toolkit.js new file mode 100644 index 0000000000..c47a3fdc7d --- /dev/null +++ b/toolkit/components/extensions/ext-toolkit.js @@ -0,0 +1,253 @@ +"use strict"; + +// These are defined on "global" which is used for the same scopes as the other +// ext-*.js files. +/* exported getCookieStoreIdForTab, getCookieStoreIdForContainer, + getContainerForCookieStoreId, + isValidCookieStoreId, isContainerCookieStoreId, + SingletonEventManager */ +/* global getCookieStoreIdForTab:false, getCookieStoreIdForContainer:false, + getContainerForCookieStoreId: false, + isValidCookieStoreId:false, isContainerCookieStoreId:false, + isDefaultCookieStoreId: false, isPrivateCookieStoreId:false, + SingletonEventManager: false */ + +XPCOMUtils.defineLazyModuleGetter(this, "ContextualIdentityService", + "resource://gre/modules/ContextualIdentityService.jsm"); + +Cu.import("resource://gre/modules/ExtensionCommon.jsm"); + +global.SingletonEventManager = ExtensionCommon.SingletonEventManager; + +/* globals DEFAULT_STORE, PRIVATE_STORE, CONTAINER_STORE */ + +global.DEFAULT_STORE = "firefox-default"; +global.PRIVATE_STORE = "firefox-private"; +global.CONTAINER_STORE = "firefox-container-"; + +global.getCookieStoreIdForTab = function(data, tab) { + if (data.incognito) { + return PRIVATE_STORE; + } + + if (tab.userContextId) { + return getCookieStoreIdForContainer(tab.userContextId); + } + + return DEFAULT_STORE; +}; + +global.isPrivateCookieStoreId = function(storeId) { + return storeId == PRIVATE_STORE; +}; + +global.isDefaultCookieStoreId = function(storeId) { + return storeId == DEFAULT_STORE; +}; + +global.isContainerCookieStoreId = function(storeId) { + return storeId !== null && storeId.startsWith(CONTAINER_STORE); +}; + +global.getCookieStoreIdForContainer = function(containerId) { + return CONTAINER_STORE + containerId; +}; + +global.getContainerForCookieStoreId = function(storeId) { + if (!isContainerCookieStoreId(storeId)) { + return null; + } + + let containerId = storeId.substring(CONTAINER_STORE.length); + if (ContextualIdentityService.getPublicIdentityFromId(containerId)) { + return parseInt(containerId, 10); + } + + return null; +}; + +global.isValidCookieStoreId = function(storeId) { + return isDefaultCookieStoreId(storeId) || + isPrivateCookieStoreId(storeId) || + isContainerCookieStoreId(storeId); +}; + +extensions.registerModules({ + manifest: { + schema: "chrome://extensions/content/schemas/extension_types.json", + scopes: [], + }, + alarms: { + url: "chrome://extensions/content/ext-alarms.js", + schema: "chrome://extensions/content/schemas/alarms.json", + scopes: ["addon_parent"], + paths: [ + ["alarms"], + ], + }, + backgroundPage: { + url: "chrome://extensions/content/ext-backgroundPage.js", + scopes: ["addon_parent"], + manifest: ["background"], + }, + contextualIdentities: { + url: "chrome://extensions/content/ext-contextualIdentities.js", + schema: "chrome://extensions/content/schemas/contextual_identities.json", + scopes: ["addon_parent"], + paths: [ + ["contextualIdentities"], + ], + }, + cookies: { + url: "chrome://extensions/content/ext-cookies.js", + schema: "chrome://extensions/content/schemas/cookies.json", + scopes: ["addon_parent"], + paths: [ + ["cookies"], + ], + }, + downloads: { + url: "chrome://extensions/content/ext-downloads.js", + schema: "chrome://extensions/content/schemas/downloads.json", + scopes: ["addon_parent"], + paths: [ + ["downloads"], + ], + }, + extension: { + url: "chrome://extensions/content/ext-extension.js", + schema: "chrome://extensions/content/schemas/extension.json", + scopes: ["addon_parent"], + paths: [ + ["extension"], + ], + }, + geolocation: { + url: "chrome://extensions/content/ext-geolocation.js", + events: ["startup"], + }, + i18n: { + url: "chrome://extensions/content/ext-i18n.js", + schema: "chrome://extensions/content/schemas/i18n.json", + scopes: ["addon_parent", "content_child", "devtools_child"], + paths: [ + ["i18n"], + ], + }, + idle: { + url: "chrome://extensions/content/ext-idle.js", + schema: "chrome://extensions/content/schemas/idle.json", + scopes: ["addon_parent"], + paths: [ + ["idle"], + ], + }, + management: { + url: "chrome://extensions/content/ext-management.js", + schema: "chrome://extensions/content/schemas/management.json", + scopes: ["addon_parent"], + paths: [ + ["management"], + ], + }, + notifications: { + url: "chrome://extensions/content/ext-notifications.js", + schema: "chrome://extensions/content/schemas/notifications.json", + scopes: ["addon_parent"], + paths: [ + ["notifications"], + ], + }, + permissions: { + url: "chrome://extensions/content/ext-permissions.js", + schema: "chrome://extensions/content/schemas/permissions.json", + scopes: ["addon_parent"], + paths: [ + ["permissions"], + ], + }, + privacy: { + url: "chrome://extensions/content/ext-privacy.js", + schema: "chrome://extensions/content/schemas/privacy.json", + scopes: ["addon_parent"], + paths: [ + ["privacy"], + ], + }, + protocolHandlers: { + url: "chrome://extensions/content/ext-protocolHandlers.js", + schema: "chrome://extensions/content/schemas/extension_protocol_handlers.json", + scopes: ["addon_parent"], + manifest: ["protocol_handlers"], + }, + proxy: { + url: "chrome://extensions/content/ext-proxy.js", + schema: "chrome://extensions/content/schemas/proxy.json", + scopes: ["addon_parent"], + paths: [ + ["proxy"], + ], + }, + runtime: { + url: "chrome://extensions/content/ext-runtime.js", + schema: "chrome://extensions/content/schemas/runtime.json", + scopes: ["addon_parent", "content_parent", "devtools_parent"], + paths: [ + ["runtime"], + ], + }, + storage: { + url: "chrome://extensions/content/ext-storage.js", + schema: "chrome://extensions/content/schemas/storage.json", + scopes: ["addon_parent", "content_parent", "devtools_parent"], + paths: [ + ["storage"], + ], + }, + test: { + schema: "chrome://extensions/content/schemas/test.json", + scopes: [], + }, + theme: { + url: "chrome://extensions/content/ext-theme.js", + schema: "chrome://extensions/content/schemas/theme.json", + scopes: ["addon_parent"], + manifest: ["theme"], + paths: [ + ["theme"], + ], + }, + topSites: { + url: "chrome://extensions/content/ext-topSites.js", + schema: "chrome://extensions/content/schemas/top_sites.json", + scopes: ["addon_parent"], + paths: [ + ["topSites"], + ], + }, + webNavigation: { + url: "chrome://extensions/content/ext-webNavigation.js", + schema: "chrome://extensions/content/schemas/web_navigation.json", + scopes: ["addon_parent"], + paths: [ + ["webNavigation"], + ], + }, + webRequest: { + url: "chrome://extensions/content/ext-webRequest.js", + schema: "chrome://extensions/content/schemas/web_request.json", + scopes: ["addon_parent"], + paths: [ + ["webRequest"], + ], + }, +}); + +if (AppConstants.MOZ_BUILD_APP === "browser") { + extensions.registerModules({ + identity: { + schema: "chrome://extensions/content/schemas/identity.json", + scopes: ["addon_parent"], + }, + }); +} diff --git a/toolkit/components/extensions/ext-topSites.js b/toolkit/components/extensions/ext-topSites.js new file mode 100644 index 0000000000..dfa0cc6774 --- /dev/null +++ b/toolkit/components/extensions/ext-topSites.js @@ -0,0 +1,40 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "NewTabUtils", + "resource://gre/modules/NewTabUtils.jsm"); + +this.topSites = class extends ExtensionAPI { + getAPI(context) { + return { + topSites: { + get: function(options) { + return new Promise(function(resolve) { + NewTabUtils.links.populateCache(function() { + let urls; + + if (options && options.providers && options.providers.length > 0) { + let urlLists = options.providers.map(function(p) { + let provider = NewTabUtils[`${p}Provider`]; + return provider ? NewTabUtils.getProviderLinks(provider).slice() : []; + }); + urls = NewTabUtils.links.mergeLinkLists(urlLists); + } else { + urls = NewTabUtils.links.getLinks(); + } + + resolve(urls.filter(link => !!link) + .map(link => { + return { + url: link.url, + title: link.title, + }; + })); + }, false); + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-webNavigation.js b/toolkit/components/extensions/ext-webNavigation.js new file mode 100644 index 0000000000..d57f14df84 --- /dev/null +++ b/toolkit/components/extensions/ext-webNavigation.js @@ -0,0 +1,206 @@ +"use strict"; + +// The ext-* files are imported into the same scopes. +/* import-globals-from ext-toolkit.js */ + +// This file expectes tabTracker to be defined in the global scope (e.g. +// by ext-utils.js). +/* global tabTracker */ + +XPCOMUtils.defineLazyModuleGetter(this, "MatchURLFilters", + "resource://gre/modules/MatchPattern.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WebNavigation", + "resource://gre/modules/WebNavigation.jsm"); + +const defaultTransitionTypes = { + topFrame: "link", + subFrame: "auto_subframe", +}; + +const frameTransitions = { + anyFrame: { + qualifiers: ["server_redirect", "client_redirect", "forward_back"], + }, + topFrame: { + types: ["reload", "form_submit"], + }, +}; + +const tabTransitions = { + topFrame: { + qualifiers: ["from_address_bar"], + types: ["auto_bookmark", "typed", "keyword", "generated", "link"], + }, + subFrame: { + types: ["manual_subframe"], + }, +}; + +const isTopLevelFrame = ({frameId, parentFrameId}) => { + return frameId == 0 && parentFrameId == -1; +}; + +const fillTransitionProperties = (eventName, src, dst) => { + if (eventName == "onCommitted" || + eventName == "onHistoryStateUpdated" || + eventName == "onReferenceFragmentUpdated") { + let frameTransitionData = src.frameTransitionData || {}; + let tabTransitionData = src.tabTransitionData || {}; + + let transitionType, transitionQualifiers = []; + + // Fill transition properties for any frame. + for (let qualifier of frameTransitions.anyFrame.qualifiers) { + if (frameTransitionData[qualifier]) { + transitionQualifiers.push(qualifier); + } + } + + if (isTopLevelFrame(dst)) { + for (let type of frameTransitions.topFrame.types) { + if (frameTransitionData[type]) { + transitionType = type; + } + } + + for (let qualifier of tabTransitions.topFrame.qualifiers) { + if (tabTransitionData[qualifier]) { + transitionQualifiers.push(qualifier); + } + } + + for (let type of tabTransitions.topFrame.types) { + if (tabTransitionData[type]) { + transitionType = type; + } + } + + // If transitionType is not defined, defaults it to "link". + if (!transitionType) { + transitionType = defaultTransitionTypes.topFrame; + } + } else { + // If it is sub-frame, transitionType defaults it to "auto_subframe", + // "manual_subframe" is set only in case of a recent user interaction. + transitionType = tabTransitionData.link ? + "manual_subframe" : defaultTransitionTypes.subFrame; + } + + // Fill the transition properties in the webNavigation event object. + dst.transitionType = transitionType; + dst.transitionQualifiers = transitionQualifiers; + } +}; + +// Similar to WebRequestEventManager but for WebNavigation. +function WebNavigationEventManager(context, eventName) { + let name = `webNavigation.${eventName}`; + let register = (fire, urlFilters) => { + // Don't create a MatchURLFilters instance if the listener does not include any filter. + let filters = urlFilters ? + new MatchURLFilters(urlFilters.url) : null; + + let listener = data => { + if (!data.browser) { + return; + } + + let data2 = { + url: data.url, + timeStamp: Date.now(), + }; + + if (eventName == "onErrorOccurred") { + data2.error = data.error; + } + + if (data.frameId != undefined) { + data2.frameId = data.frameId; + data2.parentFrameId = data.parentFrameId; + } + + if (data.sourceFrameId != undefined) { + data2.sourceFrameId = data.sourceFrameId; + } + + // Fills in tabId typically. + Object.assign(data2, tabTracker.getBrowserData(data.browser)); + if (data2.tabId < 0) { + return; + } + + if (data.sourceTabBrowser) { + data2.sourceTabId = tabTracker.getBrowserData(data.sourceTabBrowser).tabId; + } + + fillTransitionProperties(eventName, data, data2); + + fire.async(data2); + }; + + WebNavigation[eventName].addListener(listener, filters); + return () => { + WebNavigation[eventName].removeListener(listener); + }; + }; + + return SingletonEventManager.call(this, context, name, register); +} + +WebNavigationEventManager.prototype = Object.create(SingletonEventManager.prototype); + +const convertGetFrameResult = (tabId, data) => { + return { + errorOccurred: data.errorOccurred, + url: data.url, + tabId, + frameId: data.frameId, + parentFrameId: data.parentFrameId, + }; +}; + +this.webNavigation = class extends ExtensionAPI { + getAPI(context) { + let {tabManager} = context.extension; + + return { + webNavigation: { + onTabReplaced: new SingletonEventManager(context, "webNavigation.onTabReplaced", fire => { + return () => {}; + }).api(), + onBeforeNavigate: new WebNavigationEventManager(context, "onBeforeNavigate").api(), + onCommitted: new WebNavigationEventManager(context, "onCommitted").api(), + onDOMContentLoaded: new WebNavigationEventManager(context, "onDOMContentLoaded").api(), + onCompleted: new WebNavigationEventManager(context, "onCompleted").api(), + onErrorOccurred: new WebNavigationEventManager(context, "onErrorOccurred").api(), + onReferenceFragmentUpdated: new WebNavigationEventManager(context, "onReferenceFragmentUpdated").api(), + onHistoryStateUpdated: new WebNavigationEventManager(context, "onHistoryStateUpdated").api(), + onCreatedNavigationTarget: new WebNavigationEventManager(context, "onCreatedNavigationTarget").api(), + getAllFrames(details) { + let tab = tabManager.get(details.tabId); + + let {innerWindowID, messageManager} = tab.browser; + let recipient = {innerWindowID}; + + return context.sendMessage(messageManager, "WebNavigation:GetAllFrames", {}, {recipient}) + .then((results) => results.map(convertGetFrameResult.bind(null, details.tabId))); + }, + getFrame(details) { + let tab = tabManager.get(details.tabId); + + let recipient = { + innerWindowID: tab.browser.innerWindowID, + }; + + let mm = tab.browser.messageManager; + return context.sendMessage(mm, "WebNavigation:GetFrame", {options: details}, {recipient}) + .then((result) => { + return result ? + convertGetFrameResult(details.tabId, result) : + Promise.reject({message: `No frame found with frameId: ${details.frameId}`}); + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/ext-webRequest.js b/toolkit/components/extensions/ext-webRequest.js new file mode 100644 index 0000000000..e8709f996a --- /dev/null +++ b/toolkit/components/extensions/ext-webRequest.js @@ -0,0 +1,145 @@ +"use strict"; + +// The ext-* files are imported into the same scopes. +/* import-globals-from ext-toolkit.js */ + +// This file expectes tabTracker to be defined in the global scope (e.g. +// by ext-utils.js). +/* global tabTracker */ + +XPCOMUtils.defineLazyModuleGetter(this, "WebRequest", + "resource://gre/modules/WebRequest.jsm"); + +// EventManager-like class specifically for WebRequest. Inherits from +// SingletonEventManager. Takes care of converting |details| parameter +// when invoking listeners. +function WebRequestEventManager(context, eventName) { + let name = `webRequest.${eventName}`; + let register = (fire, filter, info) => { + let listener = data => { + // Prevent listening in on requests originating from system principal to + // prevent tinkering with OCSP, app and addon updates, etc. + if (data.isSystemPrincipal) { + return; + } + + // Check hosts permissions for both the resource being requested, + const hosts = context.extension.whiteListedHosts; + if (!hosts.matches(Services.io.newURI(data.url))) { + return; + } + // and the origin that is loading the resource. + const origin = data.documentUrl; + const own = origin && origin.startsWith(context.extension.getURL()); + if (origin && !own && !hosts.matches(Services.io.newURI(origin))) { + return; + } + + let browserData = {tabId: -1, windowId: -1}; + if (data.browser) { + browserData = tabTracker.getBrowserData(data.browser); + } + if (filter.tabId != null && browserData.tabId != filter.tabId) { + return; + } + if (filter.windowId != null && browserData.windowId != filter.windowId) { + return; + } + + let data2 = { + requestId: data.requestId, + url: data.url, + originUrl: data.originUrl, + documentUrl: data.documentUrl, + method: data.method, + tabId: browserData.tabId, + type: data.type, + timeStamp: Date.now(), + frameId: data.windowId, + parentFrameId: data.parentWindowId, + }; + + const maybeCached = ["onResponseStarted", "onBeforeRedirect", "onCompleted", "onErrorOccurred"]; + if (maybeCached.includes(eventName)) { + data2.fromCache = !!data.fromCache; + } + + if ("ip" in data) { + data2.ip = data.ip; + } + + let optional = ["requestHeaders", "responseHeaders", "statusCode", "statusLine", "error", "redirectUrl", + "requestBody", "scheme", "realm", "isProxy", "challenger"]; + for (let opt of optional) { + if (opt in data) { + data2[opt] = data[opt]; + } + } + + return fire.sync(data2); + }; + + let filter2 = {}; + if (filter.urls) { + let perms = new MatchPatternSet([...context.extension.whiteListedHosts.patterns, + ...context.extension.optionalOrigins.patterns]); + + filter2.urls = new MatchPatternSet(filter.urls); + + if (!perms.overlapsAll(filter2.urls)) { + Cu.reportError("The webRequest.addListener filter doesn't overlap with host permissions."); + } + } + if (filter.types) { + filter2.types = filter.types; + } + if (filter.tabId) { + filter2.tabId = filter.tabId; + } + if (filter.windowId) { + filter2.windowId = filter.windowId; + } + + let info2 = []; + if (info) { + for (let desc of info) { + if (desc == "blocking" && !context.extension.hasPermission("webRequestBlocking")) { + Cu.reportError("Using webRequest.addListener with the blocking option " + + "requires the 'webRequestBlocking' permission."); + } else { + info2.push(desc); + } + } + } + + WebRequest[eventName].addListener(listener, filter2, info2); + return () => { + WebRequest[eventName].removeListener(listener); + }; + }; + + return SingletonEventManager.call(this, context, name, register); +} + +WebRequestEventManager.prototype = Object.create(SingletonEventManager.prototype); + +this.webRequest = class extends ExtensionAPI { + getAPI(context) { + return { + webRequest: { + onBeforeRequest: new WebRequestEventManager(context, "onBeforeRequest").api(), + onBeforeSendHeaders: new WebRequestEventManager(context, "onBeforeSendHeaders").api(), + onSendHeaders: new WebRequestEventManager(context, "onSendHeaders").api(), + onHeadersReceived: new WebRequestEventManager(context, "onHeadersReceived").api(), + onAuthRequired: new WebRequestEventManager(context, "onAuthRequired").api(), + onBeforeRedirect: new WebRequestEventManager(context, "onBeforeRedirect").api(), + onResponseStarted: new WebRequestEventManager(context, "onResponseStarted").api(), + onErrorOccurred: new WebRequestEventManager(context, "onErrorOccurred").api(), + onCompleted: new WebRequestEventManager(context, "onCompleted").api(), + handlerBehaviorChanged: function() { + // TODO: Flush all caches. + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/extension-process-script.js b/toolkit/components/extensions/extension-process-script.js new file mode 100644 index 0000000000..fc8b4f27d2 --- /dev/null +++ b/toolkit/components/extensions/extension-process-script.js @@ -0,0 +1,411 @@ +/* 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/. */ +"use strict"; + +/** + * This script contains the minimum, skeleton content process code that we need + * in order to lazily load other extension modules when they are first + * necessary. Anything which is not likely to be needed immediately, or shortly + * after startup, in *every* browser process live outside of this file. + */ + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel", + "resource://gre/modules/MessageChannel.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionChild", + "resource://gre/modules/ExtensionChild.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionContent", + "resource://gre/modules/ExtensionContent.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionPageChild", + "resource://gre/modules/ExtensionPageChild.jsm"); + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); + +XPCOMUtils.defineLazyGetter(this, "console", () => ExtensionUtils.getConsole()); + +const { + DefaultWeakMap, + getInnerWindowID, +} = ExtensionUtils; + +// We need to avoid touching Services.appinfo here in order to prevent +// the wrong version from being cached during xpcshell test startup. +const appinfo = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime); +const isContentProcess = appinfo.processType == appinfo.PROCESS_TYPE_CONTENT; + +function parseScriptOptions(options) { + return { + allFrames: options.all_frames, + matchAboutBlank: options.match_about_blank, + frameID: options.frame_id, + runAt: options.run_at, + + matches: new MatchPatternSet(options.matches), + excludeMatches: new MatchPatternSet(options.exclude_matches || []), + includeGlobs: options.include_globs && options.include_globs.map(glob => new MatchGlob(glob)), + excludeGlobs: options.exclude_globs && options.exclude_globs.map(glob => new MatchGlob(glob)), + + jsPaths: options.js || [], + cssPaths: options.css || [], + }; +} + +var extensions = new DefaultWeakMap(policy => { + let extension = new ExtensionChild.BrowserExtensionContent(policy.initData); + extension.policy = policy; + return extension; +}); + +var contentScripts = new DefaultWeakMap(matcher => { + return new ExtensionContent.Script(extensions.get(matcher.extension), + matcher); +}); + +function getMessageManager(window) { + let docShell = window.document.docShell.QueryInterface(Ci.nsIInterfaceRequestor); + try { + return docShell.getInterface(Ci.nsIContentFrameMessageManager); + } catch (e) { + // Some windows don't support this interface (hidden window). + return null; + } +} + +var DocumentManager; +var ExtensionManager; + +class ExtensionGlobal { + constructor(global) { + this.global = global; + + MessageChannel.addListener(global, "Extension:Capture", this); + MessageChannel.addListener(global, "Extension:DetectLanguage", this); + MessageChannel.addListener(global, "Extension:Execute", this); + MessageChannel.addListener(global, "WebNavigation:GetFrame", this); + MessageChannel.addListener(global, "WebNavigation:GetAllFrames", this); + } + + get messageFilterStrict() { + return { + innerWindowID: getInnerWindowID(this.global.content), + }; + } + + receiveMessage({target, messageName, recipient, data}) { + switch (messageName) { + case "Extension:Capture": + return ExtensionContent.handleExtensionCapture(this.global, data.width, data.height, data.options); + case "Extension:DetectLanguage": + return ExtensionContent.handleDetectLanguage(this.global, target); + case "Extension:Execute": + let policy = WebExtensionPolicy.getByID(recipient.extensionId); + + let matcher = new WebExtensionContentScript(policy, parseScriptOptions(data.options)); + + Object.assign(matcher, { + wantReturnValue: data.options.wantReturnValue, + removeCSS: data.options.remove_css, + cssOrigin: data.options.css_origin, + cssCode: data.options.cssCode, + jsCode: data.options.jsCode, + }); + + let script = contentScripts.get(matcher); + + return ExtensionContent.handleExtensionExecute(this.global, target, data.options, script); + case "WebNavigation:GetFrame": + return ExtensionContent.handleWebNavigationGetFrame(this.global, data.options); + case "WebNavigation:GetAllFrames": + return ExtensionContent.handleWebNavigationGetAllFrames(this.global); + } + } +} + +// Responsible for creating ExtensionContexts and injecting content +// scripts into them when new documents are created. +DocumentManager = { + globals: new Map(), + + // Initialize listeners that we need regardless of whether extensions are + // enabled. + earlyInit() { + Services.obs.addObserver(this, "tab-content-frameloader-created"); // eslint-disable-line mozilla/balanced-listeners + }, + + extensionProcessInitialized: false, + initExtensionProcess() { + if (this.extensionProcessInitialized || !WebExtensionPolicy.isExtensionProcess) { + return; + } + this.extensionProcessInitialized = true; + + for (let global of this.globals.keys()) { + ExtensionPageChild.init(global); + } + }, + + // Initialize a frame script global which extension contexts may be loaded + // into. + initGlobal(global) { + // Note: {once: true} does not work as expected here. + global.addEventListener("unload", event => { // eslint-disable-line mozilla/balanced-listeners + this.uninitGlobal(global); + }); + + this.globals.set(global, new ExtensionGlobal(global)); + if (this.extensionProcessInitialized && WebExtensionPolicy.isExtensionProcess) { + ExtensionPageChild.init(global); + } + }, + uninitGlobal(global) { + if (this.extensionProcessInitialized) { + ExtensionPageChild.uninit(global); + } + this.globals.delete(global); + }, + + initExtension(extension) { + this.initExtensionProcess(); + + this.injectExtensionScripts(extension); + }, + + // Listeners + + observe(subject, topic, data) { + if (topic == "tab-content-frameloader-created") { + this.initGlobal(subject); + } + }, + + // Script loading + + injectExtensionScripts(extension) { + for (let window of this.enumerateWindows()) { + for (let script of extension.contentScripts) { + if (script.matchesWindow(window)) { + contentScripts.get(script).injectInto(window); + } + } + } + }, + + /** + * Checks that all parent frames for the given withdow either have the + * same add-on ID, or are special chrome-privileged documents such as + * about:addons or developer tools panels. + * + * @param {Window} window + * The window to check. + * @param {string} addonId + * The add-on ID to check. + * @returns {boolean} + */ + checkParentFrames(window, addonId) { + while (window.parent !== window) { + let {frameElement} = window; + window = window.parent; + + let principal = window.document.nodePrincipal; + + if (Services.scriptSecurityManager.isSystemPrincipal(principal)) { + // The add-on manager is a special case, since it contains extension + // options pages in same-type frames. + if (window.location.href === "about:addons") { + return true; + } + + // NOTE: Special handling for devtools panels using a chrome iframe here + // for the devtools panel, it is needed because a content iframe breaks + // switching between docked and undocked mode (see bug 1075490). + if (frameElement && + frameElement.mozMatchesSelector("browser[webextension-view-type='devtools_panel']")) { + return true; + } + } + + if (principal.addonId !== addonId) { + return false; + } + } + + return true; + }, + + loadInto(policy, window) { + let extension = extensions.get(policy); + if (WebExtensionPolicy.isExtensionProcess && this.checkParentFrames(window, policy.id)) { + // We're in a top-level extension frame, or a sub-frame thereof, + // in the extension process. Inject the full extension page API. + ExtensionPageChild.initExtensionContext(extension, window); + } else { + // We're in a content sub-frame or not in the extension process. + // Only inject a minimal content script API. + ExtensionContent.initExtensionContext(extension, window); + } + }, + + // Helpers + + * enumerateWindows(docShell) { + if (docShell) { + let enum_ = docShell.getDocShellEnumerator(docShell.typeContent, + docShell.ENUMERATE_FORWARDS); + + for (let docShell of XPCOMUtils.IterSimpleEnumerator(enum_, Ci.nsIInterfaceRequestor)) { + yield docShell.getInterface(Ci.nsIDOMWindow); + } + } else { + for (let global of this.globals.keys()) { + yield* this.enumerateWindows(global.docShell); + } + } + }, +}; + +ExtensionManager = { + init() { + MessageChannel.setupMessageManagers([Services.cpmm]); + + Services.cpmm.addMessageListener("Extension:Startup", this); + Services.cpmm.addMessageListener("Extension:Shutdown", this); + Services.cpmm.addMessageListener("Extension:FlushJarCache", this); + + let procData = Services.cpmm.initialProcessData || {}; + + for (let data of procData["Extension:Extensions"] || []) { + this.initExtension(data); + } + + if (isContentProcess) { + // Make sure we handle new schema data until Schemas.jsm is loaded. + if (!procData["Extension:Schemas"]) { + procData["Extension:Schemas"] = new Map(); + } + this.schemaJSON = procData["Extension:Schemas"]; + + Services.cpmm.addMessageListener("Schema:Add", this); + } + }, + + initExtensionPolicy(data, extension) { + let policy = WebExtensionPolicy.getByID(data.id); + if (!policy) { + let localizeCallback = ( + extension ? extension.localize.bind(extension) + : str => extensions.get(policy).localize(str)); + + policy = new WebExtensionPolicy({ + id: data.id, + mozExtensionHostname: data.uuid, + baseURL: data.resourceURL, + + permissions: Array.from(data.permissions), + allowedOrigins: new MatchPatternSet(data.whiteListedHosts), + webAccessibleResources: data.webAccessibleResources.map(host => new MatchGlob(host)), + + contentSecurityPolicy: data.manifest.content_security_policy, + + localizeCallback, + + backgroundScripts: (data.manifest.background && + data.manifest.background.scripts), + + contentScripts: (data.manifest.content_scripts || []).map(parseScriptOptions), + }); + + policy.active = true; + policy.initData = data; + } + return policy; + }, + + initExtension(data) { + let policy = this.initExtensionPolicy(data); + + DocumentManager.initExtension(policy); + }, + + receiveMessage({name, data}) { + switch (name) { + case "Extension:Startup": { + this.initExtension(data); + + Services.cpmm.sendAsyncMessage("Extension:StartupComplete"); + break; + } + + case "Extension:Shutdown": { + let policy = WebExtensionPolicy.getByID(data.id); + + if (extensions.has(policy)) { + extensions.get(policy).shutdown(); + } + + if (isContentProcess) { + policy.active = false; + } + Services.cpmm.sendAsyncMessage("Extension:ShutdownComplete"); + break; + } + + case "Extension:FlushJarCache": { + ExtensionUtils.flushJarCache(data.path); + Services.cpmm.sendAsyncMessage("Extension:FlushJarCacheComplete"); + break; + } + + case "Schema:Add": { + this.schemaJSON.set(data.url, data.schema); + break; + } + } + }, +}; + +function ExtensionProcessScript() { + if (!ExtensionProcessScript.singleton) { + ExtensionProcessScript.singleton = this; + } + return ExtensionProcessScript.singleton; +} + +ExtensionProcessScript.singleton = null; + +ExtensionProcessScript.prototype = { + classID: Components.ID("{21f9819e-4cdf-49f9-85a0-850af91a5058}"), + QueryInterface: XPCOMUtils.generateQI([Ci.mozIExtensionProcessScript]), + + get wrappedJSObject() { return this; }, + + initExtension(data, extension) { + return ExtensionManager.initExtensionPolicy(data, extension); + }, + + initExtensionDocument(policy, doc) { + if (DocumentManager.globals.has(getMessageManager(doc.defaultView))) { + DocumentManager.loadInto(policy, doc.defaultView); + } + }, + + preloadContentScript(contentScript) { + contentScripts.get(contentScript).preload(); + }, + + loadContentScript(contentScript, window) { + if (DocumentManager.globals.has(getMessageManager(window))) { + contentScripts.get(contentScript).injectInto(window); + } + }, +}; + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([ExtensionProcessScript]); + +DocumentManager.earlyInit(); +ExtensionManager.init(); diff --git a/toolkit/components/extensions/extensions-toolkit.manifest b/toolkit/components/extensions/extensions-toolkit.manifest new file mode 100644 index 0000000000..abfcad8ba5 --- /dev/null +++ b/toolkit/components/extensions/extensions-toolkit.manifest @@ -0,0 +1,13 @@ +# scripts +category webextension-scripts toolkit chrome://extensions/content/ext-toolkit.js +category webextension-scripts-content toolkit chrome://extensions/content/ext-c-toolkit.js +category webextension-scripts-devtools toolkit chrome://extensions/content/ext-c-toolkit.js +category webextension-scripts-addon toolkit chrome://extensions/content/ext-c-toolkit.js + +category webextension-schemas events chrome://extensions/content/schemas/events.json +category webextension-schemas native_host_manifest chrome://extensions/content/schemas/native_host_manifest.json +category webextension-schemas types chrome://extensions/content/schemas/types.json + + +component {21f9819e-4cdf-49f9-85a0-850af91a5058} extension-process-script.js +contract @mozilla.org/webextensions/extension-process-script;1 {21f9819e-4cdf-49f9-85a0-850af91a5058} diff --git a/toolkit/components/extensions/jar.mn b/toolkit/components/extensions/jar.mn new file mode 100644 index 0000000000..97374c1002 --- /dev/null +++ b/toolkit/components/extensions/jar.mn @@ -0,0 +1,41 @@ +# 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/. + +toolkit.jar: +% content extensions %content/extensions/ + content/extensions/ext-alarms.js + content/extensions/ext-backgroundPage.js + content/extensions/ext-browser-content.js + content/extensions/ext-contextualIdentities.js + content/extensions/ext-cookies.js + content/extensions/ext-downloads.js + content/extensions/ext-extension.js + content/extensions/ext-geolocation.js + content/extensions/ext-i18n.js + content/extensions/ext-idle.js + content/extensions/ext-management.js + content/extensions/ext-notifications.js + content/extensions/ext-permissions.js + content/extensions/ext-privacy.js + content/extensions/ext-protocolHandlers.js + content/extensions/ext-proxy.js + content/extensions/ext-runtime.js + content/extensions/ext-storage.js + content/extensions/ext-theme.js + content/extensions/ext-toolkit.js + content/extensions/ext-topSites.js + content/extensions/ext-webRequest.js + content/extensions/ext-webNavigation.js + # Below is a separate group using the naming convention ext-c-*.js that run + # in the child process. + content/extensions/ext-c-backgroundPage.js + content/extensions/ext-c-extension.js +#ifndef ANDROID + content/extensions/ext-c-identity.js +#endif + content/extensions/ext-c-permissions.js + content/extensions/ext-c-runtime.js + content/extensions/ext-c-storage.js + content/extensions/ext-c-test.js + content/extensions/ext-c-toolkit.js diff --git a/toolkit/components/extensions/moz.build b/toolkit/components/extensions/moz.build new file mode 100644 index 0000000000..13794bc0a2 --- /dev/null +++ b/toolkit/components/extensions/moz.build @@ -0,0 +1,93 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +with Files('**'): + BUG_COMPONENT = ('Toolkit', 'WebExtensions: General') + +EXTRA_JS_MODULES += [ + 'Extension.jsm', + 'ExtensionAPI.jsm', + 'ExtensionChild.jsm', + 'ExtensionChildDevToolsUtils.jsm', + 'ExtensionCommon.jsm', + 'ExtensionContent.jsm', + 'ExtensionPageChild.jsm', + 'ExtensionParent.jsm', + 'ExtensionPermissions.jsm', + 'ExtensionPreferencesManager.jsm', + 'ExtensionSettingsStore.jsm', + 'ExtensionStorage.jsm', + 'ExtensionStorageSync.jsm', + 'ExtensionTabs.jsm', + 'ExtensionUtils.jsm', + 'LegacyExtensionsUtils.jsm', + 'MessageChannel.jsm', + 'NativeMessaging.jsm', + 'ProxyScriptContext.jsm', + 'Schemas.jsm', +] + +EXTRA_COMPONENTS += [ + 'extension-process-script.js', + 'extensions-toolkit.manifest', +] + +TESTING_JS_MODULES += [ + 'ExtensionTestCommon.jsm', + 'ExtensionXPCShellUtils.jsm', +] + +DIRS += [ + 'schemas', + 'webrequest', +] + +XPIDL_SOURCES += [ + 'mozIExtensionProcessScript.idl', +] + +XPIDL_MODULE = 'webextensions' + +EXPORTS.mozilla = [ + 'AddonManagerWebAPI.h', + 'ExtensionPolicyService.h', +] + +EXPORTS.mozilla.extensions = [ + 'MatchGlob.h', + 'MatchPattern.h', + 'WebExtensionContentScript.h', + 'WebExtensionPolicy.h', +] + +UNIFIED_SOURCES += [ + 'AddonManagerWebAPI.cpp', + 'ExtensionPolicyService.cpp', + 'MatchPattern.cpp', + 'WebExtensionPolicy.cpp', +] + +FINAL_LIBRARY = 'xul' + + +JAR_MANIFESTS += ['jar.mn'] + +BROWSER_CHROME_MANIFESTS += [ + 'test/browser/browser.ini', +] + +MOCHITEST_MANIFESTS += [ + 'test/mochitest/mochitest-remote.ini', + 'test/mochitest/mochitest.ini' +] +MOCHITEST_CHROME_MANIFESTS += ['test/mochitest/chrome.ini'] +XPCSHELL_TESTS_MANIFESTS += [ + 'test/xpcshell/native_messaging.ini', + 'test/xpcshell/xpcshell-remote.ini', + 'test/xpcshell/xpcshell.ini', +] + +include('/ipc/chromium/chromium-config.mozbuild') diff --git a/toolkit/components/extensions/mozIExtensionProcessScript.idl b/toolkit/components/extensions/mozIExtensionProcessScript.idl new file mode 100644 index 0000000000..0d11feedb8 --- /dev/null +++ b/toolkit/components/extensions/mozIExtensionProcessScript.idl @@ -0,0 +1,18 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface mozIDOMWindowProxy; +interface nsIDOMDocument; + +[scriptable,uuid(6b09dc51-6caa-4ca7-9d6d-30c87258a630)] +interface mozIExtensionProcessScript : nsISupports +{ + void preloadContentScript(in nsISupports contentScript); + + void loadContentScript(in nsISupports contentScript, in mozIDOMWindowProxy window); + + void initExtensionDocument(in nsISupports extension, in nsIDOMDocument doc); +}; diff --git a/toolkit/components/extensions/schemas/LICENSE b/toolkit/components/extensions/schemas/LICENSE new file mode 100644 index 0000000000..9314092fdc --- /dev/null +++ b/toolkit/components/extensions/schemas/LICENSE @@ -0,0 +1,27 @@ +// Copyright (c) 2006-2008 The Chromium Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/toolkit/components/extensions/schemas/alarms.json b/toolkit/components/extensions/schemas/alarms.json new file mode 100644 index 0000000000..2a72a28425 --- /dev/null +++ b/toolkit/components/extensions/schemas/alarms.json @@ -0,0 +1,145 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "alarms", + "permissions": ["alarms"], + "types": [ + { + "id": "Alarm", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of this alarm." + }, + "scheduledTime": { + "type": "number", + "description": "Time when the alarm is scheduled to fire, in milliseconds past the epoch." + }, + "periodInMinutes": { + "type": "number", + "optional": true, + "description": "When present, signals that the alarm triggers periodically after so many minutes." + } + } + } + ], + "functions": [ + { + "name": "create", + "type": "function", + "description": "Creates an alarm. After the delay is expired, the onAlarm event is fired. If there is another alarm with the same name (or no name if none is specified), it will be cancelled and replaced by this alarm.", + "parameters": [ + { + "type": "string", + "name": "name", + "optional": true, + "description": "Optional name to identify this alarm. Defaults to the empty string." + }, + { + "type": "object", + "name": "alarmInfo", + "description": "Details about the alarm. The alarm first fires either at 'when' milliseconds past the epoch (if 'when' is provided), after 'delayInMinutes' minutes from the current time (if 'delayInMinutes' is provided instead), or after 'periodInMinutes' minutes from the current time (if only 'periodInMinutes' is provided). Users should never provide both 'when' and 'delayInMinutes'. If 'periodInMinutes' is provided, then the alarm recurs repeatedly after that many minutes.", + "properties": { + "when": {"type": "number", "optional": true, + "description": "Time when the alarm is scheduled to first fire, in milliseconds past the epoch."}, + "delayInMinutes": {"type": "number", "optional": true, + "description": "Number of minutes from the current time after which the alarm should first fire."}, + "periodInMinutes": {"type": "number", "optional": true, + "description": "Number of minutes after which the alarm should recur repeatedly."} + } + } + ] + }, + { + "name": "get", + "type": "function", + "description": "Retrieves details about the specified alarm.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "name", + "optional": true, + "description": "The name of the alarm to get. Defaults to the empty string." + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { "name": "alarm", "$ref": "Alarm" } + ] + } + ] + }, + { + "name": "getAll", + "type": "function", + "description": "Gets an array of all the alarms.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { "name": "alarms", "type": "array", "items": { "$ref": "Alarm" } } + ] + } + ] + }, + { + "name": "clear", + "type": "function", + "description": "Clears the alarm with the given name.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "name", + "optional": true, + "description": "The name of the alarm to clear. Defaults to the empty string." + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { "name": "wasCleared", "type": "boolean", "description": "Whether an alarm of the given name was found to clear." } + ] + } + ] + }, + { + "name": "clearAll", + "type": "function", + "description": "Clears all alarms.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { "name": "wasCleared", "type": "boolean", "description": "Whether any alarm was found to clear." } + ] + } + ] + } + ], + "events": [ + { + "name": "onAlarm", + "type": "function", + "description": "Fired when an alarm has expired. Useful for transient background pages.", + "parameters": [ + { + "name": "name", + "$ref": "Alarm", + "description": "The alarm that has expired." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/contextual_identities.json b/toolkit/components/extensions/schemas/contextual_identities.json new file mode 100644 index 0000000000..fb63994672 --- /dev/null +++ b/toolkit/components/extensions/schemas/contextual_identities.json @@ -0,0 +1,123 @@ +/* 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/. */ + +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "Permission", + "choices": [{ + "type": "string", + "enum": [ + "contextualIdentities" + ] + }] + } + ] + }, + { + "namespace": "contextualIdentities", + "description": "Use the browser.contextualIdentities API to query and modify contextual identity, also called as containers.", + "permissions": ["contextualIdentities"], + "types": [ + { + "id": "ContextualIdentity", + "type": "object", + "description": "Represents information about a contextual identity.", + "properties": { + "name": {"type": "string", "description": "The name of the contextual identity."}, + "icon": {"type": "string", "description": "The icon of the contextual identity."}, + "color": {"type": "string", "description": "The color of the contextual identity."}, + "cookieStoreId": {"type": "string", "description": "The cookie store ID of the contextual identity."} + } + } + ], + "functions": [ + { + "name": "get", + "type": "function", + "description": "Retrieves information about a single contextual identity.", + "async": true, + "parameters": [ + { + "type": "string", + "name": "cookieStoreId", + "description": "The ID of the contextual identity cookie store. " + } + ] + }, + { + "name": "query", + "type": "function", + "description": "Retrieves all contextual identities", + "async": true, + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Information to filter the contextual identities being retrieved.", + "properties": { + "name": {"type": "string", "optional": true, "description": "Filters the contextual identity by name."} + } + } + ] + }, + { + "name": "create", + "type": "function", + "description": "Creates a contextual identity with the given data.", + "async": true, + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Details about the contextual identity being created.", + "properties": { + "name": {"type": "string", "optional": false, "description": "The name of the contextual identity." }, + "color": {"type": "string", "optional": false, "description": "The color of the contextual identity." }, + "icon": {"type": "string", "optional": false, "description": "The icon of the contextual identity." } + } + } + ] + }, + { + "name": "update", + "type": "function", + "description": "Updates a contextual identity with the given data.", + "async": true, + "parameters": [ + { + "type": "string", + "name": "cookieStoreId", + "description": "The ID of the contextual identity cookie store. " + }, + { + "type": "object", + "name": "details", + "description": "Details about the contextual identity being created.", + "properties": { + "name": {"type": "string", "optional": true, "description": "The name of the contextual identity." }, + "color": {"type": "string", "optional": true, "description": "The color of the contextual identity." }, + "icon": {"type": "string", "optional": true, "description": "The icon of the contextual identity." } + } + } + ] + }, + { + "name": "remove", + "type": "function", + "description": "Deletes a contetual identity by its cookie Store ID.", + "async": true, + "parameters": [ + { + "type": "string", + "name": "cookieStoreId", + "description": "The ID of the contextual identity cookie store. " + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/cookies.json b/toolkit/components/extensions/schemas/cookies.json new file mode 100644 index 0000000000..1a26407719 --- /dev/null +++ b/toolkit/components/extensions/schemas/cookies.json @@ -0,0 +1,224 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [{ + "type": "string", + "enum": [ + "cookies" + ] + }] + } + ] + }, + { + "namespace": "cookies", + "description": "Use the browser.cookies API to query and modify cookies, and to be notified when they change.", + "permissions": ["cookies"], + "types": [ + { + "id": "Cookie", + "type": "object", + "description": "Represents information about an HTTP cookie.", + "properties": { + "name": {"type": "string", "description": "The name of the cookie."}, + "value": {"type": "string", "description": "The value of the cookie."}, + "domain": {"type": "string", "description": "The domain of the cookie (e.g. \"www.google.com\", \"example.com\")."}, + "hostOnly": {"type": "boolean", "description": "True if the cookie is a host-only cookie (i.e. a request's host must exactly match the domain of the cookie)."}, + "path": {"type": "string", "description": "The path of the cookie."}, + "secure": {"type": "boolean", "description": "True if the cookie is marked as Secure (i.e. its scope is limited to secure channels, typically HTTPS)."}, + "httpOnly": {"type": "boolean", "description": "True if the cookie is marked as HttpOnly (i.e. the cookie is inaccessible to client-side scripts)."}, + "session": {"type": "boolean", "description": "True if the cookie is a session cookie, as opposed to a persistent cookie with an expiration date."}, + "expirationDate": {"type": "number", "optional": true, "description": "The expiration date of the cookie as the number of seconds since the UNIX epoch. Not provided for session cookies."}, + "storeId": {"type": "string", "description": "The ID of the cookie store containing this cookie, as provided in getAllCookieStores()."} + } + }, + { + "id": "CookieStore", + "type": "object", + "description": "Represents a cookie store in the browser. An incognito mode window, for instance, uses a separate cookie store from a non-incognito window.", + "properties": { + "id": {"type": "string", "description": "The unique identifier for the cookie store."}, + "tabIds": {"type": "array", "items": {"type": "integer"}, "description": "Identifiers of all the browser tabs that share this cookie store."} + } + }, + { + "id": "OnChangedCause", + "type": "string", + "enum": ["evicted", "expired", "explicit", "expired_overwrite", "overwrite"], + "description": "The underlying reason behind the cookie's change. If a cookie was inserted, or removed via an explicit call to $(ref:cookies.remove), \"cause\" will be \"explicit\". If a cookie was automatically removed due to expiry, \"cause\" will be \"expired\". If a cookie was removed due to being overwritten with an already-expired expiration date, \"cause\" will be set to \"expired_overwrite\". If a cookie was automatically removed due to garbage collection, \"cause\" will be \"evicted\". If a cookie was automatically removed due to a \"set\" call that overwrote it, \"cause\" will be \"overwrite\". Plan your response accordingly." + } + ], + "functions": [ + { + "name": "get", + "type": "function", + "description": "Retrieves information about a single cookie. If more than one cookie of the same name exists for the given URL, the one with the longest path will be returned. For cookies with the same path length, the cookie with the earliest creation time will be returned.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Details to identify the cookie being retrieved.", + "properties": { + "url": {"type": "string", "description": "The URL with which the cookie to retrieve is associated. This argument may be a full URL, in which case any data following the URL path (e.g. the query string) is simply ignored. If host permissions for this URL are not specified in the manifest file, the API call will fail."}, + "name": {"type": "string", "description": "The name of the cookie to retrieve."}, + "storeId": {"type": "string", "optional": true, "description": "The ID of the cookie store in which to look for the cookie. By default, the current execution context's cookie store will be used."} + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "cookie", "$ref": "Cookie", "optional": true, "description": "Contains details about the cookie. This parameter is null if no such cookie was found." + } + ] + } + ] + }, + { + "name": "getAll", + "type": "function", + "description": "Retrieves all cookies from a single cookie store that match the given information. The cookies returned will be sorted, with those with the longest path first. If multiple cookies have the same path length, those with the earliest creation time will be first.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Information to filter the cookies being retrieved.", + "properties": { + "url": {"type": "string", "optional": true, "description": "Restricts the retrieved cookies to those that would match the given URL."}, + "name": {"type": "string", "optional": true, "description": "Filters the cookies by name."}, + "domain": {"type": "string", "optional": true, "description": "Restricts the retrieved cookies to those whose domains match or are subdomains of this one."}, + "path": {"type": "string", "optional": true, "description": "Restricts the retrieved cookies to those whose path exactly matches this string."}, + "secure": {"type": "boolean", "optional": true, "description": "Filters the cookies by their Secure property."}, + "session": {"type": "boolean", "optional": true, "description": "Filters out session vs. persistent cookies."}, + "storeId": {"type": "string", "optional": true, "description": "The cookie store to retrieve cookies from. If omitted, the current execution context's cookie store will be used."} + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "cookies", "type": "array", "items": {"$ref": "Cookie"}, "description": "All the existing, unexpired cookies that match the given cookie info." + } + ] + } + ] + }, + { + "name": "set", + "type": "function", + "description": "Sets a cookie with the given cookie data; may overwrite equivalent cookies if they exist.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Details about the cookie being set.", + "properties": { + "url": {"type": "string", "description": "The request-URI to associate with the setting of the cookie. This value can affect the default domain and path values of the created cookie. If host permissions for this URL are not specified in the manifest file, the API call will fail."}, + "name": {"type": "string", "optional": true, "description": "The name of the cookie. Empty by default if omitted."}, + "value": {"type": "string", "optional": true, "description": "The value of the cookie. Empty by default if omitted."}, + "domain": {"type": "string", "optional": true, "description": "The domain of the cookie. If omitted, the cookie becomes a host-only cookie."}, + "path": {"type": "string", "optional": true, "description": "The path of the cookie. Defaults to the path portion of the url parameter."}, + "secure": {"type": "boolean", "optional": true, "description": "Whether the cookie should be marked as Secure. Defaults to false."}, + "httpOnly": {"type": "boolean", "optional": true, "description": "Whether the cookie should be marked as HttpOnly. Defaults to false."}, + "expirationDate": {"type": "number", "optional": true, "description": "The expiration date of the cookie as the number of seconds since the UNIX epoch. If omitted, the cookie becomes a session cookie."}, + "storeId": {"type": "string", "optional": true, "description": "The ID of the cookie store in which to set the cookie. By default, the cookie is set in the current execution context's cookie store."} + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "cookie", "$ref": "Cookie", "optional": true, "description": "Contains details about the cookie that's been set. If setting failed for any reason, this will be \"null\", and $(ref:runtime.lastError) will be set." + } + ] + } + ] + }, + { + "name": "remove", + "type": "function", + "description": "Deletes a cookie by name.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Information to identify the cookie to remove.", + "properties": { + "url": {"type": "string", "description": "The URL associated with the cookie. If host permissions for this URL are not specified in the manifest file, the API call will fail."}, + "name": {"type": "string", "description": "The name of the cookie to remove."}, + "storeId": {"type": "string", "optional": true, "description": "The ID of the cookie store to look in for the cookie. If unspecified, the cookie is looked for by default in the current execution context's cookie store."} + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "details", + "type": "object", + "description": "Contains details about the cookie that's been removed. If removal failed for any reason, this will be \"null\", and $(ref:runtime.lastError) will be set.", + "optional": true, + "properties": { + "url": {"type": "string", "description": "The URL associated with the cookie that's been removed."}, + "name": {"type": "string", "description": "The name of the cookie that's been removed."}, + "storeId": {"type": "string", "description": "The ID of the cookie store from which the cookie was removed."} + } + } + ] + } + ] + }, + { + "name": "getAllCookieStores", + "type": "function", + "description": "Lists all existing cookie stores.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "cookieStores", "type": "array", "items": {"$ref": "CookieStore"}, "description": "All the existing cookie stores." + } + ] + } + ] + } + ], + "events": [ + { + "name": "onChanged", + "type": "function", + "description": "Fired when a cookie is set or removed. As a special case, note that updating a cookie's properties is implemented as a two step process: the cookie to be updated is first removed entirely, generating a notification with \"cause\" of \"overwrite\" . Afterwards, a new cookie is written with the updated values, generating a second notification with \"cause\" \"explicit\".", + "parameters": [ + { + "type": "object", + "name": "changeInfo", + "properties": { + "removed": {"type": "boolean", "description": "True if a cookie was removed."}, + "cookie": {"$ref": "Cookie", "description": "Information about the cookie that was set or removed."}, + "cause": {"$ref": "OnChangedCause", "description": "The underlying reason behind the cookie's change."} + } + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/downloads.json b/toolkit/components/extensions/schemas/downloads.json new file mode 100644 index 0000000000..dcd43e4e15 --- /dev/null +++ b/toolkit/components/extensions/schemas/downloads.json @@ -0,0 +1,793 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "Permission", + "choices": [{ + "type": "string", + "enum": [ + "downloads", + "downloads.open", + "downloads.shelf" + ] + }] + } + ] + }, + { + "namespace": "downloads", + "permissions": ["downloads"], + "types": [ + { + "id": "FilenameConflictAction", + "type": "string", + "enum": [ + "uniquify", + "overwrite", + "prompt" + ] + }, + { + "id": "InterruptReason", + "type": "string", + "enum": [ + "FILE_FAILED", + "FILE_ACCESS_DENIED", + "FILE_NO_SPACE", + "FILE_NAME_TOO_LONG", + "FILE_TOO_LARGE", + "FILE_VIRUS_INFECTED", + "FILE_TRANSIENT_ERROR", + "FILE_BLOCKED", + "FILE_SECURITY_CHECK_FAILED", + "FILE_TOO_SHORT", + "NETWORK_FAILED", + "NETWORK_TIMEOUT", + "NETWORK_DISCONNECTED", + "NETWORK_SERVER_DOWN", + "NETWORK_INVALID_REQUEST", + "SERVER_FAILED", + "SERVER_NO_RANGE", + "SERVER_BAD_CONTENT", + "SERVER_UNAUTHORIZED", + "SERVER_CERT_PROBLEM", + "SERVER_FORBIDDEN", + "USER_CANCELED", + "USER_SHUTDOWN", + "CRASH" + ] + }, + { + "id": "DangerType", + "type": "string", + "enum": [ + "file", + "url", + "content", + "uncommon", + "host", + "unwanted", + "safe", + "accepted" + ], + "description": "
file
The download's filename is suspicious.
url
The download's URL is known to be malicious.
content
The downloaded file is known to be malicious.
uncommon
The download's URL is not commonly downloaded and could be dangerous.
safe
The download presents no known danger to the user's computer.
These string constants will never change, however the set of DangerTypes may change." + }, + { + "id": "State", + "type": "string", + "enum": [ + "in_progress", + "interrupted", + "complete" + ], + "description": "
in_progress
The download is currently receiving data from the server.
interrupted
An error broke the connection with the file host.
complete
The download completed successfully.
These string constants will never change, however the set of States may change." + }, + { + "id": "DownloadItem", + "type": "object", + "properties": { + "id": { + "description": "An identifier that is persistent across browser sessions.", + "type": "integer" + }, + "url": { + "description": "Absolute URL.", + "type": "string" + }, + "referrer": { + "type": "string" + }, + "filename": { + "description": "Absolute local path.", + "type": "string" + }, + "incognito": { + "description": "False if this download is recorded in the history, true if it is not recorded.", + "type": "boolean" + }, + "danger": { + "$ref": "DangerType", + "description": "Indication of whether this download is thought to be safe or known to be suspicious." + }, + "mime": { + "description": "The file's MIME type.", + "type": "string" + }, + "startTime": { + "description": "Number of milliseconds between the unix epoch and when this download began.", + "type": "string" + }, + "endTime": { + "description": "Number of milliseconds between the unix epoch and when this download ended.", + "optional": true, + "type": "string" + }, + "estimatedEndTime": { + "type": "string", + "optional": true + }, + "state": { + "$ref": "State", + "description": "Indicates whether the download is progressing, interrupted, or complete." + }, + "paused": { + "description": "True if the download has stopped reading data from the host, but kept the connection open.", + "type": "boolean" + }, + "canResume": { + "type": "boolean" + }, + "error": { + "description": "Number indicating why a download was interrupted.", + "optional": true, + "$ref": "InterruptReason" + }, + "bytesReceived": { + "description": "Number of bytes received so far from the host, without considering file compression.", + "type": "number" + }, + "totalBytes": { + "description": "Number of bytes in the whole file, without considering file compression, or -1 if unknown.", + "type": "number" + }, + "fileSize": { + "description": "Number of bytes in the whole file post-decompression, or -1 if unknown.", + "type": "number" + }, + "exists": { + "type": "boolean" + }, + "byExtensionId": { + "type": "string", + "optional": true + }, + "byExtensionName": { + "type": "string", + "optional": true + } + } + }, + { + "id": "StringDelta", + "type": "object", + "properties": { + "current": { + "optional": true, + "type": "string" + }, + "previous": { + "optional": true, + "type": "string" + } + } + }, + { + "id": "DoubleDelta", + "type": "object", + "properties": { + "current": { + "optional": true, + "type": "number" + }, + "previous": { + "optional": true, + "type": "number" + } + } + }, + { + "id": "BooleanDelta", + "type": "object", + "properties": { + "current": { + "optional": true, + "type": "boolean" + }, + "previous": { + "optional": true, + "type": "boolean" + } + } + }, + { + "id": "DownloadTime", + "description": "A time specified as a Date object, a number or string representing milliseconds since the epoch, or an ISO 8601 string", + "choices": [ + { + "type": "string", + "pattern": "^[1-9]\\d*$" + }, + { + "$ref": "extensionTypes.Date" + } + ] + }, + { + "id": "DownloadQuery", + "description": "Parameters that combine to specify a predicate that can be used to select a set of downloads. Used for example in search() and erase()", + "type": "object", + "properties": { + "query": { + "description": "This array of search terms limits results to DownloadItems whose filename or url contain all of the search terms that do not begin with a dash '-' and none of the search terms that do begin with a dash.", + "optional": true, + "type": "array", + "items": { "type": "string" } + }, + "startedBefore": { + "description": "Limits results to downloads that started before the given ms since the epoch.", + "optional": true, + "$ref": "DownloadTime" + }, + "startedAfter": { + "description": "Limits results to downloads that started after the given ms since the epoch.", + "optional": true, + "$ref": "DownloadTime" + }, + "endedBefore": { + "description": "Limits results to downloads that ended before the given ms since the epoch.", + "optional": true, + "$ref": "DownloadTime" + }, + "endedAfter": { + "description": "Limits results to downloads that ended after the given ms since the epoch.", + "optional": true, + "$ref": "DownloadTime" + }, + "totalBytesGreater": { + "description": "Limits results to downloads whose totalBytes is greater than the given integer.", + "optional": true, + "type": "number" + }, + "totalBytesLess": { + "description": "Limits results to downloads whose totalBytes is less than the given integer.", + "optional": true, + "type": "number" + }, + "filenameRegex": { + "description": "Limits results to DownloadItems whose filename matches the given regular expression.", + "optional": true, + "type": "string" + }, + "urlRegex": { + "description": "Limits results to DownloadItems whose url matches the given regular expression.", + "optional": true, + "type": "string" + }, + "limit": { + "description": "Setting this integer limits the number of results. Otherwise, all matching DownloadItems will be returned.", + "optional": true, + "type": "integer" + }, + "orderBy": { + "description": "Setting elements of this array to DownloadItem properties in order to sort the search results. For example, setting orderBy='startTime' sorts the DownloadItems by their start time in ascending order. To specify descending order, prefix orderBy with a hyphen: '-startTime'.", + "optional": true, + "type": "array", + "items": { "type": "string" } + }, + "id": { + "type": "integer", + "optional": true + }, + "url": { + "description": "Absolute URL.", + "optional": true, + "type": "string" + }, + "filename": { + "description": "Absolute local path.", + "optional": true, + "type": "string" + }, + "danger": { + "$ref": "DangerType", + "description": "Indication of whether this download is thought to be safe or known to be suspicious.", + "optional": true + }, + "mime": { + "description": "The file's MIME type.", + "optional": true, + "type": "string" + }, + "startTime": { + "optional": true, + "type": "string" + }, + "endTime": { + "optional": true, + "type": "string" + }, + "state": { + "$ref": "State", + "description": "Indicates whether the download is progressing, interrupted, or complete.", + "optional": true + }, + "paused": { + "description": "True if the download has stopped reading data from the host, but kept the connection open.", + "optional": true, + "type": "boolean" + }, + "error": { + "description": "Why a download was interrupted.", + "optional": true, + "$ref": "InterruptReason" + }, + "bytesReceived": { + "description": "Number of bytes received so far from the host, without considering file compression.", + "optional": true, + "type": "number" + }, + "totalBytes": { + "description": "Number of bytes in the whole file, without considering file compression, or -1 if unknown.", + "optional": true, + "type": "number" + }, + "fileSize": { + "description": "Number of bytes in the whole file post-decompression, or -1 if unknown.", + "optional": true, + "type": "number" + }, + "exists": { + "type": "boolean", + "optional": true + } + } + } + ], + "functions": [ + { + "name": "download", + "type": "function", + "async": "callback", + "description": "Download a URL. If the URL uses the HTTP[S] protocol, then the request will include all cookies currently set for its hostname. If both filename and saveAs are specified, then the Save As dialog will be displayed, pre-populated with the specified filename. If the download started successfully, callback will be called with the new DownloadItem's downloadId. If there was an error starting the download, then callback will be called with downloadId=undefined and chrome.extension.lastError will contain a descriptive string. The error strings are not guaranteed to remain backwards compatible between releases. You must not parse it.", + "parameters": [ + { + "description": "What to download and how.", + "name": "options", + "type": "object", + "properties": { + "url": { + "description": "The URL to download.", + "type": "string", + "format": "url" + }, + "filename": { + "description": "A file path relative to the Downloads directory to contain the downloaded file.", + "optional": true, + "type": "string" + }, + "conflictAction": { + "$ref": "FilenameConflictAction", + "optional": true + }, + "saveAs": { + "description": "Use a file-chooser to allow the user to select a filename.", + "optional": true, + "type": "boolean" + }, + "method": { + "description": "The HTTP method to use if the URL uses the HTTP[S] protocol.", + "enum": [ + "GET", + "POST" + ], + "optional": true, + "type": "string" + }, + "headers": { + "optional": true, + "type": "array", + "description": "Extra HTTP headers to send with the request if the URL uses the HTTP[s] protocol. Each header is represented as a dictionary containing the keys name and either value or binaryValue, restricted to those allowed by XMLHttpRequest.", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Name of the HTTP header.", + "type": "string" + }, + "value": { + "description": "Value of the HTTP header.", + "type": "string" + } + } + } + }, + "body": { + "description": "Post body.", + "optional": true, + "type": "string" + } + } + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "name": "downloadId", + "type": "integer" + } + ] + } + ] + }, + { + "name": "search", + "type": "function", + "async": "callback", + "description": "Find DownloadItems. Set query to the empty object to get all DownloadItems. To get a specific DownloadItem, set only the id field.", + "parameters": [ + { + "name": "query", + "$ref": "DownloadQuery" + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "items": { + "$ref": "DownloadItem" + }, + "name": "results", + "type": "array" + } + ] + } + ] + }, + { + "name": "pause", + "type": "function", + "async": "callback", + "description": "Pause the download. If the request was successful the download is in a paused state. Otherwise chrome.extension.lastError contains an error message. The request will fail if the download is not active.", + "parameters": [ + { + "description": "The id of the download to pause.", + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "optional": true, + "parameters": [], + "type": "function" + } + ] + }, + { + "name": "resume", + "type": "function", + "async": "callback", + "description": "Resume a paused download. If the request was successful the download is in progress and unpaused. Otherwise chrome.extension.lastError contains an error message. The request will fail if the download is not active.", + "parameters": [ + { + "description": "The id of the download to resume.", + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "optional": true, + "parameters": [], + "type": "function" + } + ] + }, + { + "name": "cancel", + "type": "function", + "async": "callback", + "description": "Cancel a download. When callback is run, the download is cancelled, completed, interrupted or doesn't exist anymore.", + "parameters": [ + { + "description": "The id of the download to cancel.", + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "optional": true, + "parameters": [], + "type": "function" + } + ] + }, + { + "name": "getFileIcon", + "type": "function", + "async": "callback", + "description": "Retrieve an icon for the specified download. For new downloads, file icons are available after the onCreated event has been received. The image returned by this function while a download is in progress may be different from the image returned after the download is complete. Icon retrieval is done by querying the underlying operating system or toolkit depending on the platform. The icon that is returned will therefore depend on a number of factors including state of the download, platform, registered file types and visual theme. If a file icon cannot be determined, chrome.extension.lastError will contain an error message.", + "parameters": [ + { + "description": "The identifier for the download.", + "name": "downloadId", + "type": "integer" + }, + { + "name": "options", + "optional": true, + "properties": { + "size": { + "description": "The size of the icon. The returned icon will be square with dimensions size * size pixels. The default size for the icon is 32x32 pixels.", + "optional": true, + "minimum": 1, + "maximum": 127, + "type": "integer" + } + }, + "type": "object" + }, + { + "name": "callback", + "parameters": [ + { + "name": "iconURL", + "optional": true, + "type": "string" + } + ], + "type": "function" + } + ] + }, + { + "name": "open", + "type": "function", + "async": "callback", + "description": "Open the downloaded file.", + "permissions": ["downloads.open"], + "parameters": [ + { + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "show", + "type": "function", + "description": "Show the downloaded file in its folder in a file manager.", + "async": "callback", + "parameters": [ + { + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "name": "success", + "type": "boolean" + } + ] + } + ] + }, + { + "name": "showDefaultFolder", + "type": "function", + "parameters": [] + }, + { + "name": "erase", + "type": "function", + "async": "callback", + "description": "Erase matching DownloadItems from history", + "parameters": [ + { + "name": "query", + "$ref": "DownloadQuery" + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "items": { + "type": "integer" + }, + "name": "erasedIds", + "type": "array" + } + ] + } + ] + }, + { + "name": "removeFile", + "async": "callback", + "type": "function", + "parameters": [ + { + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ ] + } + ] + }, + { + "description": "Prompt the user to either accept or cancel a dangerous download. acceptDanger() does not automatically accept dangerous downloads.", + "name": "acceptDanger", + "unsupported": true, + "parameters": [ + { + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ ] + } + ], + "type": "function" + }, + { + "description": "Initiate dragging the file to another application.", + "name": "drag", + "unsupported": true, + "parameters": [ + { + "name": "downloadId", + "type": "integer" + } + ], + "type": "function" + }, + { + "name": "setShelfEnabled", + "type": "function", + "unsupported": true, + "parameters": [ + { + "name": "enabled", + "type": "boolean" + } + ] + } + ], + "events": [ + { + "description": "This event fires with the DownloadItem object when a download begins.", + "name": "onCreated", + "parameters": [ + { + "$ref": "DownloadItem", + "name": "downloadItem" + } + ], + "type": "function" + }, + { + "description": "Fires with the downloadId when a download is erased from history.", + "name": "onErased", + "parameters": [ + { + "name": "downloadId", + "description": "The id of the DownloadItem that was erased.", + "type": "integer" + } + ], + "type": "function" + }, + { + "name": "onChanged", + "description": "When any of a DownloadItem's properties except bytesReceived changes, this event fires with the downloadId and an object containing the properties that changed.", + "parameters": [ + { + "name": "downloadDelta", + "type": "object", + "properties": { + "id": { + "description": "The id of the DownloadItem that changed.", + "type": "integer" + }, + "url": { + "description": "Describes a change in a DownloadItem's url.", + "optional": true, + "$ref": "StringDelta" + }, + "filename": { + "description": "Describes a change in a DownloadItem's filename.", + "optional": true, + "$ref": "StringDelta" + }, + "danger": { + "description": "Describes a change in a DownloadItem's danger.", + "optional": true, + "$ref": "StringDelta" + }, + "mime": { + "description": "Describes a change in a DownloadItem's mime.", + "optional": true, + "$ref": "StringDelta" + }, + "startTime": { + "description": "Describes a change in a DownloadItem's startTime.", + "optional": true, + "$ref": "StringDelta" + }, + "endTime": { + "description": "Describes a change in a DownloadItem's endTime.", + "optional": true, + "$ref": "StringDelta" + }, + "state": { + "description": "Describes a change in a DownloadItem's state.", + "optional": true, + "$ref": "StringDelta" + }, + "canResume": { + "optional": true, + "$ref": "BooleanDelta" + }, + "paused": { + "description": "Describes a change in a DownloadItem's paused.", + "optional": true, + "$ref": "BooleanDelta" + }, + "error": { + "description": "Describes a change in a DownloadItem's error.", + "optional": true, + "$ref": "StringDelta" + }, + "totalBytes": { + "description": "Describes a change in a DownloadItem's totalBytes.", + "optional": true, + "$ref": "DoubleDelta" + }, + "fileSize": { + "description": "Describes a change in a DownloadItem's fileSize.", + "optional": true, + "$ref": "DoubleDelta" + }, + "exists": { + "optional": true, + "$ref": "BooleanDelta" + } + } + } + ], + "type": "function" + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/events.json b/toolkit/components/extensions/schemas/events.json new file mode 100644 index 0000000000..ea3cbb5d29 --- /dev/null +++ b/toolkit/components/extensions/schemas/events.json @@ -0,0 +1,322 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "events", + "description": "The chrome.events namespace contains common types used by APIs dispatching events to notify you when something interesting happens.", + "types": [ + { + "id": "Rule", + "type": "object", + "description": "Description of a declarative rule for handling events.", + "properties": { + "id": { + "type": "string", + "optional": true, + "description": "Optional identifier that allows referencing this rule." + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "optional": true, + "description": "Tags can be used to annotate rules and perform operations on sets of rules." + }, + "conditions": { + "type": "array", + "items": {"type": "any"}, + "description": "List of conditions that can trigger the actions." + }, + "actions": { + "type": "array", + "items": {"type": "any"}, + "description": "List of actions that are triggered if one of the condtions is fulfilled." + }, + "priority": { + "type": "integer", + "optional": true, + "description": "Optional priority of this rule. Defaults to 100." + } + } + }, + { + "id": "Event", + "type": "object", + "description": "An object which allows the addition and removal of listeners for a Chrome event.", + "functions": [ + { + "name": "addListener", + "type": "function", + "description": "Registers an event listener callback to an event.", + "parameters": [ + { + "name": "callback", + "type": "function", + "description": "Called when an event occurs. The parameters of this function depend on the type of event." + } + ] + }, + { + "name": "removeListener", + "type": "function", + "description": "Deregisters an event listener callback from an event.", + "parameters": [ + { + "name": "callback", + "type": "function", + "description": "Listener that shall be unregistered." + } + ] + }, + { + "name": "hasListener", + "type": "function", + "parameters": [ + { + "name": "callback", + "type": "function", + "description": "Listener whose registration status shall be tested." + } + ], + "returns": { + "type": "boolean", + "description": "True if callback is registered to the event." + } + }, + { + "name": "hasListeners", + "type": "function", + "parameters": [], + "returns": { + "type": "boolean", + "description": "True if any event listeners are registered to the event." + } + }, + { + "name": "addRules", + "unsupported": true, + "type": "function", + "description": "Registers rules to handle events.", + "parameters": [ + { + "name": "eventName", + "type": "string", + "description": "Name of the event this function affects." + }, + { + "name": "webViewInstanceId", + "type": "integer", + "description": "If provided, this is an integer that uniquely identfies the associated with this function call." + }, + { + "name": "rules", + "type": "array", + "items": {"$ref": "Rule"}, + "description": "Rules to be registered. These do not replace previously registered rules." + }, + { + "name": "callback", + "optional": true, + "type": "function", + "parameters": [ + { + "name": "rules", + "type": "array", + "items": {"$ref": "Rule"}, + "description": "Rules that were registered, the optional parameters are filled with values." + } + ], + "description": "Called with registered rules." + } + ] + }, + { + "name": "getRules", + "unsupported": true, + "type": "function", + "description": "Returns currently registered rules.", + "parameters": [ + { + "name": "eventName", + "type": "string", + "description": "Name of the event this function affects." + }, + { + "name": "webViewInstanceId", + "type": "integer", + "description": "If provided, this is an integer that uniquely identfies the associated with this function call." + }, + { + "name": "ruleIdentifiers", + "optional": true, + "type": "array", + "items": {"type": "string"}, + "description": "If an array is passed, only rules with identifiers contained in this array are returned." + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "rules", + "type": "array", + "items": {"$ref": "Rule"}, + "description": "Rules that were registered, the optional parameters are filled with values." + } + ], + "description": "Called with registered rules." + } + ] + }, + { + "name": "removeRules", + "unsupported": true, + "type": "function", + "description": "Unregisters currently registered rules.", + "parameters": [ + { + "name": "eventName", + "type": "string", + "description": "Name of the event this function affects." + }, + { + "name": "webViewInstanceId", + "type": "integer", + "description": "If provided, this is an integer that uniquely identfies the associated with this function call." + }, + { + "name": "ruleIdentifiers", + "optional": true, + "type": "array", + "items": {"type": "string"}, + "description": "If an array is passed, only rules with identifiers contained in this array are unregistered." + }, + { + "name": "callback", + "optional": true, + "type": "function", + "parameters": [], + "description": "Called when rules were unregistered." + } + ] + } + ] + }, + { + "id": "UrlFilter", + "type": "object", + "description": "Filters URLs for various criteria. See event filtering. All criteria are case sensitive.", + "properties": { + "hostContains": { + "type": "string", + "description": "Matches if the host name of the URL contains a specified string. To test whether a host name component has a prefix 'foo', use hostContains: '.foo'. This matches 'www.foobar.com' and 'foo.com', because an implicit dot is added at the beginning of the host name. Similarly, hostContains can be used to match against component suffix ('foo.') and to exactly match against components ('.foo.'). Suffix- and exact-matching for the last components need to be done separately using hostSuffix, because no implicit dot is added at the end of the host name.", + "optional": true + }, + "hostEquals": { + "type": "string", + "description": "Matches if the host name of the URL is equal to a specified string.", + "optional": true + }, + "hostPrefix": { + "type": "string", + "description": "Matches if the host name of the URL starts with a specified string.", + "optional": true + }, + "hostSuffix": { + "type": "string", + "description": "Matches if the host name of the URL ends with a specified string.", + "optional": true + }, + "pathContains": { + "type": "string", + "description": "Matches if the path segment of the URL contains a specified string.", + "optional": true + }, + "pathEquals": { + "type": "string", + "description": "Matches if the path segment of the URL is equal to a specified string.", + "optional": true + }, + "pathPrefix": { + "type": "string", + "description": "Matches if the path segment of the URL starts with a specified string.", + "optional": true + }, + "pathSuffix": { + "type": "string", + "description": "Matches if the path segment of the URL ends with a specified string.", + "optional": true + }, + "queryContains": { + "type": "string", + "description": "Matches if the query segment of the URL contains a specified string.", + "optional": true + }, + "queryEquals": { + "type": "string", + "description": "Matches if the query segment of the URL is equal to a specified string.", + "optional": true + }, + "queryPrefix": { + "type": "string", + "description": "Matches if the query segment of the URL starts with a specified string.", + "optional": true + }, + "querySuffix": { + "type": "string", + "description": "Matches if the query segment of the URL ends with a specified string.", + "optional": true + }, + "urlContains": { + "type": "string", + "description": "Matches if the URL (without fragment identifier) contains a specified string. Port numbers are stripped from the URL if they match the default port number.", + "optional": true + }, + "urlEquals": { + "type": "string", + "description": "Matches if the URL (without fragment identifier) is equal to a specified string. Port numbers are stripped from the URL if they match the default port number.", + "optional": true + }, + "urlMatches": { + "type": "string", + "description": "Matches if the URL (without fragment identifier) matches a specified regular expression. Port numbers are stripped from the URL if they match the default port number. The regular expressions use the RE2 syntax.", + "optional": true + }, + "originAndPathMatches": { + "type": "string", + "description": "Matches if the URL without query segment and fragment identifier matches a specified regular expression. Port numbers are stripped from the URL if they match the default port number. The regular expressions use the RE2 syntax.", + "optional": true + }, + "urlPrefix": { + "type": "string", + "description": "Matches if the URL (without fragment identifier) starts with a specified string. Port numbers are stripped from the URL if they match the default port number.", + "optional": true + }, + "urlSuffix": { + "type": "string", + "description": "Matches if the URL (without fragment identifier) ends with a specified string. Port numbers are stripped from the URL if they match the default port number.", + "optional": true + }, + "schemes": { + "type": "array", + "description": "Matches if the scheme of the URL is equal to any of the schemes specified in the array.", + "optional": true, + "items": { "type": "string" } + }, + "ports": { + "type": "array", + "description": "Matches if the port of the URL is contained in any of the specified port lists. For example [80, 443, [1000, 1200]] matches all requests on port 80, 443 and in the range 1000-1200.", + "optional": true, + "items": { + "choices": [ + {"type": "integer", "description": "A specific port."}, + {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "integer"}, "description": "A pair of integers identiying the start and end (both inclusive) of a port range."} + ] + } + } + } + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/experiments.json b/toolkit/components/extensions/schemas/experiments.json new file mode 100644 index 0000000000..c687173a94 --- /dev/null +++ b/toolkit/components/extensions/schemas/experiments.json @@ -0,0 +1,16 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "Permission", + "choices": [ + { + "type": "string", + "pattern": "^experiments(\\.\\w+)+$" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/extension.json b/toolkit/components/extensions/schemas/extension.json new file mode 100644 index 0000000000..5e3ba4dfde --- /dev/null +++ b/toolkit/components/extensions/schemas/extension.json @@ -0,0 +1,183 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "extension", + "allowedContexts": ["content", "devtools"], + "description": "The browser.extension API has utilities that can be used by any extension page. It includes support for exchanging messages between an extension and its content scripts or between extensions, as described in detail in $(topic:messaging)[Message Passing].", + "properties": { + "lastError": { + "type": "object", + "optional": true, + "allowedContexts": ["content", "devtools"], + "description": "Set for the lifetime of a callback if an ansychronous extension api has resulted in an error. If no error has occured lastError will be undefined.", + "properties": { + "message": { "type": "string", "description": "Description of the error that has taken place." } + }, + "additionalProperties": { + "type": "any" + } + }, + "inIncognitoContext": { + "type": "boolean", + "optional": true, + "allowedContexts": ["content", "devtools"], + "description": "True for content scripts running inside incognito tabs, and for extension pages running inside an incognito process. The latter only applies to extensions with 'split' incognito_behavior." + } + }, + "types": [ + { + "id": "ViewType", + "type": "string", + "enum": ["tab", "popup", "sidebar"], + "description": "The type of extension view." + } + ], + "functions": [ + { + "name": "getURL", + "type": "function", + "allowedContexts": ["content", "devtools"], + "description": "Converts a relative path within an extension install directory to a fully-qualified URL.", + "parameters": [ + { + "type": "string", + "name": "path", + "description": "A path to a resource within an extension expressed relative to its install directory." + } + ], + "returns": { + "type": "string", + "description": "The fully-qualified URL to the resource." + } + }, + { + "name": "getViews", + "type": "function", + "description": "Returns an array of the JavaScript 'window' objects for each of the pages running inside the current extension.", + "parameters": [ + { + "type": "object", + "name": "fetchProperties", + "optional": true, + "properties": { + "type": { + "$ref": "ViewType", + "optional": true, + "description": "The type of view to get. If omitted, returns all views (including background pages and tabs). Valid values: 'tab', 'popup', 'sidebar'." + }, + "windowId": { + "type": "integer", + "optional": true, + "description": "The window to restrict the search to. If omitted, returns all views." + }, + "tabId": { + "type": "integer", + "optional":true, + "description": "Find a view according to a tab id. If this field is omitted, returns all views." + } + } + } + ], + "returns": { + "type": "array", + "description": "Array of global objects", + "items": { + "name": "viewGlobals", + "type": "object", + "isInstanceOf": "Window", + "additionalProperties": { "type": "any" } + } + } + }, + { + "name": "getBackgroundPage", + "type": "function", + "description": "Returns the JavaScript 'window' object for the background page running inside the current extension. Returns null if the extension has no background page.", + "parameters": [], + "returns": { + "type": "object", + "optional": true, + "name": "backgroundPageGlobal", + "isInstanceOf": "Window", + "additionalProperties": { "type": "any" } + } + }, + { + "name": "isAllowedIncognitoAccess", + "type": "function", + "description": "Retrieves the state of the extension's access to Incognito-mode (as determined by the user-controlled 'Allowed in Incognito' checkbox.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "isAllowedAccess", + "type": "boolean", + "description": "True if the extension has access to Incognito mode, false otherwise." + } + ] + } + ] + }, + { + "name": "isAllowedFileSchemeAccess", + "type": "function", + "description": "Retrieves the state of the extension's access to the 'file://' scheme (as determined by the user-controlled 'Allow access to File URLs' checkbox.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "isAllowedAccess", + "type": "boolean", + "description": "True if the extension can access the 'file://' scheme, false otherwise." + } + ] + } + ] + }, + { + "name": "setUpdateUrlData", + "unsupported": true, + "type": "function", + "description": "Sets the value of the ap CGI parameter used in the extension's update URL. This value is ignored for extensions that are hosted in the browser vendor's store.", + "parameters": [ + {"type": "string", "name": "data", "maxLength": 1024} + ] + } + ], + "events": [ + { + "name": "onRequest", + "unsupported": true, + "deprecated": "Please use $(ref:runtime.onMessage).", + "type": "function", + "description": "Fired when a request is sent from either an extension process or a content script.", + "parameters": [ + {"name": "request", "type": "any", "optional": true, "description": "The request sent by the calling script."}, + {"name": "sender", "$ref": "runtime.MessageSender" }, + {"name": "sendResponse", "type": "function", "description": "Function to call (at most once) when you have a response. The argument should be any JSON-ifiable object, or undefined if there is no response. If you have more than one onRequest listener in the same document, then only one may send a response." } + ] + }, + { + "name": "onRequestExternal", + "unsupported": true, + "deprecated": "Please use $(ref:runtime.onMessageExternal).", + "type": "function", + "description": "Fired when a request is sent from another extension.", + "parameters": [ + {"name": "request", "type": "any", "optional": true, "description": "The request sent by the calling script."}, + {"name": "sender", "$ref": "runtime.MessageSender" }, + {"name": "sendResponse", "type": "function", "description": "Function to call when you have a response. The argument should be any JSON-ifiable object, or undefined if there is no response." } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/extension_protocol_handlers.json b/toolkit/components/extensions/schemas/extension_protocol_handlers.json new file mode 100644 index 0000000000..639ea730ce --- /dev/null +++ b/toolkit/components/extensions/schemas/extension_protocol_handlers.json @@ -0,0 +1,51 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "id": "ProtocolHandler", + "type": "object", + "description": "Represents a protocol handler definition.", + "properties": { + "name": { + "description": "A user-readable title string for the protocol handler. This will be displayed to the user in interface objects as needed.", + "type": "string" + }, + "protocol": { + "description": "The protocol the site wishes to handle, specified as a string. For example, you can register to handle SMS text message links by registering to handle the \"sms\" scheme.", + "choices": [{ + "type": "string", + "enum": [ + "bitcoin", "geo", "im", "irc", "ircs", "magnet", "mailto", + "mms", "news", "nntp", "sip", "sms", "smsto", "ssh", "tel", + "urn", "webcal", "wtai", "xmpp" + ] + }, { + "type": "string", + "pattern": "^(ext|web)\\+[a-z0-9.+-]+$" + }] + }, + "uriTemplate": { + "description": "The URL of the handler, as a string. This string should include \"%s\" as a placeholder which will be replaced with the escaped URL of the document to be handled. This URL might be a true URL, or it could be a phone number, email address, or so forth.", + "preprocess": "localize", + "choices": [ + {"$ref": "ExtensionURL"}, + {"$ref": "HttpURL"} + ] + } + } + }, + { + "$extend": "WebExtensionManifest", + "properties": { + "protocol_handlers": { + "description": "A list of protocol handler definitions.", + "optional": true, + "type": "array", + "items": {"$ref": "ProtocolHandler"} + } + } + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/extension_types.json b/toolkit/components/extensions/schemas/extension_types.json new file mode 100644 index 0000000000..d48b980673 --- /dev/null +++ b/toolkit/components/extensions/schemas/extension_types.json @@ -0,0 +1,95 @@ +// Copyright 2014 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "extensionTypes", + "description": "The browser.extensionTypes API contains type declarations for WebExtensions.", + "types": [ + { + "id": "ImageFormat", + "type": "string", + "enum": ["jpeg", "png"], + "description": "The format of an image." + }, + { + "id": "ImageDetails", + "type": "object", + "description": "Details about the format and quality of an image.", + "properties": { + "format": { + "$ref": "ImageFormat", + "optional": true, + "description": "The format of the resulting image. Default is \"jpeg\"." + }, + "quality": { + "type": "integer", + "optional": true, + "minimum": 0, + "maximum": 100, + "description": "When format is \"jpeg\", controls the quality of the resulting image. This value is ignored for PNG images. As quality is decreased, the resulting image will have more visual artifacts, and the number of bytes needed to store it will decrease." + } + } + }, + { + "id": "RunAt", + "type": "string", + "enum": ["document_start", "document_end", "document_idle"], + "description": "The soonest that the JavaScript or CSS will be injected into the tab." + }, + { + "id": "CSSOrigin", + "type": "string", + "enum": ["user", "author"], + "description": "The origin of the CSS to inject, this affects the cascading order (priority) of the stylesheet." + }, + { + "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.

Warning:
Be careful using the code parameter. Incorrect use of it may open your extension to cross site scripting attacks."}, + "file": {"type": "string", "optional": true, "description": "JavaScript or CSS file to inject."}, + "allFrames": {"type": "boolean", "optional": true, "description": "If allFrames is true, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's false 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 false."}, + "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 allFrames." + }, + "runAt": { + "$ref": "RunAt", + "optional": true, + "default": "document_idle", + "description": "The soonest that the JavaScript or CSS will be injected into the tab. Defaults to \"document_idle\"." + }, + "cssOrigin": { + "$ref": "CSSOrigin", + "optional": true, + "description": "The css origin of the stylesheet to inject. Defaults to \"author\"." + } + } + }, + { + "id": "Date", + "choices": [ + { + "type": "string", + "format": "date" + }, + { + "type": "integer", + "minimum": 0 + }, + { + "type": "object", + "isInstanceOf": "Date", + "additionalProperties": { "type": "any" } + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/i18n.json b/toolkit/components/extensions/schemas/i18n.json new file mode 100644 index 0000000000..bc3b233a45 --- /dev/null +++ b/toolkit/components/extensions/schemas/i18n.json @@ -0,0 +1,132 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "WebExtensionManifest", + "properties": { + "default_locale": { + "type": "string", + "optional": "true" + } + } + } + ] + }, + { + "namespace": "i18n", + "allowedContexts": ["content", "devtools"], + "defaultContexts": ["content", "devtools"], + "description": "Use the browser.i18n infrastructure to implement internationalization across your whole app or extension.", + "types": [ + { + "id": "LanguageCode", + "type": "string", + "description": "An ISO language code such as en or fr. For a complete list of languages supported by this method, see kLanguageInfoTable. For an unknown language, und will be returned, which means that [percentage] of the text is unknown to CLD" + } + ], + "functions": [ + { + "name": "getAcceptLanguages", + "type": "function", + "description": "Gets the accept-languages of the browser. This is different from the locale used by the browser; to get the locale, use $(ref:i18n.getUILanguage).", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + {"name": "languages", "type": "array", "items": {"$ref": "LanguageCode"}, "description": "Array of LanguageCode"} + ] + } + ] + }, + { + "name": "getMessage", + "type": "function", + "description": "Gets the localized string for the specified message. If the message is missing, this method returns an empty string (''). If the format of the getMessage() call is wrong — for example, messageName is not a string or the substitutions array has more than 9 elements — this method returns undefined.", + "parameters": [ + { + "type": "string", + "name": "messageName", + "description": "The name of the message, as specified in the $(topic:i18n-messages)[messages.json] file." + }, + { + "type": "any", + "name": "substitutions", + "optional": true, + "description": "Substitution strings, if the message requires any." + } + ], + "returns": { + "type": "string", + "description": "Message localized for current locale." + } + }, + { + "name": "getUILanguage", + "type": "function", + "description": "Gets the browser UI language of the browser. This is different from $(ref:i18n.getAcceptLanguages) which returns the preferred user languages.", + "parameters": [], + "returns": { + "type": "string", + "description": "The browser UI language code such as en-US or fr-FR." + } + }, + { + "name": "detectLanguage", + "type": "function", + "description": "Detects the language of the provided text using CLD.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "text", + "description": "User input string to be translated." + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "type": "object", + "name": "result", + "description": "LanguageDetectionResult object that holds detected langugae reliability and array of DetectedLanguage", + "properties": { + "isReliable": { "type": "boolean", "description": "CLD detected language reliability" }, + "languages": + { + "type": "array", + "description": "array of detectedLanguage", + "items": + { + "type": "object", + "description": "DetectedLanguage object that holds detected ISO language code and its percentage in the input string", + "properties": + { + "language": + { + "$ref": "LanguageCode" + }, + "percentage": + { + "type": "integer", + "description": "The percentage of the detected language" + } + } + } + } + } + } + ] + } + ] + } + ], + "events": [] + } +] diff --git a/toolkit/components/extensions/schemas/identity.json b/toolkit/components/extensions/schemas/identity.json new file mode 100644 index 0000000000..1709bff56a --- /dev/null +++ b/toolkit/components/extensions/schemas/identity.json @@ -0,0 +1,219 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "Permission", + "choices": [{ + "type": "string", + "enum": [ + "identity" + ] + }] + } + ] + }, + { + "namespace": "identity", + "description": "Use the chrome.identity API to get OAuth2 access tokens. ", + "permissions": ["identity"], + "types": [ + { + "id": "AccountInfo", + "type": "object", + "description": "An object encapsulating an OAuth account id.", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for the account. This ID will not change for the lifetime of the account. " + } + } + } + ], + "functions": [ + { + "name": "getAccounts", + "type": "function", + "unsupported": true, + "description": "Retrieves a list of AccountInfo objects describing the accounts present on the profile.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "results", + "type": "array", + "items": { + "$ref": "AccountInfo" + } + } + ] + } + ] + }, + { + "name": "getAuthToken", + "type": "function", + "unsupported": true, + "description": "Gets an OAuth2 access token using the client ID and scopes specified in the oauth2 section of manifest.json.", + "async": "callback", + "parameters": [ + { + "name": "details", + "optional": true, + "type": "object", + "properties": { + "interactive": { + "optional": true, + "type": "boolean" + }, + "account": { + "optional": true, + "$ref": "AccountInfo" + }, + "scopes": { + "optional": true, + "type": "array", + "items": { + "type": "string" + } + } + } + }, + { + "name": "callback", + "optional": true, + "type": "function", + "parameters": [ + { + "name": "results", + "type": "array", + "items": { + "$ref": "AccountInfo" + } + } + ] + } + ] + }, + { + "name": "getProfileUserInfo", + "type": "function", + "unsupported": true, + "description": "Retrieves email address and obfuscated gaia id of the user signed into a profile.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "userinfo", + "type": "object", + "properties": { + "email": {"type": "string"}, + "id": { "type": "string" } + } + } + ] + } + ] + }, + { + "name": "removeCachedAuthToken", + "type": "function", + "unsupported": true, + "description": "Removes an OAuth2 access token from the Identity API's token cache.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "token": {"type": "string"} + } + }, + { + "name": "callback", + "optional": true, + "type": "function", + "parameters": [ + { + "name": "userinfo", + "type": "object", + "properties": { + "email": {"type": "string"}, + "id": { "type": "string" } + } + } + ] + } + ] + }, + { + "name": "launchWebAuthFlow", + "type": "function", + "description": "Starts an auth flow at the specified URL.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "url": {"type": "string"}, + "interactive": {"type": "boolean", "optional": true} + } + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": " responseUrl", + "type": "string", + "optional": true + } + ] + } + ] + }, + { + "name": "getRedirectURL", + "type": "function", + "description": "Generates a redirect URL to be used in |launchWebAuthFlow|.", + "parameters": [ + { + "name": " path", + "type": "string", + "default": "", + "optional": true, + "description": "The path appended to the end of the generated URL. " + } + ], + "returns": { + "string": "path" + } + } + ], + "events": [ + { + "name": "onSignInChanged", + "unsupported": true, + "type": "function", + "description": "Fired when signin state changes for an account on the user's profile.", + "parameters": [ + { + "name": "account", + "$ref": "AccountInfo" + }, + { + "name": "signedIn", + "type": "boolean" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/idle.json b/toolkit/components/extensions/schemas/idle.json new file mode 100644 index 0000000000..e0b3b951ee --- /dev/null +++ b/toolkit/components/extensions/schemas/idle.json @@ -0,0 +1,70 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "idle", + "description": "Use the browser.idle API to detect when the machine's idle state changes.", + "permissions": ["idle"], + "types": [ + { + "id": "IdleState", + "type": "string", + "enum": ["active", "idle"] + } + ], + "functions": [ + { + "name": "queryState", + "type": "function", + "description": "Returns \"idle\" if the user has not generated any input for a specified number of seconds, or \"active\" otherwise.", + "async": "callback", + "parameters": [ + { + "name": "detectionIntervalInSeconds", + "type": "integer", + "minimum": 15, + "description": "The system is considered idle if detectionIntervalInSeconds seconds have elapsed since the last user input detected." + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "newState", + "$ref": "IdleState" + } + ] + } + ] + }, + { + "name": "setDetectionInterval", + "type": "function", + "description": "Sets the interval, in seconds, used to determine when the system is in an idle state for onStateChanged events. The default interval is 60 seconds.", + "parameters": [ + { + "name": "intervalInSeconds", + "type": "integer", + "minimum": 15, + "description": "Threshold, in seconds, used to determine when the system is in an idle state." + } + ] + } + ], + "events": [ + { + "name": "onStateChanged", + "type": "function", + "description": "Fired when the system changes to an active or idle state. The event fires with \"idle\" if the the user has not generated any input for a specified number of seconds, and \"active\" when the user generates input on an idle system.", + "parameters": [ + { + "name": "newState", + "$ref": "IdleState" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/jar.mn b/toolkit/components/extensions/schemas/jar.mn new file mode 100644 index 0000000000..8b6809e507 --- /dev/null +++ b/toolkit/components/extensions/schemas/jar.mn @@ -0,0 +1,35 @@ +# 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/. + +toolkit.jar: +% content extensions %content/extensions/ + content/extensions/schemas/alarms.json + content/extensions/schemas/contextual_identities.json + content/extensions/schemas/cookies.json + content/extensions/schemas/downloads.json + content/extensions/schemas/events.json + content/extensions/schemas/experiments.json + content/extensions/schemas/extension.json + content/extensions/schemas/extension_types.json + content/extensions/schemas/extension_protocol_handlers.json + content/extensions/schemas/i18n.json +#ifndef ANDROID + content/extensions/schemas/identity.json +#endif + content/extensions/schemas/idle.json + content/extensions/schemas/management.json + content/extensions/schemas/manifest.json + content/extensions/schemas/native_host_manifest.json + content/extensions/schemas/notifications.json + content/extensions/schemas/permissions.json + content/extensions/schemas/proxy.json + content/extensions/schemas/privacy.json + content/extensions/schemas/runtime.json + content/extensions/schemas/storage.json + content/extensions/schemas/test.json + content/extensions/schemas/theme.json + content/extensions/schemas/top_sites.json + content/extensions/schemas/types.json + content/extensions/schemas/web_navigation.json + content/extensions/schemas/web_request.json diff --git a/toolkit/components/extensions/schemas/management.json b/toolkit/components/extensions/schemas/management.json new file mode 100644 index 0000000000..b4339e3bc1 --- /dev/null +++ b/toolkit/components/extensions/schemas/management.json @@ -0,0 +1,327 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "Permission", + "choices": [{ + "type": "string", + "enum": [ + "management" + ] + }] + } + ] + }, + { + "namespace":"management", + "description": "The browser.management API provides ways to manage the list of extensions that are installed and running.", + "types": [ + { + "id": "IconInfo", + "description": "Information about an icon belonging to an extension.", + "type": "object", + "properties": { + "size": { + "type": "integer", + "description": "A number representing the width and height of the icon. Likely values include (but are not limited to) 128, 48, 24, and 16." + }, + "url": { + "type": "string", + "description": "The URL for this icon image. To display a grayscale version of the icon (to indicate that an extension is disabled, for example), append ?grayscale=true to the URL." + } + } + }, + { + "id": "ExtensionDisabledReason", + "description": "A reason the item is disabled.", + "type": "string", + "enum": ["unknown", "permissions_increase"] + }, + { + "id": "ExtensionType", + "description": "The type of this extension. Will always be 'extension'.", + "type": "string", + "enum": ["extension"] + }, + { + "id": "ExtensionInstallType", + "description": "How the extension was installed. One of
development: The extension was loaded unpacked in developer mode,
normal: The extension was installed normally via an .xpi file,
sideload: The extension was installed by other software on the machine,
other: The extension was installed by other means.", + "type": "string", + "enum": ["development", "normal", "sideload", "other"] + }, + { + "id": "ExtensionInfo", + "description": "Information about an installed extension.", + "type": "object", + "properties": { + "id": { + "description": "The extension's unique identifier.", + "type": "string" + }, + "name": { + "description": "The name of this extension.", + "type": "string" + }, + "shortName": { + "description": "A short version of the name of this extension.", + "type": "string", + "optional": true + }, + "description": { + "description": "The description of this extension.", + "type": "string" + }, + "version": { + "description": "The version of this extension.", + "type": "string" + }, + "versionName": { + "description": "The version name of this extension if the manifest specified one.", + "type": "string", + "optional": true + }, + "mayDisable": { + "description": "Whether this extension can be disabled or uninstalled by the user.", + "type": "boolean" + }, + "enabled": { + "description": "Whether it is currently enabled or disabled.", + "type": "boolean" + }, + "disabledReason": { + "description": "A reason the item is disabled.", + "$ref": "ExtensionDisabledReason", + "optional": true + }, + "type": { + "description": "The type of this extension. Will always return 'extension'.", + "$ref": "ExtensionType" + }, + "homepageUrl": { + "description": "The URL of the homepage of this extension.", + "type": "string", + "optional": true + }, + "updateUrl": { + "description": "The update URL of this extension.", + "type": "string", + "optional": true + }, + "optionsUrl": { + "description": "The url for the item's options page, if it has one.", + "type": "string" + }, + "icons": { + "description": "A list of icon information. Note that this just reflects what was declared in the manifest, and the actual image at that url may be larger or smaller than what was declared, so you might consider using explicit width and height attributes on img tags referencing these images. See the manifest documentation on icons for more details.", + "type": "array", + "optional": true, + "items": { + "$ref": "IconInfo" + } + }, + "permissions": { + "description": "Returns a list of API based permissions.", + "type": "array", + "optional": true, + "items" : { + "type": "string" + } + }, + "hostPermissions": { + "description": "Returns a list of host based permissions.", + "type": "array", + "optional": true, + "items" : { + "type": "string" + } + }, + "installType": { + "description": "How the extension was installed.", + "$ref": "ExtensionInstallType" + } + } + } + ], + "functions": [ + { + "name": "getAll", + "type": "function", + "permissions": ["management"], + "description": "Returns a list of information about installed extensions.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "type": "array", + "name": "result", + "items": { + "$ref": "ExtensionInfo" + } + } + ] + } + ] + }, + { + "name": "get", + "type": "function", + "permissions": ["management"], + "unsupported": true, + "description": "Returns information about the installed extension that has the given ID.", + "async": "callback", + "parameters": [ + { + "name": "id", + "$ref": "manifest.ExtensionID", + "description": "The ID from an item of $(ref:management.ExtensionInfo)." + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "name": "result", + "$ref": "ExtensionInfo" + } + ] + } + ] + }, + { + "name": "getSelf", + "type": "function", + "description": "Returns information about the calling extension. Note: This function can be used without requesting the 'management' permission in the manifest.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "result", + "$ref": "ExtensionInfo" + } + ] + } + ] + }, + { + "name": "uninstallSelf", + "type": "function", + "description": "Uninstalls the calling extension. Note: This function can be used without requesting the 'management' permission in the manifest.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "options", + "optional": true, + "properties": { + "showConfirmDialog": { + "type": "boolean", + "optional": true, + "description": "Whether or not a confirm-uninstall dialog should prompt the user. Defaults to false." + }, + "dialogMessage": { + "type": "string", + "optional": true, + "description": "The message to display to a user when being asked to confirm removal of the extension." + } + } + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "setEnabled", + "type": "function", + "permissions": ["management"], + "description": "Enables or disables the given add-on.", + "async": "callback", + "parameters": [ + { + "name": "id", + "type": "string", + "description": "ID of the add-on to enable/disable." + }, + { + "name": "enabled", + "type": "boolean", + "description": "Whether to enable or disable the add-on." + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [] + } + ] + } + ], + "events": [ + { + "name": "onDisabled", + "type": "function", + "permissions": ["management"], + "description": "Fired when an addon has been disabled.", + "parameters": [ + { + "name": "info", + "$ref": "ExtensionInfo" + } + ] + }, + { + "name": "onEnabled", + "type": "function", + "permissions": ["management"], + "description": "Fired when an addon has been enabled.", + "parameters": [ + { + "name": "info", + "$ref": "ExtensionInfo" + } + ] + }, + { + "name": "onInstalled", + "type": "function", + "permissions": ["management"], + "description": "Fired when an addon has been installed.", + "parameters": [ + { + "name": "info", + "$ref": "ExtensionInfo" + } + ] + }, + { + "name": "onUninstalled", + "type": "function", + "permissions": ["management"], + "description": "Fired when an addon has been uninstalled.", + "parameters": [ + { + "name": "info", + "$ref": "ExtensionInfo" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/manifest.json b/toolkit/components/extensions/schemas/manifest.json new file mode 100644 index 0000000000..d423572e79 --- /dev/null +++ b/toolkit/components/extensions/schemas/manifest.json @@ -0,0 +1,409 @@ +[ + { + "namespace": "manifest", + "permissions": [], + "types": [ + { + "id": "WebExtensionManifest", + "type": "object", + "description": "Represents a WebExtension manifest.json file", + "properties": { + "manifest_version": { + "type": "integer", + "minimum": 2, + "maximum": 2 + }, + + "minimum_chrome_version":{ + "type": "string", + "optional": true + }, + + "applications": { + "type": "object", + "optional": true, + "properties": { + "gecko": { + "$ref": "FirefoxSpecificProperties", + "optional": true + } + } + }, + + "browser_specific_settings": { + "type": "object", + "optional": true, + "properties": { + "gecko": { + "$ref": "FirefoxSpecificProperties", + "optional": true + } + } + }, + + "name": { + "type": "string", + "optional": false, + "preprocess": "localize" + }, + + "short_name": { + "type": "string", + "optional": true, + "preprocess": "localize" + }, + + "description": { + "type": "string", + "optional": true, + "preprocess": "localize" + }, + + "author": { + "type": "string", + "optional": true, + "preprocess": "localize", + "onError": "warn" + }, + + "version": { + "type": "string", + "optional": false + }, + + "homepage_url": { + "type": "string", + "format": "url", + "optional": true, + "preprocess": "localize" + }, + + "icons": { + "type": "object", + "optional": true, + "patternProperties": { + "^[1-9]\\d*$": { "type": "string" } + } + }, + + "incognito": { + "type": "string", + "enum": ["spanning"], + "optional": true, + "onError": "warn" + }, + + "background": { + "choices": [ + { + "type": "object", + "properties": { + "page": { "$ref": "ExtensionURL" }, + "persistent": { + "optional": true, + "$ref": "PersistentBackgroundProperty" + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + { + "type": "object", + "properties": { + "scripts": { + "type": "array", + "items": { "$ref": "ExtensionURL" } + }, + "persistent": { + "optional": true, + "$ref": "PersistentBackgroundProperty" + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + } + ], + "optional": true + }, + + "options_ui": { + "type": "object", + + "optional": true, + + "properties": { + "page": { "$ref": "ExtensionURL" }, + "browser_style": { + "type": "boolean", + "optional": true + }, + "chrome_style": { + "type": "boolean", + "optional": true + }, + "open_in_tab": { + "type": "boolean", + "optional": true + } + }, + + "additionalProperties": { + "type": "any", + "deprecated": "An unexpected property was found in the WebExtension manifest" + } + }, + + "content_scripts": { + "type": "array", + "optional": true, + "items": { "$ref": "ContentScript" } + }, + + "content_security_policy": { + "type": "string", + "optional": true, + "format": "contentSecurityPolicy", + "onError": "warn" + }, + + "permissions": { + "type": "array", + "default": [], + "items": { + "$ref": "Permission", + "onError": "warn" + }, + "optional": true + }, + + "optional_permissions": { + "type": "array", + "items": { + "choices": [ + { "$ref": "OptionalPermission" }, + { + "type": "string", + "deprecated": "Unknown optional permission ${value}" + } + ] + }, + "optional": true, + "default": [] + }, + + "web_accessible_resources": { + "type": "array", + "items": { "type": "string" }, + "optional": true + }, + + "developer": { + "type": "object", + "optional": true, + "properties": { + "name": { + "type": "string", + "optional": true, + "preprocess": "localize" + }, + "url": { + "type": "string", + "optional": true, + "preprocess": "localize" + } + } + } + + }, + + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + { + "id": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": [ + "clipboardRead", + "clipboardWrite", + "geolocation", + "idle", + "notifications" + ] + }, + { "$ref": "MatchPattern" } + ] + }, + { + "id": "Permission", + "choices": [ + { "$ref": "OptionalPermission" }, + { + "type": "string", + "enum": [ + "alarms", + "storage" + ] + } + ] + }, + { + "id": "HttpURL", + "type": "string", + "format": "url", + "pattern": "^https?://.*$" + }, + { + "id": "ExtensionURL", + "type": "string", + "format": "strictRelativeUrl" + }, + { + "id": "ExtensionID", + "choices": [ + { + "type": "string", + "pattern": "(?i)^\\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\}$" + }, + { + "type": "string", + "pattern": "(?i)^[a-z0-9-._]*@[a-z0-9-._]+$" + } + ] + }, + { + "id": "FirefoxSpecificProperties", + "type": "object", + "properties": { + "id": { + "$ref": "ExtensionID", + "optional": true + }, + + "update_url": { + "type": "string", + "format": "url", + "optional": true + }, + + "strict_min_version": { + "type": "string", + "optional": true + }, + + "strict_max_version": { + "type": "string", + "optional": true + } + } + }, + { + "id": "MatchPattern", + "choices": [ + { + "type": "string", + "enum": [""] + }, + { + "type": "string", + "pattern": "^(https?|wss?|file|ftp|\\*)://(\\*|\\*\\.[^*/]+|[^*/]+)/.*$" + }, + { + "type": "string", + "pattern": "^file:///.*$" + } + ] + }, + { + "id": "ContentScript", + "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. Based on InjectDetails, but using underscore rather than camel case naming conventions.", + "additionalProperties": { "$ref": "UnrecognizedProperty" }, + "properties": { + "matches": { + "type": "array", + "optional": false, + "minItems": 1, + "items": { "$ref": "MatchPattern" } + }, + "exclude_matches": { + "type": "array", + "optional": true, + "minItems": 1, + "items": { "$ref": "MatchPattern" } + }, + "include_globs": { + "type": "array", + "optional": true, + "items": { "type": "string" } + }, + "exclude_globs": { + "type": "array", + "optional": true, + "items": { "type": "string" } + }, + "css": { + "type": "array", + "optional": true, + "description": "The list of CSS files to inject", + "items": { "$ref": "ExtensionURL" } + }, + "js": { + "type": "array", + "optional": true, + "description": "The list of CSS files to inject", + "items": { "$ref": "ExtensionURL" } + }, + "all_frames": {"type": "boolean", "optional": true, "description": "If allFrames is true, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's false and is only injected into the top frame."}, + "match_about_blank": {"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 false."}, + "run_at": { + "$ref": "extensionTypes.RunAt", + "optional": true, + "default": "document_idle", + "description": "The soonest that the JavaScript or CSS will be injected into the tab. Defaults to \"document_idle\"." + } + } + }, + { + "id": "IconPath", + "choices": [ + { + "type": "object", + "patternProperties": { + "^[1-9]\\d*$": { "$ref": "ExtensionURL" } + }, + "additionalProperties": false + }, + { "$ref": "ExtensionURL" } + ] + }, + { + "id": "IconImageData", + "choices": [ + { + "type": "object", + "patternProperties": { + "^[1-9]\\d*$": { "$ref": "ImageData" } + }, + "additionalProperties": false + }, + { "$ref": "ImageData" } + ] + }, + { + "id": "ImageData", + "type": "object", + "isInstanceOf": "ImageData", + "postprocess": "convertImageDataToURL" + }, + { + "id": "UnrecognizedProperty", + "type": "any", + "deprecated": "An unexpected property was found in the WebExtension manifest." + }, + { + "id": "PersistentBackgroundProperty", + "type": "boolean", + "deprecated": "Event pages are not currently supported. This will run as a persistent background page." + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/moz.build b/toolkit/components/extensions/schemas/moz.build new file mode 100644 index 0000000000..aac3a838c4 --- /dev/null +++ b/toolkit/components/extensions/schemas/moz.build @@ -0,0 +1,7 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +JAR_MANIFESTS += ['jar.mn'] diff --git a/toolkit/components/extensions/schemas/native_host_manifest.json b/toolkit/components/extensions/schemas/native_host_manifest.json new file mode 100644 index 0000000000..4ad2ea7f16 --- /dev/null +++ b/toolkit/components/extensions/schemas/native_host_manifest.json @@ -0,0 +1,37 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "id": "NativeHostManifest", + "type": "object", + "description": "Represents a native host manifest file", + "properties": { + "name": { + "type": "string", + "pattern": "^\\w+(\\.\\w+)*$" + }, + "description": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "stdio" + ] + }, + "allowed_extensions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "manifest.ExtensionID" + } + } + } + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/notifications.json b/toolkit/components/extensions/schemas/notifications.json new file mode 100644 index 0000000000..12878e8c8e --- /dev/null +++ b/toolkit/components/extensions/schemas/notifications.json @@ -0,0 +1,416 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "notifications", + "permissions": ["notifications"], + "types": [ + { + "id": "TemplateType", + "type": "string", + "enum": [ + "basic", + "image", + "list", + "progress" + ] + }, + { + "id": "PermissionLevel", + "type": "string", + "enum": [ + "granted", + "denied" + ] + }, + { + "id": "NotificationItem", + "type": "object", + "properties": { + "title": { + "description": "Title of one item of a list notification.", + "type": "string" + }, + "message": { + "description": "Additional details about this item.", + "type": "string" + } + } + }, + { + "id": "CreateNotificationOptions", + "type": "object", + "properties": { + "type": { + "description": "Which type of notification to display.", + "$ref": "TemplateType" + }, + "iconUrl": { + "optional": true, + "description": "A URL to the sender's avatar, app icon, or a thumbnail for image notifications.", + "type": "string" + }, + "appIconMaskUrl": { + "optional": true, + "description": "A URL to the app icon mask.", + "type": "string" + }, + "title": { + "description": "Title of the notification (e.g. sender name for email).", + "type": "string" + }, + "message": { + "description": "Main notification content.", + "type": "string" + }, + "contextMessage": { + "optional": true, + "description": "Alternate notification content with a lower-weight font.", + "type": "string" + }, + "priority": { + "optional": true, + "description": "Priority ranges from -2 to 2. -2 is lowest priority. 2 is highest. Zero is default.", + "type": "integer", + "minimum": -2, + "maximum": 2 + }, + "eventTime": { + "optional": true, + "description": "A timestamp associated with the notification, in milliseconds past the epoch.", + "type": "number" + }, + "buttons": { + "unsupported": true, + "optional": true, + "description": "Text and icons for up to two notification action buttons.", + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "iconUrl": { + "optional": true, + "type": "string" + } + } + } + }, + "imageUrl": { + "optional": true, + "description": "A URL to the image thumbnail for image-type notifications.", + "type": "string" + }, + "items": { + "optional": true, + "description": "Items for multi-item notifications.", + "type": "array", + "items": { "$ref": "NotificationItem" } + }, + "progress": { + "optional": true, + "description": "Current progress ranges from 0 to 100.", + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "isClickable": { + "optional": true, + "description": "Whether to show UI indicating that the app will visibly respond to clicks on the body of a notification.", + "type": "boolean" + } + } + }, + { + "id": "UpdateNotificationOptions", + "type": "object", + "properties": { + "type": { + "optional": true, + "description": "Which type of notification to display.", + "$ref": "TemplateType" + }, + "iconUrl": { + "optional": true, + "description": "A URL to the sender's avatar, app icon, or a thumbnail for image notifications.", + "type": "string" + }, + "appIconMaskUrl": { + "optional": true, + "description": "A URL to the app icon mask.", + "type": "string" + }, + "title": { + "optional": true, + "description": "Title of the notification (e.g. sender name for email).", + "type": "string" + }, + "message": { + "optional": true, + "description": "Main notification content.", + "type": "string" + }, + "contextMessage": { + "optional": true, + "description": "Alternate notification content with a lower-weight font.", + "type": "string" + }, + "priority": { + "optional": true, + "description": "Priority ranges from -2 to 2. -2 is lowest priority. 2 is highest. Zero is default.", + "type": "integer", + "minimum": -2, + "maximum": 2 + }, + "eventTime": { + "optional": true, + "description": "A timestamp associated with the notification, in milliseconds past the epoch.", + "type": "number" + }, + "buttons": { + "unsupported": true, + "optional": true, + "description": "Text and icons for up to two notification action buttons.", + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "iconUrl": { + "optional": true, + "type": "string" + } + } + } + }, + "imageUrl": { + "optional": true, + "description": "A URL to the image thumbnail for image-type notifications.", + "type": "string" + }, + "items": { + "optional": true, + "description": "Items for multi-item notifications.", + "type": "array", + "items": { "$ref": "NotificationItem" } + }, + "progress": { + "optional": true, + "description": "Current progress ranges from 0 to 100.", + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "isClickable": { + "optional": true, + "description": "Whether to show UI indicating that the app will visibly respond to clicks on the body of a notification.", + "type": "boolean" + } + } + } + ], + "functions": [ + { + "name": "create", + "type": "function", + "description": "Creates and displays a notification.", + "async": "callback", + "parameters": [ + { + "optional": true, + "type": "string", + "name": "notificationId", + "description": "Identifier of the notification. If it is empty, this method generates an id. If it matches an existing notification, this method first clears that notification before proceeding with the create operation." + }, + { + "$ref": "CreateNotificationOptions", + "name": "options", + "description": "Contents of the notification." + }, + { + "optional": true, + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "notificationId", + "type": "string", + "description": "The notification id (either supplied or generated) that represents the created notification." + } + ] + } + ] + }, + { + "name": "update", + "unsupported": true, + "type": "function", + "description": "Updates an existing notification.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The id of the notification to be updated." + }, + { + "$ref": "UpdateNotificationOptions", + "name": "options", + "description": "Contents of the notification to update to." + }, + { + "optional": true, + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "wasUpdated", + "type": "boolean", + "description": "Indicates whether a matching notification existed." + } + ] + } + ] + }, + { + "name": "clear", + "type": "function", + "description": "Clears an existing notification.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The id of the notification to be updated." + }, + { + "optional": true, + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "wasCleared", + "type": "boolean", + "description": "Indicates whether a matching notification existed." + } + ] + } + ] + }, + { + "name": "getAll", + "type": "function", + "description": "Retrieves all the notifications.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "notifications", + "type": "object", + "description": "The set of notifications currently in the system." + } + ] + } + ] + }, + { + "name": "getPermissionLevel", + "unsupported": true, + "type": "function", + "description": "Retrieves whether the user has enabled notifications from this app or extension.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "level", + "$ref": "PermissionLevel", + "description": "The current permission level." + } + ] + } + ] + } + ], + "events": [ + { + "name": "onClosed", + "type": "function", + "description": "Fired when the notification closed, either by the system or by user action.", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The notificationId of the closed notification." + }, + { + "type": "boolean", + "name": "byUser", + "description": "True if the notification was closed by the user." + } + ] + }, + { + "name": "onClicked", + "type": "function", + "description": "Fired when the user clicked in a non-button area of the notification.", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The notificationId of the clicked notification." + } + ] + }, + { + "name": "onButtonClicked", + "type": "function", + "description": "Fired when the user pressed a button in the notification.", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The notificationId of the clicked notification." + }, + { + "type": "number", + "name": "buttonIndex", + "description": "The index of the button clicked by the user." + } + ] + }, + { + "name": "onPermissionLevelChanged", + "unsupported": true, + "type": "function", + "description": "Fired when the user changes the permission level.", + "parameters": [ + { + "$ref": "PermissionLevel", + "name": "level", + "description": "The new permission level." + } + ] + }, + { + "name": "onShowSettings", + "unsupported": true, + "type": "function", + "description": "Fired when the user clicked on a link for the app's notification settings.", + "parameters": [ + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/permissions.json b/toolkit/components/extensions/schemas/permissions.json new file mode 100644 index 0000000000..9a289ae9dc --- /dev/null +++ b/toolkit/components/extensions/schemas/permissions.json @@ -0,0 +1,153 @@ +[ + { + "namespace": "permissions", + "permissions": ["manifest:optional_permissions"], + "types": [ + { + "id": "Permissions", + "type": "object", + "properties": { + "permissions": { + "type": "array", + "items": { "$ref": "manifest.OptionalPermission" }, + "optional": true, + "default": [] + }, + "origins": { + "type": "array", + "items": { "$ref": "manifest.MatchPattern" }, + "optional": true, + "default": [] + } + } + }, + { + "id": "AnyPermissions", + "type": "object", + "properties": { + "permissions": { + "type": "array", + "items": { "$ref": "manifest.Permission" }, + "optional": true, + "default": [] + }, + "origins": { + "type": "array", + "items": { "$ref": "manifest.MatchPattern" }, + "optional": true, + "default": [] + } + } + } + ], + "functions": [ + { + "name": "getAll", + "type": "function", + "async": "callback", + "description": "Get a list of all the extension's permissions.", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "permissions", + "$ref": "AnyPermissions" + } + ] + } + ] + }, + { + "name": "contains", + "type": "function", + "async": "callback", + "description": "Check if the extension has the given permissions.", + "parameters": [ + { + "name": "permissions", + "$ref": "AnyPermissions" + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "result", + "type": "boolean" + } + ] + } + ] + }, + { + "name": "request", + "type": "function", + "allowedContexts": ["content"], + "async": "callback", + "description": "Request the given permissions.", + "parameters": [ + { + "name": "permissions", + "$ref": "Permissions" + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "granted", + "type": "boolean" + } + ] + } + ] + }, + { + "name": "remove", + "type": "function", + "async": "callback", + "description": "Relinquish the given permissions.", + "parameters": [ + { + "name": "permissions", + "$ref": "Permissions" + }, + { + "name": "callback", + "type": "function", + "parameters": [ + ] + } + ] + } + ], + "events": [ + { + "name": "onAdded", + "type": "function", + "unsupported": true, + "description": "Fired when the extension acquires new permissions.", + "parameters": [ + { + "name": "permissions", + "$ref": "Permissions" + } + ] + }, + { + "name": "onRemoved", + "type": "function", + "unsupported": true, + "description": "Fired when permissions are removed from the extension.", + "parameters": [ + { + "name": "permissions", + "$ref": "Permissions" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/privacy.json b/toolkit/components/extensions/schemas/privacy.json new file mode 100644 index 0000000000..3820b36eb1 --- /dev/null +++ b/toolkit/components/extensions/schemas/privacy.json @@ -0,0 +1,77 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "Permission", + "choices": [{ + "type": "string", + "enum": [ + "privacy" + ] + }] + } + ] + }, + { + "namespace": "privacy", + "permissions": ["privacy"] + }, + { + "namespace": "privacy.network", + "description": "Use the browser.privacy API to control usage of the features in the browser that can affect a user's privacy.", + "permissions": ["privacy"], + "types": [ + { + "id": "IPHandlingPolicy", + "type": "string", + "enum": ["default", "default_public_and_private_interfaces", "default_public_interface_only", "disable_non_proxied_udp"], + "description": "The IP handling policy of WebRTC." + } + ], + "properties": { + "networkPredictionEnabled": { + "$ref": "types.Setting", + "description": "If enabled, the browser attempts to speed up your web browsing experience by pre-resolving DNS entries, prerendering sites (<link rel='prefetch' ...>), and preemptively opening TCP and SSL connections to servers. This preference's value is a boolean, defaulting to true." + }, + "peerConnectionEnabled": { + "$ref": "types.Setting", + "description": "Allow users to enable and disable RTCPeerConnections (aka WebRTC)." + }, + "webRTCIPHandlingPolicy": { + "$ref": "types.Setting", + "description": "Allow users to specify the media performance/privacy tradeoffs which impacts how WebRTC traffic will be routed and how much local address information is exposed. This preference's value is of type IPHandlingPolicy, defaulting to default." + } + } + }, + { + "namespace": "privacy.websites", + "description": "Use the browser.privacy API to control usage of the features in the browser that can affect a user's privacy.", + "permissions": ["privacy"], + "properties": { + "thirdPartyCookiesAllowed": { + "$ref": "types.Setting", + "description": "If disabled, the browser blocks third-party sites from setting cookies. The value of this preference is of type boolean, and the default value is true.", + "unsupported": true + }, + "hyperlinkAuditingEnabled": { + "$ref": "types.Setting", + "description": "If enabled, the browser sends auditing pings when requested by a website (<a ping>). The value of this preference is of type boolean, and the default value is true." + }, + "referrersEnabled": { + "$ref": "types.Setting", + "description": "If enabled, the browser sends referer headers with your requests. Yes, the name of this preference doesn't match the misspelled header. No, we're not going to change it. The value of this preference is of type boolean, and the default value is true.", + "unsupported": true + }, + "protectedContentEnabled": { + "$ref": "types.Setting", + "description": "Available on Windows and ChromeOS only: If enabled, the browser provides a unique ID to plugins in order to run protected content. The value of this preference is of type boolean, and the default value is true.", + "unsupported": true + } + } + } +] diff --git a/toolkit/components/extensions/schemas/proxy.json b/toolkit/components/extensions/schemas/proxy.json new file mode 100644 index 0000000000..5f7f0e4bbe --- /dev/null +++ b/toolkit/components/extensions/schemas/proxy.json @@ -0,0 +1,49 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "Permission", + "choices": [{ + "type": "string", + "enum": [ + "proxy" + ] + }] + } + ] + }, + { + "namespace": "proxy", + "description": "Use the browser.proxy API to register proxy scripts in Firefox. Proxy scripts in Firefox are proxy auto-config files with extra contextual information and support for additional return types.", + "permissions": ["proxy"], + "functions": [ + { + "name": "registerProxyScript", + "type": "function", + "description": "Registers the proxy script for the extension.", + "async": true, + "parameters": [ + { + "name": "url", + "type": "string", + "format": "strictRelativeUrl" + } + ] + } + ], + "events": [ + { + "name": "onProxyError", + "type": "function", + "description": "Notifies about proxy script errors.", + "parameters": [ + { + "name": "error", + "type": "object" + } + ] + } + ] + } +] \ No newline at end of file diff --git a/toolkit/components/extensions/schemas/runtime.json b/toolkit/components/extensions/schemas/runtime.json new file mode 100644 index 0000000000..d7f5f7adfc --- /dev/null +++ b/toolkit/components/extensions/schemas/runtime.json @@ -0,0 +1,594 @@ +// Copyright 2014 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "Permission", + "choices": [{ + "type": "string", + "enum": [ + "nativeMessaging" + ] + }] + } + ] + }, + { + "namespace": "runtime", + "allowedContexts": ["content", "devtools", "proxy"], + "description": "Use the browser.runtime API to retrieve the background page, return details about the manifest, and listen for and respond to events in the app or extension lifecycle. You can also use this API to convert the relative path of URLs to fully-qualified URLs.", + "types": [ + { + "id": "Port", + "type": "object", + "allowedContexts": ["content", "devtools"], + "description": "An object which allows two way communication with other pages.", + "properties": { + "name": {"type": "string"}, + "disconnect": { "type": "function" }, + "onDisconnect": { "$ref": "events.Event" }, + "onMessage": { "$ref": "events.Event" }, + "postMessage": {"type": "function"}, + "sender": { + "$ref": "MessageSender", + "optional": true, + "description": "This property will only be present on ports passed to onConnect/onConnectExternal listeners." + } + }, + "additionalProperties": { "type": "any"} + }, + { + "id": "MessageSender", + "type": "object", + "allowedContexts": ["content", "devtools"], + "description": "An object containing information about the script context that sent a message or request.", + "properties": { + "tab": {"$ref": "tabs.Tab", "optional": true, "description": "The $(ref:tabs.Tab) which opened the connection, if any. This property will only be present when the connection was opened from a tab (including content scripts), and only if the receiver is an extension, not an app."}, + "frameId": {"type": "integer", "optional": true, "description": "The $(topic:frame_ids)[frame] that opened the connection. 0 for top-level frames, positive for child frames. This will only be set when tab is set."}, + "id": {"type": "string", "optional": true, "description": "The ID of the extension or app that opened the connection, if any."}, + "url": {"type": "string", "optional": true, "description": "The URL of the page or frame that opened the connection. If the sender is in an iframe, it will be iframe's URL not the URL of the page which hosts it."}, + "tlsChannelId": {"unsupported": true, "type": "string", "optional": true, "description": "The TLS channel ID of the page or frame that opened the connection, if requested by the extension or app, and if available."} + } + }, + { + "id": "PlatformOs", + "type": "string", + "allowedContexts": ["content", "devtools"], + "description": "The operating system the browser is running on.", + "enum": ["mac", "win", "android", "cros", "linux", "openbsd"] + }, + { + "id": "PlatformArch", + "type": "string", + "enum": ["arm", "x86-32", "x86-64"], + "allowedContexts": ["content", "devtools"], + "description": "The machine's processor architecture." + }, + { + "id": "PlatformInfo", + "type": "object", + "allowedContexts": ["content", "devtools"], + "description": "An object containing information about the current platform.", + "properties": { + "os": { + "$ref": "PlatformOs", + "description": "The operating system the browser is running on." + }, + "arch": { + "$ref": "PlatformArch", + "description": "The machine's processor architecture." + }, + "nacl_arch" : { + "unsupported": true, + "description": "The native client architecture. This may be different from arch on some platforms.", + "$ref": "PlatformNaclArch" + } + } + }, + { + "id": "BrowserInfo", + "type": "object", + "description": "An object containing information about the current browser.", + "properties": { + "name": { + "type": "string", + "description": "The name of the browser, for example 'Firefox'." + }, + "vendor": { + "type": "string", + "description": "The name of the browser vendor, for example 'Mozilla'." + }, + "version": { + "type": "string", + "description": "The browser's version, for example '42.0.0' or '0.8.1pre'." + }, + "buildID": { + "type": "string", + "description": "The browser's build ID/date, for example '20160101'." + } + } + }, + { + "id": "RequestUpdateCheckStatus", + "type": "string", + "enum": ["throttled", "no_update", "update_available"], + "allowedContexts": ["content", "devtools"], + "description": "Result of the update check." + }, + { + "id": "OnInstalledReason", + "type": "string", + "enum": ["install", "update", "browser_update"], + "allowedContexts": ["content", "devtools"], + "description": "The reason that this event is being dispatched." + }, + { + "id": "OnRestartRequiredReason", + "type": "string", + "allowedContexts": ["content", "devtools"], + "description": "The reason that the event is being dispatched. 'app_update' is used when the restart is needed because the application is updated to a newer version. 'os_update' is used when the restart is needed because the browser/OS is updated to a newer version. 'periodic' is used when the system runs for more than the permitted uptime set in the enterprise policy.", + "enum": ["app_update", "os_update", "periodic"] + } + ], + "properties": { + "lastError": { + "type": "object", + "optional": true, + "allowedContexts": ["content", "devtools"], + "description": "This will be defined during an API method callback if there was an error", + "properties": { + "message": { + "optional": true, + "type": "string", + "description": "Details about the error which occurred." + } + }, + "additionalProperties": { + "type": "any" + } + }, + "id": { + "type": "string", + "allowedContexts": ["content", "devtools"], + "description": "The ID of the extension/app." + } + }, + "functions": [ + { + "name": "getBackgroundPage", + "type": "function", + "description": "Retrieves the JavaScript 'window' object for the background page running inside the current extension/app. If the background page is an event page, the system will ensure it is loaded before calling the callback. If there is no background page, an error is set.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "backgroundPage", + "optional": true, + "type": "object", + "isInstanceOf": "Window", + "additionalProperties": { "type": "any" }, + "description": "The JavaScript 'window' object for the background page." + } + ] + } + ] + }, + { + "name": "openOptionsPage", + "type": "function", + "description": "

Open your Extension's options page, if possible.

The precise behavior may depend on your manifest's $(topic:optionsV2)[options_ui] or $(topic:options)[options_page] key, or what the browser happens to support at the time.

If your Extension does not declare an options page, or the browser failed to create one for some other reason, the callback will set $(ref:lastError).

", + "async": "callback", + "parameters": [{ + "type": "function", + "name": "callback", + "parameters": [], + "optional": true + }] + }, + { + "name": "getManifest", + "allowedContexts": ["content", "devtools"], + "description": "Returns details about the app or extension from the manifest. The object returned is a serialization of the full $(topic:manifest)[manifest file].", + "type": "function", + "parameters": [], + "returns": { + "type": "object", + "properties": {}, + "additionalProperties": { "type": "any" }, + "description": "The manifest details." + } + }, + { + "name": "getURL", + "type": "function", + "allowedContexts": ["content", "devtools"], + "description": "Converts a relative path within an app/extension install directory to a fully-qualified URL.", + "parameters": [ + { + "type": "string", + "name": "path", + "description": "A path to a resource within an app/extension expressed relative to its install directory." + } + ], + "returns": { + "type": "string", + "description": "The fully-qualified URL to the resource." + } + }, + { + "name": "setUninstallURL", + "type": "function", + "description": "Sets the URL to be visited upon uninstallation. This may be used to clean up server-side data, do analytics, and implement surveys. Maximum 255 characters.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "url", + "maxLength": 255, + "description": "URL to be opened after the extension is uninstalled. This URL must have an http: or https: scheme. Set an empty string to not open a new tab upon uninstallation." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called when the uninstall URL is set. If the given URL is invalid, $(ref:runtime.lastError) will be set.", + "parameters": [] + } + ] + }, + { + "name": "reload", + "description": "Reloads the app or extension.", + "type": "function", + "parameters": [] + }, + { + "name": "requestUpdateCheck", + "unsupported": true, + "type": "function", + "description": "Requests an update check for this app/extension.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "status", + "$ref": "RequestUpdateCheckStatus", + "description": "Result of the update check." + }, + { + "name": "details", + "type": "object", + "optional": true, + "properties": { + "version": { + "type": "string", + "description": "The version of the available update." + } + }, + "description": "If an update is available, this contains more information about the available update." + } + ] + } + ] + }, + { + "name": "restart", + "unsupported": true, + "description": "Restart the device when the app runs in kiosk mode. Otherwise, it's no-op.", + "type": "function", + "parameters": [] + }, + { + "name": "connect", + "type": "function", + "allowedContexts": ["content", "devtools"], + "description": "Attempts to connect to connect listeners within an extension/app (such as the background page), or other extensions/apps. This is useful for content scripts connecting to their extension processes, inter-app/extension communication, and $(topic:manifest/externally_connectable)[web messaging]. Note that this does not connect to any listeners in a content script. Extensions may connect to content scripts embedded in tabs via $(ref:tabs.connect).", + "parameters": [ + {"type": "string", "name": "extensionId", "optional": true, "description": "The ID of the extension or app to connect to. If omitted, a connection will be attempted with your own extension. Required if sending messages from a web page for $(topic:manifest/externally_connectable)[web messaging]."}, + { + "type": "object", + "name": "connectInfo", + "properties": { + "name": { "type": "string", "optional": true, "description": "Will be passed into onConnect for processes that are listening for the connection event." }, + "includeTlsChannelId": { "type": "boolean", "optional": true, "description": "Whether the TLS channel ID will be passed into onConnectExternal for processes that are listening for the connection event." } + }, + "optional": true + } + ], + "returns": { + "$ref": "Port", + "description": "Port through which messages can be sent and received. The port's $(ref:runtime.Port onDisconnect) event is fired if the extension/app does not exist. " + } + }, + { + "name": "connectNative", + "type": "function", + "description": "Connects to a native application in the host machine.", + "permissions": ["nativeMessaging"], + "parameters": [ + { + "type": "string", + "name": "application", + "description": "The name of the registered application to connect to." + } + ], + "returns": { + "$ref": "Port", + "description": "Port through which messages can be sent and received with the application" + } + }, + { + "name": "sendMessage", + "type": "function", + "allowAmbiguousOptionalArguments": true, + "allowedContexts": ["content", "devtools", "proxy"], + "description": "Sends a single message to event listeners within your extension/app or a different extension/app. Similar to $(ref:runtime.connect) but only sends a single message, with an optional response. If sending to your extension, the $(ref:runtime.onMessage) event will be fired in each page, or $(ref:runtime.onMessageExternal), if a different extension. Note that extensions cannot send messages to content scripts using this method. To send messages to content scripts, use $(ref:tabs.sendMessage).", + "async": "responseCallback", + "parameters": [ + {"type": "string", "name": "extensionId", "optional": true, "description": "The ID of the extension/app to send the message to. If omitted, the message will be sent to your own extension/app. Required if sending messages from a web page for $(topic:manifest/externally_connectable)[web messaging]."}, + { "type": "any", "name": "message" }, + { + "type": "object", + "name": "options", + "properties": { + "includeTlsChannelId": { "type": "boolean", "optional": true, "unsupported": true, "description": "Whether the TLS channel ID will be passed into onMessageExternal for processes that are listening for the connection event." }, + "toProxyScript": { "type": "boolean", "optional": true, "description": "If true, the message will be directed to the extension's proxy sandbox."} + }, + "optional": true + }, + { + "type": "function", + "name": "responseCallback", + "optional": true, + "parameters": [ + { + "name": "response", + "type": "any", + "description": "The JSON response object sent by the handler of the message. If an error occurs while connecting to the extension, the callback will be called with no arguments and $(ref:runtime.lastError) will be set to the error message." + } + ] + } + ] + }, + { + "name": "sendNativeMessage", + "type": "function", + "description": "Send a single message to a native application.", + "permissions": ["nativeMessaging"], + "async": "responseCallback", + "parameters": [ + { + "name": "application", + "description": "The name of the native messaging host.", + "type": "string" + }, + { + "name": "message", + "description": "The message that will be passed to the native messaging host.", + "type": "any" + }, + { + "type": "function", + "name": "responseCallback", + "optional": true, + "parameters": [ + { + "name": "response", + "type": "any", + "description": "The response message sent by the native messaging host. If an error occurs while connecting to the native messaging host, the callback will be called with no arguments and $(ref:runtime.lastError) will be set to the error message." + } + ] + } + ] + }, + { + "name": "getBrowserInfo", + "type": "function", + "description": "Returns information about the current browser.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "description": "Called with results", + "parameters": [ + { + "name": "browserInfo", + "$ref": "BrowserInfo" + } + ] + } + ] + }, + { + "name": "getPlatformInfo", + "type": "function", + "description": "Returns information about the current platform.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "description": "Called with results", + "parameters": [ + { + "name": "platformInfo", + "$ref": "PlatformInfo" + } + ] + } + ] + }, + { + "name": "getPackageDirectoryEntry", + "unsupported": true, + "type": "function", + "description": "Returns a DirectoryEntry for the package directory.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "directoryEntry", + "type": "object", + "additionalProperties": { "type": "any" }, + "isInstanceOf": "DirectoryEntry" + } + ] + } + ] + } + ], + "events": [ + { + "name": "onStartup", + "type": "function", + "description": "Fired when a profile that has this extension installed first starts up. This event is not fired for incognito profiles." + }, + { + "name": "onInstalled", + "type": "function", + "description": "Fired when the extension is first installed, when the extension is updated to a new version, and when the browser is updated to a new version.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "reason": { + "$ref": "OnInstalledReason", + "description": "The reason that this event is being dispatched." + }, + "previousVersion": { + "type": "string", + "optional": true, + "description": "Indicates the previous version of the extension, which has just been updated. This is present only if 'reason' is 'update'." + }, + "temporary": { + "type": "boolean", + "description": "Indicates whether the addon is installed as a temporary extension." + }, + "id": { + "type": "string", + "optional": true, + "unsupported": true, + "description": "Indicates the ID of the imported shared module extension which updated. This is present only if 'reason' is 'shared_module_update'." + } + } + } + ] + }, + { + "name": "onSuspend", + "unsupported": true, + "type": "function", + "description": "Sent to the event page just before it is unloaded. This gives the extension opportunity to do some clean up. Note that since the page is unloading, any asynchronous operations started while handling this event are not guaranteed to complete. If more activity for the event page occurs before it gets unloaded the onSuspendCanceled event will be sent and the page won't be unloaded. " + }, + { + "name": "onSuspendCanceled", + "unsupported": true, + "type": "function", + "description": "Sent after onSuspend to indicate that the app won't be unloaded after all." + }, + { + "name": "onUpdateAvailable", + "type": "function", + "description": "Fired when an update is available, but isn't installed immediately because the app is currently running. If you do nothing, the update will be installed the next time the background page gets unloaded, if you want it to be installed sooner you can explicitly call $(ref:runtime.reload). If your extension is using a persistent background page, the background page of course never gets unloaded, so unless you call $(ref:runtime.reload) manually in response to this event the update will not get installed until the next time the browser itself restarts. If no handlers are listening for this event, and your extension has a persistent background page, it behaves as if $(ref:runtime.reload) is called in response to this event.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "version": { + "type": "string", + "description": "The version number of the available update." + } + }, + "additionalProperties": { "type": "any" }, + "description": "The manifest details of the available update." + } + ] + }, + { + "name": "onBrowserUpdateAvailable", + "unsupported": true, + "type": "function", + "description": "Fired when an update for the browser is available, but isn't installed immediately because a browser restart is required.", + "deprecated": "Please use $(ref:runtime.onRestartRequired).", + "parameters": [] + }, + { + "name": "onConnect", + "type": "function", + "allowedContexts": ["content", "devtools"], + "description": "Fired when a connection is made from either an extension process or a content script.", + "parameters": [ + {"$ref": "Port", "name": "port"} + ] + }, + { + "name": "onConnectExternal", + "type": "function", + "description": "Fired when a connection is made from another extension.", + "parameters": [ + {"$ref": "Port", "name": "port"} + ] + }, + { + "name": "onMessage", + "type": "function", + "allowedContexts": ["content", "devtools", "proxy"], + "description": "Fired when a message is sent from either an extension process or a content script.", + "parameters": [ + {"name": "message", "type": "any", "optional": true, "description": "The message sent by the calling script."}, + {"name": "sender", "$ref": "MessageSender" }, + {"name": "sendResponse", "type": "function", "description": "Function to call (at most once) when you have a response. The argument should be any JSON-ifiable object. If you have more than one onMessage listener in the same document, then only one may send a response. This function becomes invalid when the event listener returns, unless you return true from the event listener to indicate you wish to send a response asynchronously (this will keep the message channel open to the other end until sendResponse is called)." } + ], + "returns": { + "type": "boolean", + "optional": true, + "description": "Return true from the event listener if you wish to call sendResponse after the event listener returns." + } + }, + { + "name": "onMessageExternal", + "type": "function", + "description": "Fired when a message is sent from another extension/app. Cannot be used in a content script.", + "parameters": [ + {"name": "message", "type": "any", "optional": true, "description": "The message sent by the calling script."}, + {"name": "sender", "$ref": "MessageSender" }, + {"name": "sendResponse", "type": "function", "description": "Function to call (at most once) when you have a response. The argument should be any JSON-ifiable object. If you have more than one onMessage listener in the same document, then only one may send a response. This function becomes invalid when the event listener returns, unless you return true from the event listener to indicate you wish to send a response asynchronously (this will keep the message channel open to the other end until sendResponse is called)." } + ], + "returns": { + "type": "boolean", + "optional": true, + "description": "Return true from the event listener if you wish to call sendResponse after the event listener returns." + } + }, + { + "name": "onRestartRequired", + "unsupported": true, + "type": "function", + "description": "Fired when an app or the device that it runs on needs to be restarted. The app should close all its windows at its earliest convenient time to let the restart to happen. If the app does nothing, a restart will be enforced after a 24-hour grace period has passed. Currently, this event is only fired for Chrome OS kiosk apps.", + "parameters": [ + { + "$ref": "OnRestartRequiredReason", + "name": "reason", + "description": "The reason that the event is being dispatched." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/storage.json b/toolkit/components/extensions/schemas/storage.json new file mode 100644 index 0000000000..f0dd322d73 --- /dev/null +++ b/toolkit/components/extensions/schemas/storage.json @@ -0,0 +1,229 @@ +// Copyright 2014 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "storage", + "allowedContexts": ["content", "devtools"], + "defaultContexts": ["content", "devtools"], + "description": "Use the browser.storage API to store, retrieve, and track changes to user data.", + "permissions": ["storage"], + "types": [ + { + "id": "StorageChange", + "type": "object", + "properties": { + "oldValue": { + "type": "any", + "description": "The old value of the item, if there was an old value.", + "optional": true + }, + "newValue": { + "type": "any", + "description": "The new value of the item, if there is a new value.", + "optional": true + } + } + }, + { + "id": "StorageArea", + "type": "object", + "functions": [ + { + "name": "get", + "type": "function", + "description": "Gets one or more items from storage.", + "async": "callback", + "parameters": [ + { + "name": "keys", + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } }, + { + "type": "object", + "description": "Storage items to return in the callback, where the values are replaced with those from storage if they exist.", + "additionalProperties": { "type": "any" } + } + ], + "description": "A single key to get, list of keys to get, or a dictionary specifying default values (see description of the object). An empty list or object will return an empty result object. Pass in null to get the entire contents of storage.", + "optional": true + }, + { + "name": "callback", + "type": "function", + "description": "Callback with storage items, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [ + { + "name": "items", + "type": "object", + "additionalProperties": { "type": "any" }, + "description": "Object with items in their key-value mappings." + } + ] + } + ] + }, + { + "name": "getBytesInUse", + "unsupported": true, + "type": "function", + "description": "Gets the amount of space (in bytes) being used by one or more items.", + "async": "callback", + "parameters": [ + { + "name": "keys", + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "description": "A single key or list of keys to get the total usage for. An empty list will return 0. Pass in null to get the total usage of all of storage.", + "optional": true + }, + { + "name": "callback", + "type": "function", + "description": "Callback with the amount of space being used by storage, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [ + { + "name": "bytesInUse", + "type": "integer", + "description": "Amount of space being used in storage, in bytes." + } + ] + } + ] + }, + { + "name": "set", + "type": "function", + "description": "Sets multiple items.", + "async": "callback", + "parameters": [ + { + "name": "items", + "type": "object", + "additionalProperties": { "type": "any" }, + "description": "

An object which gives each key/value pair to update storage with. Any other key/value pairs in storage will not be affected.

Primitive values such as numbers will serialize as expected. Values with a typeof \"object\" and \"function\" will typically serialize to {}, with the exception of Array (serializes as expected), Date, and Regex (serialize using their String representation).

" + }, + { + "name": "callback", + "type": "function", + "description": "Callback on success, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [], + "optional": true + } + ] + }, + { + "name": "remove", + "type": "function", + "description": "Removes one or more items from storage.", + "async": "callback", + "parameters": [ + { + "name": "keys", + "choices": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ], + "description": "A single key or a list of keys for items to remove." + }, + { + "name": "callback", + "type": "function", + "description": "Callback on success, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [], + "optional": true + } + ] + }, + { + "name": "clear", + "type": "function", + "description": "Removes all items from storage.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "description": "Callback on success, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [], + "optional": true + } + ] + } + ] + } + ], + "events": [ + { + "name": "onChanged", + "type": "function", + "description": "Fired when one or more items change.", + "parameters": [ + { + "name": "changes", + "type": "object", + "additionalProperties": { "$ref": "StorageChange" }, + "description": "Object mapping each key that changed to its corresponding $(ref:storage.StorageChange) for that item." + }, + { + "name": "areaName", + "type": "string", + "description": "The name of the storage area (\"sync\", \"local\" or \"managed\") the changes are for." + } + ] + } + ], + "properties": { + "sync": { + "$ref": "StorageArea", + "description": "Items in the sync storage area are synced by the browser.", + "properties": { + "QUOTA_BYTES": { + "value": 102400, + "description": "The maximum total amount (in bytes) of data that can be stored in sync storage, as measured by the JSON stringification of every value plus every key's length. Updates that would cause this limit to be exceeded fail immediately and set $(ref:runtime.lastError)." + }, + "QUOTA_BYTES_PER_ITEM": { + "value": 8192, + "description": "The maximum size (in bytes) of each individual item in sync storage, as measured by the JSON stringification of its value plus its key length. Updates containing items larger than this limit will fail immediately and set $(ref:runtime.lastError)." + }, + "MAX_ITEMS": { + "value": 512, + "description": "The maximum number of items that can be stored in sync storage. Updates that would cause this limit to be exceeded will fail immediately and set $(ref:runtime.lastError)." + }, + "MAX_WRITE_OPERATIONS_PER_HOUR": { + "value": 1800, + "description": "

The maximum number of set, remove, or clear operations that can be performed each hour. This is 1 every 2 seconds, a lower ceiling than the short term higher writes-per-minute limit.

Updates that would cause this limit to be exceeded fail immediately and set $(ref:runtime.lastError).

" + }, + "MAX_WRITE_OPERATIONS_PER_MINUTE": { + "value": 120, + "description": "

The maximum number of set, remove, or clear operations that can be performed each minute. This is 2 per second, providing higher throughput than writes-per-hour over a shorter period of time.

Updates that would cause this limit to be exceeded fail immediately and set $(ref:runtime.lastError).

" + }, + "MAX_SUSTAINED_WRITE_OPERATIONS_PER_MINUTE": { + "value": 1000000, + "deprecated": "The storage.sync API no longer has a sustained write operation quota.", + "description": "" + } + } + }, + "local": { + "$ref": "StorageArea", + "description": "Items in the local storage area are local to each machine.", + "properties": { + "QUOTA_BYTES": { + "value": 5242880, + "description": "The maximum amount (in bytes) of data that can be stored in local storage, as measured by the JSON stringification of every value plus every key's length. This value will be ignored if the extension has the unlimitedStorage permission. Updates that would cause this limit to be exceeded fail immediately and set $(ref:runtime.lastError)." + } + } + }, + "managed": { + "unsupported": true, + "$ref": "StorageArea", + "description": "Items in the managed storage area are set by the domain administrator, and are read-only for the extension; trying to modify this namespace results in an error." + } + } + } +] diff --git a/toolkit/components/extensions/schemas/test.json b/toolkit/components/extensions/schemas/test.json new file mode 100644 index 0000000000..e6144ec986 --- /dev/null +++ b/toolkit/components/extensions/schemas/test.json @@ -0,0 +1,215 @@ +// Copyright 2014 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "test", + "allowedContexts": ["content", "devtools"], + "defaultContexts": ["content", "devtools"], + "description": "none", + "functions": [ + { + "name": "notifyFail", + "type": "function", + "description": "Notifies the browser process that test code running in the extension failed. This is only used for internal unit testing.", + "parameters": [ + {"type": "string", "name": "message"} + ] + }, + { + "name": "notifyPass", + "type": "function", + "description": "Notifies the browser process that test code running in the extension passed. This is only used for internal unit testing.", + "parameters": [ + {"type": "string", "name": "message", "optional": true} + ] + }, + { + "name": "log", + "type": "function", + "description": "Logs a message during internal unit testing.", + "parameters": [ + {"type": "string", "name": "message"} + ] + }, + { + "name": "sendMessage", + "type": "function", + "description": "Sends a string message to the browser process, generating a Notification that C++ test code can wait for.", + "allowAmbiguousOptionalArguments": true, + "parameters": [ + {"type": "any", "name": "arg1", "optional": true}, + {"type": "any", "name": "arg2", "optional": true} + ] + }, + { + "name": "fail", + "type": "function", + "parameters": [ + {"type": "any", "name": "message", "optional": true} + ] + }, + { + "name": "succeed", + "type": "function", + "parameters": [ + {"type": "any", "name": "message", "optional": true} + ] + }, + { + "name": "assertTrue", + "type": "function", + "allowAmbiguousOptionalArguments": true, + "parameters": [ + {"name": "test", "type": "any", "optional": true}, + {"type": "string", "name": "message", "optional": true} + ] + }, + { + "name": "assertFalse", + "type": "function", + "allowAmbiguousOptionalArguments": true, + "parameters": [ + {"name": "test", "type": "any", "optional": true}, + {"type": "string", "name": "message", "optional": true} + ] + }, + { + "name": "assertBool", + "type": "function", + "unsupported": true, + "parameters": [ + { + "name": "test", + "choices": [ + {"type": "string"}, + {"type": "boolean"} + ] + }, + {"type": "boolean", "name": "expected"}, + {"type": "string", "name": "message", "optional": true} + ] + }, + { + "name": "checkDeepEq", + "type": "function", + "unsupported": true, + "allowAmbiguousOptionalArguments": true, + "parameters": [ + {"type": "any", "name": "expected"}, + {"type": "any", "name": "actual"} + ] + }, + { + "name": "assertEq", + "type": "function", + "allowAmbiguousOptionalArguments": true, + "parameters": [ + {"type": "any", "name": "expected", "optional": true}, + {"type": "any", "name": "actual", "optional": true}, + {"type": "string", "name": "message", "optional": true} + ] + }, + { + "name": "assertNoLastError", + "type": "function", + "unsupported": true, + "parameters": [] + }, + { + "name": "assertLastError", + "type": "function", + "unsupported": true, + "parameters": [ + {"type": "string", "name": "expectedError"} + ] + }, + { + "name": "assertRejects", + "type": "function", + "async": true, + "parameters": [ + { + "name": "promise", + "$ref": "Promise" + }, + { + "name": "expectedError", + "$ref": "ExpectedError", + "optional": true + }, + { + "name": "message", + "type": "string", + "optional": true + } + ] + }, + { + "name": "assertThrows", + "type": "function", + "parameters": [ + { + "name": "func", + "type": "function" + }, + { + "name": "expectedError", + "$ref": "ExpectedError", + "optional": true + }, + { + "name": "message", + "type": "string", + "optional": true + } + ] + } + ], + "types": [ + { + "id": "ExpectedError", + "choices": [ + {"type": "string"}, + {"type": "object", "isInstanceOf": "RegExp", "additionalProperties": true}, + {"type": "function"} + ] + }, + { + "id": "Promise", + "choices": [ + { + "type": "object", + "properties": { + "then": {"type": "function"} + }, + "additionalProperties": true + }, + { + "type": "object", + "isInstanceOf": "Promise", + "additionalProperties": true + } + ] + } + ], + "events": [ + { + "name": "onMessage", + "type": "function", + "description": "Used to test sending messages to extensions.", + "parameters": [ + { + "type": "string", + "name": "message" + }, + { + "type": "any", + "name": "argument" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/theme.json b/toolkit/components/extensions/schemas/theme.json new file mode 100644 index 0000000000..067f0388fe --- /dev/null +++ b/toolkit/components/extensions/schemas/theme.json @@ -0,0 +1,464 @@ +// 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/. + +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "Permission", + "choices": [{ + "type": "string", + "enum": [ + "theme" + ] + }] + }, + { + "id": "ThemeType", + "type": "object", + "properties": { + "images": { + "type": "object", + "optional": true, + "properties": { + "additional_backgrounds": { + "type": "array", + "items": { "$ref": "ExtensionURL" }, + "optional": true + }, + "headerURL": { + "$ref": "ExtensionURL", + "optional": true + }, + "theme_frame": { + "$ref": "ExtensionURL", + "optional": true + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + "colors": { + "type": "object", + "optional": true, + "properties": { + "accentcolor": { + "type": "string", + "optional": true + }, + "frame": { + "type": "array", + "items": { + "type": "number" + }, + "optional": true + }, + "tab_text": { + "type": "array", + "items": { + "type": "number" + }, + "optional": true + }, + "textcolor": { + "type": "string", + "optional": true + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + "icons": { + "type": "object", + "optional": true, + "properties": { + "back": { + "$ref": "ExtensionURL", + "optional": true + }, + "forward": { + "$ref": "ExtensionURL", + "optional": true + }, + "reload": { + "$ref": "ExtensionURL", + "optional": true + }, + "stop": { + "$ref": "ExtensionURL", + "optional": true + }, + "bookmark_star": { + "$ref": "ExtensionURL", + "optional": true + }, + "bookmark_menu": { + "$ref": "ExtensionURL", + "optional": true + }, + "downloads": { + "$ref": "ExtensionURL", + "optional": true + }, + "home": { + "$ref": "ExtensionURL", + "optional": true + }, + "app_menu": { + "$ref": "ExtensionURL", + "optional": true + }, + "cut": { + "$ref": "ExtensionURL", + "optional": true + }, + "copy": { + "$ref": "ExtensionURL", + "optional": true + }, + "paste": { + "$ref": "ExtensionURL", + "optional": true + }, + "new_window": { + "$ref": "ExtensionURL", + "optional": true + }, + "new_private_window": { + "$ref": "ExtensionURL", + "optional": true + }, + "save_page": { + "$ref": "ExtensionURL", + "optional": true + }, + "print": { + "$ref": "ExtensionURL", + "optional": true + }, + "history": { + "$ref": "ExtensionURL", + "optional": true + }, + "full_screen": { + "$ref": "ExtensionURL", + "optional": true + }, + "find": { + "$ref": "ExtensionURL", + "optional": true + }, + "options": { + "$ref": "ExtensionURL", + "optional": true + }, + "addons": { + "$ref": "ExtensionURL", + "optional": true + }, + "developer": { + "$ref": "ExtensionURL", + "optional": true + }, + "synced_tabs": { + "$ref": "ExtensionURL", + "optional": true + }, + "open_file": { + "$ref": "ExtensionURL", + "optional": true + }, + "sidebars": { + "$ref": "ExtensionURL", + "optional": true + }, + "share_page": { + "$ref": "ExtensionURL", + "optional": true + }, + "subscribe": { + "$ref": "ExtensionURL", + "optional": true + }, + "text_encoding": { + "$ref": "ExtensionURL", + "optional": true + }, + "email_link": { + "$ref": "ExtensionURL", + "optional": true + }, + "forget": { + "$ref": "ExtensionURL", + "optional": true + }, + "pocket": { + "$ref": "ExtensionURL", + "optional": true + }, + "getmsg": { + "$ref": "ExtensionURL", + "optional": true + }, + "newmsg": { + "$ref": "ExtensionURL", + "optional": true + }, + "address": { + "$ref": "ExtensionURL", + "optional": true + }, + "reply": { + "$ref": "ExtensionURL", + "optional": true + }, + "replyall": { + "$ref": "ExtensionURL", + "optional": true + }, + "replylist": { + "$ref": "ExtensionURL", + "optional": true + }, + "forwarding": { + "$ref": "ExtensionURL", + "optional": true + }, + "delete": { + "$ref": "ExtensionURL", + "optional": true + }, + "junk": { + "$ref": "ExtensionURL", + "optional": true + }, + "file": { + "$ref": "ExtensionURL", + "optional": true + }, + "nextUnread": { + "$ref": "ExtensionURL", + "optional": true + }, + "prevUnread": { + "$ref": "ExtensionURL", + "optional": true + }, + "mark": { + "$ref": "ExtensionURL", + "optional": true + }, + "tag": { + "$ref": "ExtensionURL", + "optional": true + }, + "compact": { + "$ref": "ExtensionURL", + "optional": true + }, + "archive": { + "$ref": "ExtensionURL", + "optional": true + }, + "chat": { + "$ref": "ExtensionURL", + "optional": true + }, + "nextMsg": { + "$ref": "ExtensionURL", + "optional": true + }, + "prevMsg": { + "$ref": "ExtensionURL", + "optional": true + }, + "QFB": { + "$ref": "ExtensionURL", + "optional": true + }, + "conversation": { + "$ref": "ExtensionURL", + "optional": true + }, + "newcard": { + "$ref": "ExtensionURL", + "optional": true + }, + "newlist": { + "$ref": "ExtensionURL", + "optional": true + }, + "editcard": { + "$ref": "ExtensionURL", + "optional": true + }, + "newim": { + "$ref": "ExtensionURL", + "optional": true + }, + "send": { + "$ref": "ExtensionURL", + "optional": true + }, + "spelling": { + "$ref": "ExtensionURL", + "optional": true + }, + "attach": { + "$ref": "ExtensionURL", + "optional": true + }, + "security": { + "$ref": "ExtensionURL", + "optional": true + }, + "save": { + "$ref": "ExtensionURL", + "optional": true + }, + "quote": { + "$ref": "ExtensionURL", + "optional": true + }, + "buddy": { + "$ref": "ExtensionURL", + "optional": true + }, + "join_chat": { + "$ref": "ExtensionURL", + "optional": true + }, + "chat_accounts": { + "$ref": "ExtensionURL", + "optional": true + }, + "calendar": { + "$ref": "ExtensionURL", + "optional": true + }, + "tasks": { + "$ref": "ExtensionURL", + "optional": true + }, + "synchronize": { + "$ref": "ExtensionURL", + "optional": true + }, + "newevent": { + "$ref": "ExtensionURL", + "optional": true + }, + "newtask": { + "$ref": "ExtensionURL", + "optional": true + }, + "editevent": { + "$ref": "ExtensionURL", + "optional": true + }, + "today": { + "$ref": "ExtensionURL", + "optional": true + }, + "category": { + "$ref": "ExtensionURL", + "optional": true + }, + "complete": { + "$ref": "ExtensionURL", + "optional": true + }, + "priority": { + "$ref": "ExtensionURL", + "optional": true + }, + "saveandclose": { + "$ref": "ExtensionURL", + "optional": true + }, + "attendees": { + "$ref": "ExtensionURL", + "optional": true + }, + "privacy": { + "$ref": "ExtensionURL", + "optional": true + }, + "status": { + "$ref": "ExtensionURL", + "optional": true + }, + "freebusy": { + "$ref": "ExtensionURL", + "optional": true + }, + "timezones": { + "$ref": "ExtensionURL", + "optional": true + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + "properties": { + "type": "object", + "optional": true, + "properties": { + "additional_backgrounds_alignment": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "bottom", "center", "left", "right", "top", + "center bottom", "center center", "center top", + "left bottom", "left center", "left top", + "right bottom", "right center", "right top" + ] + }, + "optional": true + }, + "additional_backgrounds_tiling": { + "type": "array", + "items": { + "type": "string", + "enum": ["no-repeat", "repeat", "repeat-x", "repeat-y"] + }, + "optional": true + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + { + "$extend": "WebExtensionManifest", + "properties": { + "theme": { + "optional": true, + "$ref": "ThemeType" + } + } + } + ] + }, + { + "namespace": "theme", + "description": "The theme API allows customizing of visual elements of the browser.", + "permissions": ["theme"], + "functions": [ + { + "name": "update", + "type": "function", + "async": true, + "description": "Make complete or partial updates to the theme. Resolves when the update has completed.", + "parameters": [ + { + "name": "details", + "$ref": "manifest.ThemeType", + "description": "The properties of the theme to update." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/top_sites.json b/toolkit/components/extensions/schemas/top_sites.json new file mode 100644 index 0000000000..1a42e1d0ad --- /dev/null +++ b/toolkit/components/extensions/schemas/top_sites.json @@ -0,0 +1,79 @@ +/* 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/. */ + +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [{ + "type": "string", + "enum": [ + "topSites" + ] + }] + } + ] + }, + { + "namespace": "topSites", + "description": "Use the chrome.topSites API to access the top sites that are displayed on the new tab page. ", + "permissions": ["topSites"], + "types": [ + { + "id": "MostVisitedURL", + "type": "object", + "description": "An object encapsulating a most visited URL, such as the URLs on the new tab page.", + "properties": { + "url": { + "type": "string", + "description": "The most visited URL." + }, + "title": { + "type": "string", + "optional": true, + "description": "The title of the page." + } + } + } + ], + "functions": [ + { + "name": "get", + "type": "function", + "description": "Gets a list of top sites.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "options", + "properties": { + "providers": { + "type": "array", + "items": { "type": "string" }, + "description": "Which providers to get top sites from. Possible values are \"places\" and \"activityStream\".", + "optional": true + } + }, + "optional": true + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "results", + "type": "array", + "items": { + "$ref": "MostVisitedURL" + } + } + ] + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/types.json b/toolkit/components/extensions/schemas/types.json new file mode 100644 index 0000000000..7bc745a0ca --- /dev/null +++ b/toolkit/components/extensions/schemas/types.json @@ -0,0 +1,163 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "types", + "description": "Contains types used by other schemas.", + "types": [ + { + "id": "SettingScope", + "type": "string", + "enum": ["regular", "regular_only", "incognito_persistent", "incognito_session_only"], + "description": "The scope of the Setting. One of
  • regular: setting for the regular profile (which is inherited by the incognito profile if not overridden elsewhere),
  • regular_only: setting for the regular profile only (not inherited by the incognito profile),
  • incognito_persistent: setting for the incognito profile that survives browser restarts (overrides regular preferences),
  • incognito_session_only: setting for the incognito profile that can only be set during an incognito session and is deleted when the incognito session ends (overrides regular and incognito_persistent preferences).
Only regular is supported by Firefox at this time." + }, + { + "id": "LevelOfControl", + "type": "string", + "enum": ["not_controllable", "controlled_by_other_extensions", "controllable_by_this_extension", "controlled_by_this_extension"], + "description": "One of
  • not_controllable: cannot be controlled by any extension
  • controlled_by_other_extensions: controlled by extensions with higher precedence
  • controllable_by_this_extension: can be controlled by this extension
  • controlled_by_this_extension: controlled by this extension
" + }, + { + "id": "Setting", + "type": "object", + "functions": [ + { + "name": "get", + "type": "function", + "description": "Gets the value of a setting.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "description": "Which setting to consider.", + "properties": { + "incognito": { + "type": "boolean", + "optional": true, + "description": "Whether to return the value that applies to the incognito session (default false)." + } + } + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "details", + "type": "object", + "description": "Details of the currently effective value.", + "properties": { + "value": { + "description": "The value of the setting.", + "type": "any" + }, + "levelOfControl": { + "$ref": "LevelOfControl", + "description": "The level of control of the setting." + }, + "incognitoSpecific": { + "description": "Whether the effective value is specific to the incognito session.
This property will only be present if the incognito property in the details parameter of get() was true.", + "type": "boolean", + "optional": true + } + } + } + ] + } + ] + }, + { + "name": "set", + "type": "function", + "description": "Sets the value of a setting.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "description": "Which setting to change.", + "properties": { + "value": { + "description": "The value of the setting.
Note that every setting has a specific value type, which is described together with the setting. An extension should not set a value of a different type.", + "type": "any" + }, + "scope": { + "$ref": "SettingScope", + "optional": true, + "description": "Where to set the setting (default: regular)." + } + } + }, + { + "name": "callback", + "type": "function", + "description": "Called at the completion of the set operation.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "clear", + "type": "function", + "description": "Clears the setting, restoring any default value.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "description": "Which setting to clear.", + "properties": { + "scope": { + "$ref": "SettingScope", + "optional": true, + "description": "Where to clear the setting (default: regular)." + } + } + }, + { + "name": "callback", + "type": "function", + "description": "Called at the completion of the clear operation.", + "optional": true, + "parameters": [] + } + ] + } + ], + "events": [ + { + "name": "onChange", + "type": "function", + "description": "Fired after the setting changes.", + "unsupported": true, + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "value": { + "description": "The value of the setting after the change.", + "type": "any" + }, + "levelOfControl": { + "$ref": "LevelOfControl", + "description": "The level of control of the setting." + }, + "incognitoSpecific": { + "description": "Whether the value that has changed is specific to the incognito session.
This property will only be present if the user has enabled the extension in incognito mode.", + "type": "boolean", + "optional": true + } + } + } + ] + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/web_navigation.json b/toolkit/components/extensions/schemas/web_navigation.json new file mode 100644 index 0000000000..b9204d34eb --- /dev/null +++ b/toolkit/components/extensions/schemas/web_navigation.json @@ -0,0 +1,386 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [{ + "type": "string", + "enum": [ + "webNavigation" + ] + }] + } + ] + }, + { + "namespace": "webNavigation", + "description": "Use the browser.webNavigation API to receive notifications about the status of navigation requests in-flight.", + "permissions": ["webNavigation"], + "types": [ + { + "id": "TransitionType", + "type": "string", + "enum": ["link", "typed", "auto_bookmark", "auto_subframe", "manual_subframe", "generated", "start_page", "form_submit", "reload", "keyword", "keyword_generated"], + "description": "Cause of the navigation. The same transition types as defined in the history API are used. These are the same transition types as defined in the $(topic:transition_types)[history API] except with \"start_page\" in place of \"auto_toplevel\" (for backwards compatibility)." + }, + { + "id": "TransitionQualifier", + "type": "string", + "enum": ["client_redirect", "server_redirect", "forward_back", "from_address_bar"] + }, + { + "id": "EventUrlFilters", + "type": "object", + "properties": { + "url": { + "type": "array", + "minItems": 1, + "items": { "$ref": "events.UrlFilter" } + } + } + } + ], + "functions": [ + { + "name": "getFrame", + "type": "function", + "description": "Retrieves information about the given frame. A frame refers to an <iframe> or a <frame> of a web page and is identified by a tab ID and a frame ID.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Information about the frame to retrieve information about.", + "properties": { + "tabId": { "type": "integer", "minimum": 0, "description": "The ID of the tab in which the frame is." }, + "processId": {"optional": true, "type": "integer", "description": "The ID of the process runs the renderer for this tab."}, + "frameId": { "type": "integer", "minimum": 0, "description": "The ID of the frame in the given tab." } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "optional": true, + "description": "Information about the requested frame, null if the specified frame ID and/or tab ID are invalid.", + "properties": { + "errorOccurred": { + "unsupported": true, + "type": "boolean", + "description": "True if the last navigation in this frame was interrupted by an error, i.e. the onErrorOccurred event fired." + }, + "url": { + "type": "string", + "description": "The URL currently associated with this frame, if the frame identified by the frameId existed at one point in the given tab. The fact that an URL is associated with a given frameId does not imply that the corresponding frame still exists." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame. Set to -1 of no parent frame exists." + } + } + } + ] + } + ] + }, + { + "name": "getAllFrames", + "type": "function", + "description": "Retrieves information about all frames of a given tab.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Information about the tab to retrieve all frames from.", + "properties": { + "tabId": { "type": "integer", "minimum": 0, "description": "The ID of the tab." } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "details", + "type": "array", + "description": "A list of frames in the given tab, null if the specified tab ID is invalid.", + "optional": true, + "items": { + "type": "object", + "properties": { + "errorOccurred": { + "unsupported": true, + "type": "boolean", + "description": "True if the last navigation in this frame was interrupted by an error, i.e. the onErrorOccurred event fired." + }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "description": "The ID of the frame. 0 indicates that this is the main frame; a positive value indicates the ID of a subframe." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame. Set to -1 of no parent frame exists." + }, + "url": { + "type": "string", + "description": "The URL currently associated with this frame." + } + } + } + } + ] + } + ] + } + ], + "events": [ + { + "name": "onBeforeNavigate", + "type": "function", + "description": "Fired when a navigation is about to occur.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": {"type": "integer", "description": "The ID of the tab in which the navigation is about to occur."}, + "url": {"type": "string"}, + "processId": {"unsupported": true, "type": "integer", "description": "The ID of the process runs the renderer for this tab."}, + "frameId": {"type": "integer", "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique for a given tab and process."}, + "parentFrameId": {"type": "integer", "description": "ID of frame that wraps the frame. Set to -1 of no parent frame exists."}, + "timeStamp": {"type": "number", "description": "The time when the browser was about to start the navigation, in milliseconds since the epoch."} + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onCommitted", + "type": "function", + "description": "Fired when a navigation is committed. The document (and the resources it refers to, such as images and subframes) might still be downloading, but at least part of the document has been received from the server and the browser has decided to switch to the new document.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": {"type": "integer", "description": "The ID of the tab in which the navigation occurs."}, + "url": {"type": "string"}, + "processId": {"unsupported": true, "type": "integer", "description": "The ID of the process runs the renderer for this tab."}, + "frameId": {"type": "integer", "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab."}, + "transitionType": {"unsupported": true, "$ref": "TransitionType", "description": "Cause of the navigation."}, + "transitionQualifiers": {"unsupported": true, "type": "array", "description": "A list of transition qualifiers.", "items": {"$ref": "TransitionQualifier"}}, + "timeStamp": {"type": "number", "description": "The time when the navigation was committed, in milliseconds since the epoch."} + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onDOMContentLoaded", + "type": "function", + "description": "Fired when the page's DOM is fully constructed, but the referenced resources may not finish loading.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": {"type": "integer", "description": "The ID of the tab in which the navigation occurs."}, + "url": {"type": "string"}, + "processId": {"unsupported": true, "type": "integer", "description": "The ID of the process runs the renderer for this tab."}, + "frameId": {"type": "integer", "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab."}, + "timeStamp": {"type": "number", "description": "The time when the page's DOM was fully constructed, in milliseconds since the epoch."} + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onCompleted", + "type": "function", + "description": "Fired when a document, including the resources it refers to, is completely loaded and initialized.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": {"type": "integer", "description": "The ID of the tab in which the navigation occurs."}, + "url": {"type": "string"}, + "processId": {"unsupported": true, "type": "integer", "description": "The ID of the process runs the renderer for this tab."}, + "frameId": {"type": "integer", "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab."}, + "timeStamp": {"type": "number", "description": "The time when the document finished loading, in milliseconds since the epoch."} + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onErrorOccurred", + "type": "function", + "description": "Fired when an error occurs and the navigation is aborted. This can happen if either a network error occurred, or the user aborted the navigation.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": {"type": "integer", "description": "The ID of the tab in which the navigation occurs."}, + "url": {"type": "string"}, + "processId": {"unsupported": true, "type": "integer", "description": "The ID of the process runs the renderer for this tab."}, + "frameId": {"type": "integer", "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab."}, + "error": {"unsupported": true, "type": "string", "description": "The error description."}, + "timeStamp": {"type": "number", "description": "The time when the error occurred, in milliseconds since the epoch."} + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onCreatedNavigationTarget", + "type": "function", + "description": "Fired when a new window, or a new tab in an existing window, is created to host a navigation.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "sourceTabId": {"type": "integer", "description": "The ID of the tab in which the navigation is triggered."}, + "sourceProcessId": {"type": "integer", "description": "The ID of the process runs the renderer for the source tab."}, + "sourceFrameId": {"type": "integer", "description": "The ID of the frame with sourceTabId in which the navigation is triggered. 0 indicates the main frame."}, + "url": {"type": "string", "description": "The URL to be opened in the new window."}, + "tabId": {"type": "integer", "description": "The ID of the tab in which the url is opened"}, + "timeStamp": {"type": "number", "description": "The time when the browser was about to create a new view, in milliseconds since the epoch."} + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onReferenceFragmentUpdated", + "type": "function", + "description": "Fired when the reference fragment of a frame was updated. All future events for that frame will use the updated URL.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": {"type": "integer", "description": "The ID of the tab in which the navigation occurs."}, + "url": {"type": "string"}, + "processId": {"unsupported": true, "type": "integer", "description": "The ID of the process runs the renderer for this tab."}, + "frameId": {"type": "integer", "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab."}, + "transitionType": {"unsupported": true, "$ref": "TransitionType", "description": "Cause of the navigation."}, + "transitionQualifiers": {"unsupported": true, "type": "array", "description": "A list of transition qualifiers.", "items": {"$ref": "TransitionQualifier"}}, + "timeStamp": {"type": "number", "description": "The time when the navigation was committed, in milliseconds since the epoch."} + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onTabReplaced", + "type": "function", + "description": "Fired when the contents of the tab is replaced by a different (usually previously pre-rendered) tab.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "replacedTabId": {"type": "integer", "description": "The ID of the tab that was replaced."}, + "tabId": {"type": "integer", "description": "The ID of the tab that replaced the old tab."}, + "timeStamp": {"type": "number", "description": "The time when the replacement happened, in milliseconds since the epoch."} + } + } + ] + }, + { + "name": "onHistoryStateUpdated", + "type": "function", + "description": "Fired when the frame's history was updated to a new URL. All future events for that frame will use the updated URL.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": {"type": "integer", "description": "The ID of the tab in which the navigation occurs."}, + "url": {"type": "string"}, + "processId": {"unsupported": true, "type": "integer", "description": "The ID of the process runs the renderer for this tab."}, + "frameId": {"type": "integer", "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab."}, + "transitionType": {"unsupported": true, "$ref": "TransitionType", "description": "Cause of the navigation."}, + "transitionQualifiers": {"unsupported": true, "type": "array", "description": "A list of transition qualifiers.", "items": {"$ref": "TransitionQualifier"}}, + "timeStamp": {"type": "number", "description": "The time when the navigation was committed, in milliseconds since the epoch."} + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/web_request.json b/toolkit/components/extensions/schemas/web_request.json new file mode 100644 index 0000000000..c6a23228d5 --- /dev/null +++ b/toolkit/components/extensions/schemas/web_request.json @@ -0,0 +1,636 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [{ + "type": "string", + "enum": [ + "webRequest", + "webRequestBlocking" + ] + }] + } + ] + }, + { + "namespace": "webRequest", + "description": "Use the browser.webRequest API to observe and analyze traffic and to intercept, block, or modify requests in-flight.", + "permissions": ["webRequest"], + "properties": { + "MAX_HANDLER_BEHAVIOR_CHANGED_CALLS_PER_10_MINUTES": { + "value": 20, + "description": "The maximum number of times that handlerBehaviorChanged can be called per 10 minute sustained interval. handlerBehaviorChanged is an expensive function call that shouldn't be called often." + } + }, + "types": [ + { + "id": "ResourceType", + "type": "string", + "enum": [ + "main_frame", + "sub_frame", + "stylesheet", + "script", + "image", + "object", + "object_subrequest", + "xmlhttprequest", + "xbl", + "xslt", + "ping", + "beacon", + "xml_dtd", + "font", + "media", + "websocket", + "csp_report", + "imageset", + "web_manifest", + "other" + ] + }, + { + "id": "OnBeforeRequestOptions", + "type": "string", + "enum": ["blocking", "requestBody"] + }, + { + "id": "OnBeforeSendHeadersOptions", + "type": "string", + "enum": ["requestHeaders", "blocking"] + }, + { + "id": "OnSendHeadersOptions", + "type": "string", + "enum": ["requestHeaders"] + }, + { + "id": "OnHeadersReceivedOptions", + "type": "string", + "enum": ["blocking", "responseHeaders"] + }, + { + "id": "OnAuthRequiredOptions", + "type": "string", + "enum": ["responseHeaders", "blocking", "asyncBlocking"] + }, + { + "id": "OnResponseStartedOptions", + "type": "string", + "enum": ["responseHeaders"] + }, + { + "id": "OnBeforeRedirectOptions", + "type": "string", + "enum": ["responseHeaders"] + }, + { + "id": "OnCompletedOptions", + "type": "string", + "enum": ["responseHeaders"] + }, + { + "id": "RequestFilter", + "type": "object", + "description": "An object describing filters to apply to webRequest events.", + "properties": { + "urls": { + "type": "array", + "description": "A list of URLs or URL patterns. Requests that cannot match any of the URLs will be filtered out.", + "items": { "type": "string" }, + "minItems": 1 + }, + "types": { + "type": "array", + "optional": true, + "description": "A list of request types. Requests that cannot match any of the types will be filtered out.", + "items": { "$ref": "ResourceType" }, + "minItems": 1 + }, + "tabId": { "type": "integer", "optional": true }, + "windowId": { "type": "integer", "optional": true } + } + }, + { + "id": "HttpHeaders", + "type": "array", + "description": "An array of HTTP headers. Each header is represented as a dictionary containing the keys name and either value or binaryValue.", + "items": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Name of the HTTP header."}, + "value": {"type": "string", "optional": true, "description": "Value of the HTTP header if it can be represented by UTF-8."}, + "binaryValue": { + "type": "array", + "optional": true, + "description": "Value of the HTTP header if it cannot be represented by UTF-8, stored as individual byte values (0..255).", + "items": {"type": "integer"} + } + } + } + }, + { + "id": "BlockingResponse", + "type": "object", + "description": "Returns value for event handlers that have the 'blocking' extraInfoSpec applied. Allows the event handler to modify network requests.", + "properties": { + "cancel": { + "type": "boolean", + "optional": true, + "description": "If true, the request is cancelled. Used in onBeforeRequest, this prevents the request from being sent." + }, + "redirectUrl": { + "type": "string", + "optional": true, + "description": "Only used as a response to the onBeforeRequest and onHeadersReceived events. If set, the original request is prevented from being sent/completed and is instead redirected to the given URL. Redirections to non-HTTP schemes such as data: are allowed. Redirects initiated by a redirect action use the original request method for the redirect, with one exception: If the redirect is initiated at the onHeadersReceived stage, then the redirect will be issued using the GET method." + }, + "requestHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "Only used as a response to the onBeforeSendHeaders event. If set, the request is made with these request headers instead." + }, + "responseHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "Only used as a response to the onHeadersReceived event. If set, the server is assumed to have responded with these response headers instead. Only return responseHeaders if you really want to modify the headers in order to limit the number of conflicts (only one extension may modify responseHeaders for each request)." + }, + "authCredentials": { + "type": "object", + "description": "Only used as a response to the onAuthRequired event. If set, the request is made using the supplied credentials.", + "optional": true, + "properties": { + "username": {"type": "string"}, + "password": {"type": "string"} + } + } + } + }, + { + "id": "UploadData", + "type": "object", + "properties": { + "bytes": { + "type": "any", + "optional": true, + "description": "An ArrayBuffer with a copy of the data." + }, + "file": { + "type": "string", + "optional": true, + "description": "A string with the file's path and name." + } + }, + "description": "Contains data uploaded in a URL request." + } + ], + "functions": [ + { + "name": "handlerBehaviorChanged", + "type": "function", + "description": "Needs to be called when the behavior of the webRequest handlers has changed to prevent incorrect handling due to caching. This function call is expensive. Don't call it often.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + } + ], + "events": [ + { + "name": "onBeforeRequest", + "type": "function", + "description": "Fired when a request is about to occur.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": {"type": "string", "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request."}, + "url": {"type": "string"}, + "method": {"type": "string", "description": "Standard HTTP method."}, + "frameId": {"type": "integer", "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab."}, + "parentFrameId": {"type": "integer", "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists."}, + "originUrl": {"type": "string", "optional": true, "description": "URL of the resource that triggered this request."}, + "documentUrl": {"type": "string", "optional": true, "description": "URL of the page into which the requested resource will be loaded."}, + "requestBody": { + "type": "object", + "optional": true, + "description": "Contains the HTTP request body data. Only provided if extraInfoSpec contains 'requestBody'.", + "properties": { + "error": {"type": "string", "optional": true, "description": "Errors when obtaining request body data."}, + "formData": { + "type": "object", + "optional": true, + "description": "If the request method is POST and the body is a sequence of key-value pairs encoded in UTF8, encoded as either multipart/form-data, or application/x-www-form-urlencoded, this dictionary is present and for each key contains the list of all values for that key. If the data is of another media type, or if it is malformed, the dictionary is not present. An example value of this dictionary is {'key': ['value1', 'value2']}.", + "properties": {}, + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + } + }, + "raw" : { + "type": "array", + "optional": true, + "items": {"$ref": "UploadData"}, + "description": "If the request method is PUT or POST, and the body is not already parsed in formData, then the unparsed request body elements are contained in this array." + } + } + }, + "tabId": {"type": "integer", "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab."}, + "type": {"$ref": "ResourceType", "description": "How the requested resource will be used."}, + "timeStamp": {"type": "number", "description": "The time when this signal is triggered, in milliseconds since the epoch."} + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnBeforeRequestOptions" + } + } + ], + "returns": { + "$ref": "BlockingResponse", + "description": "If \"blocking\" is specified in the \"extraInfoSpec\" parameter, the event listener should return an object of this type.", + "optional": true + } + }, + { + "name": "onBeforeSendHeaders", + "type": "function", + "description": "Fired before sending an HTTP request, once the request headers are available. This may occur after a TCP connection is made to the server, but before any HTTP data is sent. ", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": {"type": "string", "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request."}, + "url": {"type": "string"}, + "method": {"type": "string", "description": "Standard HTTP method."}, + "frameId": {"type": "integer", "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab."}, + "parentFrameId": {"type": "integer", "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists."}, + "originUrl": {"type": "string", "optional": true, "description": "URL of the resource that triggered this request."}, + "documentUrl": {"type": "string", "optional": true, "description": "URL of the page into which the requested resource will be loaded."}, + "tabId": {"type": "integer", "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab."}, + "type": {"$ref": "ResourceType", "description": "How the requested resource will be used."}, + "timeStamp": {"type": "number", "description": "The time when this signal is triggered, in milliseconds since the epoch."}, + "requestHeaders": {"$ref": "HttpHeaders", "optional": true, "description": "The HTTP request headers that are going to be sent out with this request."} + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnBeforeSendHeadersOptions" + } + } + ], + "returns": { + "$ref": "BlockingResponse", + "description": "If \"blocking\" is specified in the \"extraInfoSpec\" parameter, the event listener should return an object of this type.", + "optional": true + } + }, + { + "name": "onSendHeaders", + "type": "function", + "description": "Fired just before a request is going to be sent to the server (modifications of previous onBeforeSendHeaders callbacks are visible by the time onSendHeaders is fired).", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": {"type": "string", "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request."}, + "url": {"type": "string"}, + "method": {"type": "string", "description": "Standard HTTP method."}, + "frameId": {"type": "integer", "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab."}, + "parentFrameId": {"type": "integer", "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists."}, + "originUrl": {"type": "string", "optional": true, "description": "URL of the resource that triggered this request."}, + "documentUrl": {"type": "string", "optional": true, "description": "URL of the page into which the requested resource will be loaded."}, + "tabId": {"type": "integer", "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab."}, + "type": {"$ref": "ResourceType", "description": "How the requested resource will be used."}, + "timeStamp": {"type": "number", "description": "The time when this signal is triggered, in milliseconds since the epoch."}, + "requestHeaders": {"$ref": "HttpHeaders", "optional": true, "description": "The HTTP request headers that have been sent out with this request."} + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnSendHeadersOptions" + } + } + ] + }, + { + "name": "onHeadersReceived", + "type": "function", + "description": "Fired when HTTP response headers of a request have been received.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": {"type": "string", "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request."}, + "url": {"type": "string"}, + "method": {"type": "string", "description": "Standard HTTP method."}, + "frameId": {"type": "integer", "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab."}, + "parentFrameId": {"type": "integer", "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists."}, + "originUrl": {"type": "string", "optional": true, "description": "URL of the resource that triggered this request."}, + "documentUrl": {"type": "string", "optional": true, "description": "URL of the page into which the requested resource will be loaded."}, + "tabId": {"type": "integer", "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab."}, + "type": {"$ref": "ResourceType", "description": "How the requested resource will be used."}, + "timeStamp": {"type": "number", "description": "The time when this signal is triggered, in milliseconds since the epoch."}, + "statusLine": {"type": "string", "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line)."}, + "responseHeaders": {"$ref": "HttpHeaders", "optional": true, "description": "The HTTP response headers that have been received with this response."}, + "statusCode": {"type": "integer", "description": "Standard HTTP status code returned by the server."} + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnHeadersReceivedOptions" + } + } + ], + "returns": { + "$ref": "BlockingResponse", + "description": "If \"blocking\" is specified in the \"extraInfoSpec\" parameter, the event listener should return an object of this type.", + "optional": true + } + }, + { + "name": "onAuthRequired", + "type": "function", + "description": "Fired when an authentication failure is received. The listener has three options: it can provide authentication credentials, it can cancel the request and display the error page, or it can take no action on the challenge. If bad user credentials are provided, this may be called multiple times for the same request.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": {"type": "string", "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request."}, + "url": {"type": "string"}, + "method": {"type": "string", "description": "Standard HTTP method."}, + "frameId": {"type": "integer", "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab."}, + "parentFrameId": {"type": "integer", "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists."}, + "originUrl": {"type": "string", "optional": true, "description": "URL of the resource that triggered this request."}, + "documentUrl": {"type": "string", "optional": true, "description": "URL of the page into which the requested resource will be loaded."}, + "tabId": {"type": "integer", "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab."}, + "type": {"$ref": "ResourceType", "description": "How the requested resource will be used."}, + "timeStamp": {"type": "number", "description": "The time when this signal is triggered, in milliseconds since the epoch."}, + "scheme": {"type": "string", "description": "The authentication scheme, e.g. Basic or Digest."}, + "realm": {"type": "string", "description": "The authentication realm provided by the server, if there is one.", "optional": true}, + "challenger": {"type": "object", "description": "The server requesting authentication.", "properties": {"host": {"type": "string"}, "port": {"type": "integer"}}}, + "isProxy": {"type": "boolean", "description": "True for Proxy-Authenticate, false for WWW-Authenticate."}, + "responseHeaders": {"$ref": "HttpHeaders", "optional": true, "description": "The HTTP response headers that were received along with this response."}, + "statusLine": {"type": "string", "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers."}, + "statusCode": {"type": "integer", "description": "Standard HTTP status code returned by the server."} + } + }, + { + "type": "function", + "optional": true, + "name": "callback", + "parameters": [ + {"name": "response", "$ref": "BlockingResponse"} + ] + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnAuthRequiredOptions" + } + } + ], + "returns": { + "$ref": "BlockingResponse", + "description": "If \"blocking\" is specified in the \"extraInfoSpec\" parameter, the event listener should return an object of this type.", + "optional": true + } + }, + { + "name": "onResponseStarted", + "type": "function", + "description": "Fired when the first byte of the response body is received. For HTTP requests, this means that the status line and response headers are available.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": {"type": "string", "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request."}, + "url": {"type": "string"}, + "method": {"type": "string", "description": "Standard HTTP method."}, + "frameId": {"type": "integer", "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab."}, + "parentFrameId": {"type": "integer", "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists."}, + "originUrl": {"type": "string", "optional": true, "description": "URL of the resource that triggered this request."}, + "documentUrl": {"type": "string", "optional": true, "description": "URL of the page into which the requested resource will be loaded."}, + "tabId": {"type": "integer", "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab."}, + "type": {"$ref": "ResourceType", "description": "How the requested resource will be used."}, + "timeStamp": {"type": "number", "description": "The time when this signal is triggered, in milliseconds since the epoch."}, + "ip": {"type": "string", "optional": true, "description": "The server IP address that the request was actually sent to. Note that it may be a literal IPv6 address."}, + "fromCache": {"type": "boolean", "description": "Indicates if this response was fetched from disk cache."}, + "statusCode": {"type": "integer", "description": "Standard HTTP status code returned by the server."}, + "responseHeaders": {"$ref": "HttpHeaders", "optional": true, "description": "The HTTP response headers that were received along with this response."}, + "statusLine": {"type": "string", "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers."} + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnResponseStartedOptions" + } + } + ] + }, + { + "name": "onBeforeRedirect", + "type": "function", + "description": "Fired when a server-initiated redirect is about to occur.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": {"type": "string", "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request."}, + "url": {"type": "string"}, + "method": {"type": "string", "description": "Standard HTTP method."}, + "frameId": {"type": "integer", "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab."}, + "parentFrameId": {"type": "integer", "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists."}, + "originUrl": {"type": "string", "optional": true, "description": "URL of the resource that triggered this request."}, + "documentUrl": {"type": "string", "optional": true, "description": "URL of the page into which the requested resource will be loaded."}, + "tabId": {"type": "integer", "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab."}, + "type": {"$ref": "ResourceType", "description": "How the requested resource will be used."}, + "timeStamp": {"type": "number", "description": "The time when this signal is triggered, in milliseconds since the epoch."}, + "ip": {"type": "string", "optional": true, "description": "The server IP address that the request was actually sent to. Note that it may be a literal IPv6 address."}, + "fromCache": {"type": "boolean", "description": "Indicates if this response was fetched from disk cache."}, + "statusCode": {"type": "integer", "description": "Standard HTTP status code returned by the server."}, + "redirectUrl": {"type": "string", "description": "The new URL."}, + "responseHeaders": {"$ref": "HttpHeaders", "optional": true, "description": "The HTTP response headers that were received along with this redirect."}, + "statusLine": {"type": "string", "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers."} + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnBeforeRedirectOptions" + } + } + ] + }, + { + "name": "onCompleted", + "type": "function", + "description": "Fired when a request is completed.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": {"type": "string", "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request."}, + "url": {"type": "string"}, + "method": {"type": "string", "description": "Standard HTTP method."}, + "frameId": {"type": "integer", "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab."}, + "parentFrameId": {"type": "integer", "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists."}, + "originUrl": {"type": "string", "optional": true, "description": "URL of the resource that triggered this request."}, + "documentUrl": {"type": "string", "optional": true, "description": "URL of the page into which the requested resource will be loaded."}, + "tabId": {"type": "integer", "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab."}, + "type": {"$ref": "ResourceType", "description": "How the requested resource will be used."}, + "timeStamp": {"type": "number", "description": "The time when this signal is triggered, in milliseconds since the epoch."}, + "ip": {"type": "string", "optional": true, "description": "The server IP address that the request was actually sent to. Note that it may be a literal IPv6 address."}, + "fromCache": {"type": "boolean", "description": "Indicates if this response was fetched from disk cache."}, + "statusCode": {"type": "integer", "description": "Standard HTTP status code returned by the server."}, + "responseHeaders": {"$ref": "HttpHeaders", "optional": true, "description": "The HTTP response headers that were received along with this response."}, + "statusLine": {"type": "string", "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers."} + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnCompletedOptions" + } + } + ] + }, + { + "name": "onErrorOccurred", + "type": "function", + "description": "Fired when an error occurs.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": {"type": "string", "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request."}, + "url": {"type": "string"}, + "method": {"type": "string", "description": "Standard HTTP method."}, + "frameId": {"type": "integer", "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab."}, + "parentFrameId": {"type": "integer", "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists."}, + "originUrl": {"type": "string", "optional": true, "description": "URL of the resource that triggered this request."}, + "documentUrl": {"type": "string", "optional": true, "description": "URL of the page into which the requested resource will be loaded."}, + "tabId": {"type": "integer", "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab."}, + "type": {"$ref": "ResourceType", "description": "How the requested resource will be used."}, + "timeStamp": {"type": "number", "description": "The time when this signal is triggered, in milliseconds since the epoch."}, + "ip": {"type": "string", "optional": true, "description": "The server IP address that the request was actually sent to. Note that it may be a literal IPv6 address."}, + "fromCache": {"type": "boolean", "description": "Indicates if this response was fetched from disk cache."}, + "error": {"type": "string", "description": "The error description. This string is not guaranteed to remain backwards compatible between releases. You must not parse and act based upon its content."} + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/test/browser/.eslintrc.js b/toolkit/components/extensions/test/browser/.eslintrc.js new file mode 100644 index 0000000000..a55fd86dc8 --- /dev/null +++ b/toolkit/components/extensions/test/browser/.eslintrc.js @@ -0,0 +1,13 @@ +"use strict"; + +module.exports = { + "extends": "plugin:mozilla/browser-test", + + "env": { + "webextensions": true, + }, + + "rules": { + "no-shadow": "off", + }, +}; diff --git a/toolkit/components/extensions/test/browser/browser.ini b/toolkit/components/extensions/test/browser/browser.ini new file mode 100644 index 0000000000..c07a789911 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser.ini @@ -0,0 +1,10 @@ +[DEFAULT] +support-files = + head.js + +[browser_ext_themes_chromeparity.js] +[browser_ext_themes_dynamic_updates.js] +[browser_ext_themes_lwtsupport.js] +[browser_ext_themes_multiple_backgrounds.js] +[browser_ext_themes_persistence.js] +[browser_ext_management_themes.js] diff --git a/toolkit/components/extensions/test/browser/browser_ext_management_themes.js b/toolkit/components/extensions/test/browser/browser_ext_management_themes.js new file mode 100644 index 0000000000..49b2d1f279 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_management_themes.js @@ -0,0 +1,135 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const {LightweightThemeManager} = Cu.import("resource://gre/modules/LightweightThemeManager.jsm", {}); + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.webextensions.themes.enabled", true]], + }); +}); + +add_task(async function test_management_themes() { + let theme = ExtensionTestUtils.loadExtension({ + manifest: { + "name": "Simple theme test", + "version": "1.0", + "description": "test theme", + "theme": { + "images": { + "headerURL": "image1.png", + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + useAddonManager: "temporary", + }); + + async function background() { + browser.management.onInstalled.addListener(info => { + browser.test.log(`${info.name} was installed`); + browser.test.assertEq(info.type, "theme", "addon is theme"); + browser.test.sendMessage("onInstalled", info.name); + }); + browser.management.onDisabled.addListener(info => { + browser.test.log(`${info.name} was disabled`); + browser.test.assertEq(info.type, "theme", "addon is theme"); + browser.test.sendMessage("onDisabled", info.name); + }); + browser.management.onEnabled.addListener(info => { + browser.test.log(`${info.name} was enabled`); + browser.test.assertEq(info.type, "theme", "addon is theme"); + browser.test.sendMessage("onEnabled", info.name); + }); + browser.management.onUninstalled.addListener(info => { + browser.test.log(`${info.name} was uninstalled`); + browser.test.assertEq(info.type, "theme", "addon is theme"); + browser.test.sendMessage("onUninstalled", info.name); + }); + + async function getAddon(type) { + let addons = await browser.management.getAll(); + // We get the 3 built-in themes plus the lwt and our addon. + browser.test.assertEq(5, addons.length, "got expected addons"); + let found; + for (let addon of addons) { + browser.test.assertEq(addon.type, "theme", "addon is theme"); + if (type == "theme" && addon.id.includes("temporary-addon")) { + found = addon; + } else if (type == "enabled" && addon.enabled) { + found = addon; + } + } + return found; + } + + browser.test.onMessage.addListener(async (msg) => { + let theme = await getAddon("theme"); + browser.test.assertEq(theme.description, "test theme", "description is correct"); + browser.test.assertTrue(theme.enabled, "theme is enabled"); + await browser.management.setEnabled(theme.id, false); + + theme = await getAddon("theme"); + + browser.test.assertTrue(!theme.enabled, "theme is disabled"); + let addon = getAddon("enabled"); + browser.test.assertTrue(addon, "another theme was enabled"); + + await browser.management.setEnabled(theme.id, true); + theme = await getAddon("theme"); + addon = await getAddon("enabled"); + browser.test.assertEq(theme.id, addon.id, "theme is enabled"); + + browser.test.sendMessage("done"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["management"], + }, + background, + }); + await extension.startup(); + + // Test LWT + LightweightThemeManager.currentTheme = { + id: "lwt@personas.mozilla.org", + version: "1", + name: "Bling", + description: "SO MUCH BLING!", + author: "Pixel Pusher", + homepageURL: "http://mochi.test:8888/data/index.html", + headerURL: "http://mochi.test:8888/data/header.png", + previewURL: "http://mochi.test:8888/data/preview.png", + iconURL: "http://mochi.test:8888/data/icon.png", + textcolor: Math.random().toString(), + accentcolor: Math.random().toString(), + }; + is(await extension.awaitMessage("onInstalled"), "Bling", "LWT installed"); + is(await extension.awaitMessage("onDisabled"), "Default", "default disabled"); + is(await extension.awaitMessage("onEnabled"), "Bling", "LWT enabled"); + + await theme.startup(); + is(await extension.awaitMessage("onInstalled"), "Simple theme test", "webextension theme installed"); + is(await extension.awaitMessage("onDisabled"), "Bling", "LWT disabled"); + // no enabled event when installed. + + extension.sendMessage("test"); + is(await extension.awaitMessage("onEnabled"), "Default", "default enabled"); + is(await extension.awaitMessage("onDisabled"), "Simple theme test", "addon disabled"); + is(await extension.awaitMessage("onEnabled"), "Simple theme test", "addon enabled"); + is(await extension.awaitMessage("onDisabled"), "Default", "default disabled"); + await extension.awaitMessage("done"); + + await Promise.all([ + theme.unload(), + extension.awaitMessage("onUninstalled"), + ]); + + is(await extension.awaitMessage("onEnabled"), "Default", "default enabled"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js b/toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js new file mode 100644 index 0000000000..3e264e7be7 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js @@ -0,0 +1,48 @@ +"use strict"; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.webextensions.themes.enabled", true]], + }); +}); + +add_task(async function test_support_theme_frame() { + const FRAME_COLOR = [71, 105, 91]; + const TAB_TEXT_COLOR = [207, 221, 192, .9]; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "theme": { + "images": { + "theme_frame": "face.png", + }, + "colors": { + "frame": FRAME_COLOR, + "tab_text": TAB_TEXT_COLOR, + }, + }, + }, + files: { + "face.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal(docEl.getAttribute("lwthemetextcolor"), "bright", + "LWT text color attribute should be set"); + + let style = window.getComputedStyle(docEl); + Assert.ok(style.backgroundImage.includes("face.png"), + `The backgroundImage should use face.png. Actual value is: ${style.backgroundImage}`); + Assert.equal(style.backgroundColor, "rgb(" + FRAME_COLOR.join(", ") + ")", + "Expected correct background color"); + Assert.equal(style.color, "rgba(" + TAB_TEXT_COLOR.join(", ") + ")", + "Expected correct text color"); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js new file mode 100644 index 0000000000..eb7dcb96a3 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js @@ -0,0 +1,94 @@ +"use strict"; + +// PNG image data for a simple red dot. +const BACKGROUND_1 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; +const ACCENT_COLOR_1 = "#a14040"; +const TEXT_COLOR_1 = "#fac96e"; + +// PNG image data for the Mozilla dino head. +const BACKGROUND_2 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="; +const ACCENT_COLOR_2 = "#03fe03"; +const TEXT_COLOR_2 = "#0ef325"; + +function hexToRGB(hex) { + hex = parseInt((hex.indexOf("#") > -1 ? hex.substring(1) : hex), 16); + return [hex >> 16, (hex & 0x00FF00) >> 8, (hex & 0x0000FF)]; +} + +function validateTheme(backgroundImage, accentColor, textColor) { + let docEl = window.document.documentElement; + let style = window.getComputedStyle(docEl); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal(docEl.getAttribute("lwthemetextcolor"), "bright", + "LWT text color attribute should be set"); + + Assert.ok(style.backgroundImage.includes(backgroundImage), "Expected correct background image"); + Assert.equal(style.backgroundColor, "rgb(" + hexToRGB(accentColor).join(", ") + ")", + "Expected correct accent color"); + Assert.equal(style.color, "rgb(" + hexToRGB(textColor).join(", ") + ")", + "Expected correct text color"); +} + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.webextensions.themes.enabled", true]], + }); +}); + +add_task(async function test_dynamic_theme_updates() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + files: { + "image1.png": BACKGROUND_1, + "image2.png": BACKGROUND_2, + }, + background() { + browser.test.onMessage.addListener((msg, details) => { + if (msg != "update-theme") { + browser.test.fail("expected 'update-theme' message"); + } + + browser.theme.update(details); + browser.test.sendMessage("theme-updated"); + }); + }, + }); + + await extension.startup(); + + extension.sendMessage("update-theme", { + "images": { + "headerURL": "image1.png", + }, + "colors": { + "accentcolor": ACCENT_COLOR_1, + "textcolor": TEXT_COLOR_1, + }, + }); + + await extension.awaitMessage("theme-updated"); + + validateTheme("image1.png", ACCENT_COLOR_1, TEXT_COLOR_1); + + extension.sendMessage("update-theme", { + "images": { + "headerURL": "image2.png", + }, + "colors": { + "accentcolor": ACCENT_COLOR_2, + "textcolor": TEXT_COLOR_2, + }, + }); + + await extension.awaitMessage("theme-updated"); + + validateTheme("image2.png", ACCENT_COLOR_2, TEXT_COLOR_2); + + await extension.unload(); + + let docEl = window.document.documentElement; + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js b/toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js new file mode 100644 index 0000000000..888dbbb0f8 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js @@ -0,0 +1,87 @@ +"use strict"; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.webextensions.themes.enabled", true]], + }); +}); + +add_task(async function test_support_LWT_properties() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "theme": { + "images": { + "headerURL": "image1.png", + }, + "colors": { + "accentcolor": ACCENT_COLOR, + "textcolor": TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let style = window.getComputedStyle(docEl); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal(docEl.getAttribute("lwthemetextcolor"), "bright", + "LWT text color attribute should be set"); + + Assert.ok(style.backgroundImage.includes("image1.png"), "Expected background image"); + Assert.equal(style.backgroundColor, "rgb(" + hexToRGB(ACCENT_COLOR).join(", ") + ")", + "Expected correct background color"); + Assert.equal(style.color, "rgb(" + hexToRGB(TEXT_COLOR).join(", ") + ")", + "Expected correct text color"); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); + +add_task(async function test_LWT_requires_all_properties_defined_image_only() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "theme": { + "images": { + "headerURL": "image1.png", + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); + await extension.unload(); + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); + +add_task(async function test_LWT_requires_all_properties_defined_colors_only() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "theme": { + "colors": { + "accentcolor": ACCENT_COLOR, + "textcolor": TEXT_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); + await extension.unload(); + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js b/toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js new file mode 100644 index 0000000000..45db713b9b --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js @@ -0,0 +1,152 @@ +"use strict"; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.webextensions.themes.enabled", true]], + }); +}); + +add_task(async function test_support_backgrounds_position() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "theme": { + "images": { + "headerURL": "face.png", + "additional_backgrounds": ["face.png", "face.png", "face.png"], + }, + "colors": { + "accentcolor": `rgb(${FRAME_COLOR.join(",")})`, + "textcolor": `rgb(${TAB_TEXT_COLOR.join(",")})`, + }, + "properties": { + "additional_backgrounds_alignment": ["left top", "center top", "right bottom"], + }, + }, + }, + files: { + "face.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal(docEl.getAttribute("lwthemetextcolor"), "bright", + "LWT text color attribute should be set"); + + let style = window.getComputedStyle(docEl); + let bgImage = style.backgroundImage.split(",")[0].trim(); + Assert.ok(bgImage.includes("face.png"), + `The backgroundImage should use face.png. Actual value is: ${bgImage}`); + Assert.equal(Array(4).fill(bgImage).join(", "), style.backgroundImage, + "The backgroundImage should use face.png four times."); + Assert.equal(style.backgroundPosition, "100% 0%, 0% 0%, 50% 0%, 100% 100%", + "The backgroundPosition should use the four values provided."); + Assert.equal(style.backgroundRepeat, "no-repeat", + "The backgroundPosition should use the default value."); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); + style = window.getComputedStyle(docEl); + // Styles should've reverted to their initial values. + Assert.equal(style.backgroundImage, "none"); + Assert.equal(style.backgroundPosition, "0% 0%"); + Assert.equal(style.backgroundRepeat, "repeat"); +}); + +add_task(async function test_support_backgrounds_repeat() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "theme": { + "images": { + "theme_frame": "face0.png", + "additional_backgrounds": ["face1.png", "face2.png", "face3.png"], + }, + "colors": { + "frame": FRAME_COLOR, + "tab_text": TAB_TEXT_COLOR, + }, + "properties": { + "additional_backgrounds_tiling": ["repeat-x", "repeat-y", "repeat"], + }, + }, + }, + files: { + "face0.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + "face1.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + "face2.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + "face3.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal(docEl.getAttribute("lwthemetextcolor"), "bright", + "LWT text color attribute should be set"); + + let style = window.getComputedStyle(docEl); + let bgImage = style.backgroundImage.split(",")[0].trim(); + Assert.ok(bgImage.includes("face0.png"), + `The backgroundImage should use face.png. Actual value is: ${bgImage}`); + Assert.equal([0, 1, 2, 3].map(num => bgImage.replace(/face[\d]*/, `face${num}`)).join(", "), + style.backgroundImage, "The backgroundImage should use face.png four times."); + Assert.equal(style.backgroundPosition, "100% 0%", + "The backgroundPosition should use the default value."); + Assert.equal(style.backgroundRepeat, "no-repeat, repeat-x, repeat-y, repeat", + "The backgroundPosition should use the four values provided."); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); + +add_task(async function test_additional_images_check() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "theme": { + "images": { + "theme_frame": "face.png", + }, + "colors": { + "frame": FRAME_COLOR, + "tab_text": TAB_TEXT_COLOR, + }, + "properties": { + "additional_backgrounds_tiling": ["repeat-x", "repeat-y", "repeat"], + }, + }, + }, + files: { + "face.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal(docEl.getAttribute("lwthemetextcolor"), "bright", + "LWT text color attribute should be set"); + + let style = window.getComputedStyle(docEl); + let bgImage = style.backgroundImage.split(",")[0]; + Assert.ok(bgImage.includes("face.png"), + `The backgroundImage should use face.png. Actual value is: ${bgImage}`); + Assert.equal(bgImage + ", none", style.backgroundImage, + "The backgroundImage should use face.png only once."); + Assert.equal(style.backgroundPosition, "100% 0%", + "The backgroundPosition should use the default value."); + Assert.equal(style.backgroundRepeat, "no-repeat", + "The backgroundPosition should use only one (default) value."); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js b/toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js new file mode 100644 index 0000000000..ea765fb052 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js @@ -0,0 +1,52 @@ +"use strict"; + +// This test checks whether applied WebExtension themes are persisted and applied +// on newly opened windows. + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.webextensions.themes.enabled", true]], + }); +}); + +add_task(async function test_multiple_windows() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "theme": { + "images": { + "headerURL": "image1.png", + }, + "colors": { + "accentcolor": ACCENT_COLOR, + "textcolor": TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let style = window.getComputedStyle(docEl); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal(docEl.getAttribute("lwthemetextcolor"), "bright", + "LWT text color attribute should be set"); + Assert.ok(style.backgroundImage.includes("image1.png"), "Expected background image"); + + // Now we'll open a new window to see if the theme is also applied there. + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + docEl = window2.document.documentElement; + style = window2.getComputedStyle(docEl); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal(docEl.getAttribute("lwthemetextcolor"), "bright", + "LWT text color attribute should be set"); + Assert.ok(style.backgroundImage.includes("image1.png"), "Expected background image"); + + await BrowserTestUtils.closeWindow(window2); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/head.js b/toolkit/components/extensions/test/browser/head.js new file mode 100644 index 0000000000..f6738abec5 --- /dev/null +++ b/toolkit/components/extensions/test/browser/head.js @@ -0,0 +1,47 @@ +/* exported ACCENT_COLOR, BACKGROUND, ENCODED_IMAGE_DATA, FRAME_COLOR, TAB_TEXT_COLOR, + TEXT_COLOR, imageBufferFromDataURI, hexToRGB */ + +"use strict"; + +const BACKGROUND = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0" + + "DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; +const ENCODED_IMAGE_DATA = "iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAgY0h" + + "STQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAAdhwAAHYcBj+XxZQAAB5dJREFUSMd" + + "91vmTlEcZB/Bvd7/vO+/ce83O3gfLDUsC4VgIghBUEo2GM9GCFTaQBEISA1qIEVNQ4aggJDGIgAGTlFUKKcqKQpVHaQyny7FrCMiywp4ze+/Mzs67M/P" + + "O+3a3v5jdWo32H/B86vv0U083weecV3+0C8lkEh6PhzS3tuLkieMSAKo3fW9Mb1eoUtM0jemerukLllzrbGlKheovUpeqkmt113hPfx/27tyFF7+/bbg" + + "e+U9g20s7kEwmMXXGNLrp2fWi4V5z/tFjJ3fWX726INbfU2xx0yelkJAKdJf3Xl5+2QcPTpv2U0JZR+u92+xvly5ygKDm20/hlX17/jvB6VNnIKXEOyd" + + "O0iFh4PLVy0XV1U83Vk54QI7JK+bl+UE5vjRfTCzJ5eWBTFEayBLjisvljKmzwmtWrVkEAPNmVrEZkyfh+fU1n59k//7X4Fbz8MK2DRSAWLNq/Yc36y9" + + "+3UVMsyAYVPMy/MTvdBKvriJhphDq6xa9vf0i1GMwPVhM5s9bsLw/EvtN2kywwnw/nzBuLDZs2z4auXGjHuvWbmBQdT5v7qytn165fLCyyGtXTR6j5GV" + + "kIsvlBCwTVNgQhMKCRDQ2iIbmJv7BpU+Ykl02UFOzdt6gkbzTEQ5Rl2KL3W8eGUE+/ssFXK+rJQ8vWigLgjk5z9ZsvpOniJzVi+ZKTUhCuATTKCjhoLA" + + "hhQAsjrSZBJcm7rZ22O+ev6mMmTLj55eu1T+jU8GOH/kJf2TZCiifIQsXfwEbN2yktxoaeYbf93DKSORMnTOZE0aZaVlQGYVKJCgjEJSCcgLB0xDERjI" + + "NFBUEaXmuB20t95eEutr0xrufpo4eepMAkMPIxx+dx9at25EWQNXsh77q0Bzwen0ShEF32HCrCpjksAWHFAKqokFhgEJt2DKJeFoQv8eDuz3duaseXZY" + + "dixthaQ+NRlRCcKO+FgCweP68wswMF/yZWcTkNpLJFAZEGi6XC07NCUIIoqaNSLQfFALCEpCSEL/bK/wuw+12sKlDQzKs6k5yZt+rI+2aNKUSNdUbSSQ" + + "Wh2mJP46rGPeYrjtkY0M7jFgciUQCiqqgrCAfBTle3G9rR1NHN3SnDq9Lg49QlBQEcbfbQCKZlhQEDkXBih27RpDOrmacfP8YB4CfHT7uNXrCMFM2FdD" + + "BVQ5TE/A5HbDSJoSpQXAbXm8A4b5+gKrwulU4KKEBnwuzHpiQu+n1jQoQsM+9cYQMT9fvf/FLBYTaDqdzbfgft95PKzbPyQqwnlAXGkJtGIgNYnJpMfw" + + "OghLG0GJE0ZdiaOnsQ16OD6XZLkiRROdAgud5sxk8ridsy/pQU1VlOIkZN6QtAGnx0FA0AtXvIA4C5OX4kOWbiLRhQBDApTmgJuLwEonMgBvjgpmgjIE" + + "hhX7DAIVKNeqE05/dJbgEgRy5eOJ1ieXr1gJA7ZNLTrVVlAZLyopLJAUlHsrAMrwwrRQ4t6E5VHgSBExjcGpO0JQNizCE05a41dhOi+cXXVm144e1AHD" + + "1vXfFMOLy+KSHEDoEJLZ8s+ZWKpUusWwpFKiMUQ4jbiAaj8Hp9oExBsMCUpEIfD6JLKZjKJVGV3RIZGdm0qxA5qmz+/cgMhBVuuMRewRRGF7fe4BYHMg" + + "N5LxdV3vhy1EjrrjA5GAyTuKpFHricfS0dSDNCQRPoSyQgSSPI+UBEtwShiWUQEHw5mMvbz4JRcXvDr3B3dBG1sq5X53GlMcX4JWVTyvRQcOumDD2vfK" + + "cjOqiQDZPGBF2ryUEnjRhJlP4d6/BiQ1TABPKiyQhgtzvjPCJlQ/OGRwauqESSUPX68U3Vi4fGeH83Hwc3bYHBWUV0m0k4HB6z7aGu6sznDos00R3exg" + + "l5ZMwc+FMaJoKKxHFnbo6DMYiELBlqLOXDBq8dsvuPTfKALpwdbX42iMLsHjLd0Zv4RNvvY1wZxdZunyVDGZm6D/47sv12RqbmOPVhG5LGnAH4S8sgu7" + + "1oK/pn2BWAoYw0dDbaTd19iqlZROejwzEjqgMSuXUifak8jF49JnNI0kAoGrBfET7+uXOrS+y5ta21JzZsw7faW45XJaXxSvyAtTpkOi483fwtAWP1wt" + + "vrhvd/VFx+26zojr9Les2PnfaTNu4cuGvvKe9BVv3/RgARiNTpk/Hod17MWikxcqzzfhK/+1jL2xc+YQAX1ISDHLV7WTpQQaLcASzPEiB41ZrmEeHkrT" + + "Q49uz/aXn+iilLKXq/MmlS0e/jFcuX4SmaQAAKSXlnIvVy1aQ6EBMFgRyCznDpfGFwdKqirF2tu5SdIeGrkiP+KS5yb7dHtIKsnI++kP9rS8RQvjmxxe" + + "jePxD2HHwwP9FdCllurGhUbx14CAbiMc4Y2qVJqwLbo0qfpdLSilILB4Xg0mT6h7vnSWzZn9RoaynobWF3K6rk1NmzMWZ83/+37+V4a1cVg5JACYF45b" + + "FGVVWOFS2V1HUCjOdBqW0Q9fYb7N9/tcSptnldjpott8rFEXBO+f+NKrWMHL9Wu1nSUAIAaUUa59aAyE43E4X3bD8W6K5K6x1h1snRaMDJDuQf7+vrzf" + + "eG+mgfrcLHh3C79bx6wttGEqERiH/AjPohWMouv2ZAAAAAElFTkSuQmCC"; +const ACCENT_COLOR = "#a14040"; +const TEXT_COLOR = "#fac96e"; +// For testing aliases of the colors above: +const FRAME_COLOR = [71, 105, 91]; +const TAB_TEXT_COLOR = [207, 221, 192, .9]; + +function hexToRGB(hex) { + hex = parseInt((hex.indexOf("#") > -1 ? hex.substring(1) : hex), 16); + return [hex >> 16, (hex & 0x00FF00) >> 8, (hex & 0x0000FF)]; +} + +function imageBufferFromDataURI(encodedImageData) { + let decodedImageData = atob(encodedImageData); + return Uint8Array.from(decodedImageData, byte => byte.charCodeAt(0)).buffer; +} diff --git a/toolkit/components/extensions/test/mochitest/.eslintrc.js b/toolkit/components/extensions/test/mochitest/.eslintrc.js new file mode 100644 index 0000000000..4ff1ab267a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/.eslintrc.js @@ -0,0 +1,14 @@ +"use strict"; + +module.exports = { + "extends": "plugin:mozilla/mochitest-test", + + "env": { + "browser": true, + "webextensions": true, + }, + + "rules": { + "no-shadow": 0, + }, +}; diff --git a/toolkit/components/extensions/test/mochitest/chrome.ini b/toolkit/components/extensions/test/mochitest/chrome.ini new file mode 100644 index 0000000000..a6b8d72e18 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/chrome.ini @@ -0,0 +1,44 @@ +[DEFAULT] +support-files = + chrome_head.js + chrome_cleanup_script.js + head.js + head_cookies.js + file_image_great.png + file_sample.html + file_with_images.html + webrequest_chromeworker.js + webrequest_test.jsm + oauth.html + redirect_auto.sjs +tags = webextensions in-process-webextensions + +[test_chrome_ext_background_page.html] +skip-if = (toolkit == 'android') # android doesn't have devtools +[test_chrome_ext_contentscript_data_uri.html] +[test_chrome_ext_contentscript_unrecognizedprop_warning.html] +[test_chrome_ext_downloads_saveAs.html] +[test_chrome_ext_eventpage_warning.html] +[test_chrome_ext_hybrid_addons.html] +[test_chrome_ext_idle.html] +[test_chrome_ext_identity.html] +skip-if = os == 'android' # unsupported. +[test_chrome_ext_permissions.html] +skip-if = os == 'android' # Bug 1350559 +[test_chrome_ext_storage_cleanup.html] +[test_chrome_ext_trackingprotection.html] +[test_chrome_ext_trustworthy_origin.html] +[test_chrome_ext_webnavigation_resolved_urls.html] +[test_chrome_ext_webrequest_background_events.html] +[test_chrome_ext_webrequest_errors.html] +[test_chrome_ext_webrequest_host_permissions.html] +[test_chrome_native_messaging_paths.html] +skip-if = os != "mac" && os != "linux" +[test_ext_cookies_expiry.html] +[test_ext_cookies_permissions_bad.html] +[test_ext_cookies_permissions_good.html] +[test_ext_cookies_containers.html] +[test_ext_jsversion.html] +[test_ext_schema.html] +[test_ext_protocolHandlers.html] +skip-if = (toolkit == 'android') # bug 1342577 diff --git a/toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js b/toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js new file mode 100644 index 0000000000..8a914d5de1 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js @@ -0,0 +1,57 @@ +"use strict"; + +/* global addMessageListener, sendAsyncMessage */ + +Components.utils.import("resource://gre/modules/AppConstants.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); + +let getBrowserApp, getTabBrowser; +if (AppConstants.MOZ_BUILD_APP === "mobile/android") { + getBrowserApp = win => win.BrowserApp; + getTabBrowser = tab => tab.browser; +} else { + getBrowserApp = win => win.gBrowser; + getTabBrowser = tab => tab.linkedBrowser; +} + +function* iterBrowserWindows() { + let enm = Services.wm.getEnumerator("navigator:browser"); + while (enm.hasMoreElements()) { + let win = enm.getNext(); + if (!win.closed && getBrowserApp(win)) { + yield win; + } + } +} + +let initialTabs = new Map(); +for (let win of iterBrowserWindows()) { + initialTabs.set(win, new Set(getBrowserApp(win).tabs)); +} + +addMessageListener("check-cleanup", extensionId => { + let results = { + extraWindows: [], + extraTabs: [], + }; + + for (let win of iterBrowserWindows()) { + if (initialTabs.has(win)) { + let tabs = initialTabs.get(win); + + for (let tab of getBrowserApp(win).tabs) { + if (!tabs.has(tab)) { + results.extraTabs.push(getTabBrowser(tab).currentURI.spec); + } + } + } else { + results.extraWindows.push( + Array.from(win.gBrowser.tabs, + tab => getTabBrowser(tab).currentURI.spec)); + } + } + + initialTabs = null; + + sendAsyncMessage("cleanup-results", results); +}); diff --git a/toolkit/components/extensions/test/mochitest/chrome_head.js b/toolkit/components/extensions/test/mochitest/chrome_head.js new file mode 100644 index 0000000000..da2f53a02b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/chrome_head.js @@ -0,0 +1,12 @@ +"use strict"; + +const { + classes: Cc, + interfaces: Ci, + utils: Cu, + results: Cr, +} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/NetUtil.jsm"); + diff --git a/toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html new file mode 100644 index 0000000000..663ebc6112 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html @@ -0,0 +1,12 @@ + + + + + + + +
+
+ + + diff --git a/toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html new file mode 100644 index 0000000000..cc1acc83d6 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html new file mode 100644 index 0000000000..a0a26a2e9d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html @@ -0,0 +1,9 @@ + + + + + +click me + + + diff --git a/toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html b/toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html new file mode 100644 index 0000000000..24c7a42986 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_WebRequest_permission_original.html b/toolkit/components/extensions/test/mochitest/file_WebRequest_permission_original.html new file mode 100644 index 0000000000..98690cca53 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebRequest_permission_original.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_WebRequest_permission_original.js b/toolkit/components/extensions/test/mochitest/file_WebRequest_permission_original.js new file mode 100644 index 0000000000..2981108b64 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebRequest_permission_original.js @@ -0,0 +1,2 @@ +"use strict"; +window.testScript = "original"; diff --git a/toolkit/components/extensions/test/mochitest/file_WebRequest_permission_redirected.html b/toolkit/components/extensions/test/mochitest/file_WebRequest_permission_redirected.html new file mode 100644 index 0000000000..58c69d4474 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebRequest_permission_redirected.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_WebRequest_permission_redirected.js b/toolkit/components/extensions/test/mochitest/file_WebRequest_permission_redirected.js new file mode 100644 index 0000000000..06fd42aa40 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebRequest_permission_redirected.js @@ -0,0 +1,2 @@ +"use strict"; +window.testScript = "redirected"; diff --git a/toolkit/components/extensions/test/mochitest/file_csp.html b/toolkit/components/extensions/test/mochitest/file_csp.html new file mode 100644 index 0000000000..206e443904 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_csp.html @@ -0,0 +1,14 @@ + + + + + + + + +
Sample text
+ + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_csp.html^headers^ b/toolkit/components/extensions/test/mochitest/file_csp.html^headers^ new file mode 100644 index 0000000000..4c6fa3c26a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_csp.html^headers^ @@ -0,0 +1 @@ +Content-Security-Policy: default-src 'self' diff --git a/toolkit/components/extensions/test/mochitest/file_ext_test_api_injection.js b/toolkit/components/extensions/test/mochitest/file_ext_test_api_injection.js new file mode 100644 index 0000000000..633226f3dd --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_ext_test_api_injection.js @@ -0,0 +1,14 @@ +"use strict"; + +/* eslint-env mozilla/frame-script */ + +var {interfaces: Ci} = Components; + +Components.utils.import("resource://gre/modules/Services.jsm"); + +Services.console.registerListener(function listener(message) { + if (/WebExt Privilege Escalation/.test(message.message)) { + Services.console.unregisterListener(listener); + sendAsyncMessage("console-message", {message: message.message}); + } +}); diff --git a/toolkit/components/extensions/test/mochitest/file_image_bad.png b/toolkit/components/extensions/test/mochitest/file_image_bad.png new file mode 100644 index 0000000000..4c3be50847 Binary files /dev/null and b/toolkit/components/extensions/test/mochitest/file_image_bad.png differ diff --git a/toolkit/components/extensions/test/mochitest/file_image_good.png b/toolkit/components/extensions/test/mochitest/file_image_good.png new file mode 100644 index 0000000000..769c636340 Binary files /dev/null and b/toolkit/components/extensions/test/mochitest/file_image_good.png differ diff --git a/toolkit/components/extensions/test/mochitest/file_image_great.png b/toolkit/components/extensions/test/mochitest/file_image_great.png new file mode 100644 index 0000000000..769c636340 Binary files /dev/null and b/toolkit/components/extensions/test/mochitest/file_image_great.png differ diff --git a/toolkit/components/extensions/test/mochitest/file_image_redirect.png b/toolkit/components/extensions/test/mochitest/file_image_redirect.png new file mode 100644 index 0000000000..4c3be50847 Binary files /dev/null and b/toolkit/components/extensions/test/mochitest/file_image_redirect.png differ diff --git a/toolkit/components/extensions/test/mochitest/file_mixed.html b/toolkit/components/extensions/test/mochitest/file_mixed.html new file mode 100644 index 0000000000..f3c7dda580 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_mixed.html @@ -0,0 +1,13 @@ + + + + + + + + +
Sample text
+ + + + diff --git a/toolkit/components/extensions/test/mochitest/file_permission_xhr.html b/toolkit/components/extensions/test/mochitest/file_permission_xhr.html new file mode 100644 index 0000000000..22a55f90d2 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_permission_xhr.html @@ -0,0 +1,55 @@ + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_privilege_escalation.html b/toolkit/components/extensions/test/mochitest/file_privilege_escalation.html new file mode 100644 index 0000000000..258f7058d9 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_privilege_escalation.html @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_remote_frame.html b/toolkit/components/extensions/test/mochitest/file_remote_frame.html new file mode 100644 index 0000000000..f1b9240092 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_remote_frame.html @@ -0,0 +1,20 @@ + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_sample.html b/toolkit/components/extensions/test/mochitest/file_sample.html new file mode 100644 index 0000000000..a20e49a1f0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_sample.html @@ -0,0 +1,12 @@ + + + + + + + + +
Sample text
+ + + diff --git a/toolkit/components/extensions/test/mochitest/file_script_bad.js b/toolkit/components/extensions/test/mochitest/file_script_bad.js new file mode 100644 index 0000000000..c425122c71 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_script_bad.js @@ -0,0 +1,3 @@ +"use strict"; + +window.failure = true; diff --git a/toolkit/components/extensions/test/mochitest/file_script_good.js b/toolkit/components/extensions/test/mochitest/file_script_good.js new file mode 100644 index 0000000000..1848edf686 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_script_good.js @@ -0,0 +1,3 @@ +"use strict"; + +window.success = window.success ? window.success + 1 : 1; diff --git a/toolkit/components/extensions/test/mochitest/file_script_redirect.js b/toolkit/components/extensions/test/mochitest/file_script_redirect.js new file mode 100644 index 0000000000..c89a196c2a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_script_redirect.js @@ -0,0 +1,4 @@ +"use strict"; + +window.failure = true; + diff --git a/toolkit/components/extensions/test/mochitest/file_script_xhr.js b/toolkit/components/extensions/test/mochitest/file_script_xhr.js new file mode 100644 index 0000000000..07f80eb2ea --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_script_xhr.js @@ -0,0 +1,5 @@ +"use strict"; + +var request = new XMLHttpRequest(); +request.open("get", "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/xhr_resource", false); +request.send(); diff --git a/toolkit/components/extensions/test/mochitest/file_simple_xhr.html b/toolkit/components/extensions/test/mochitest/file_simple_xhr.html new file mode 100644 index 0000000000..f6ef67277d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_xhr.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + +
+
+ + + diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html new file mode 100644 index 0000000000..06dbd43741 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html @@ -0,0 +1,12 @@ + + + + + + + +
+
+ + + diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html new file mode 100644 index 0000000000..307990714b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html @@ -0,0 +1,12 @@ + + + + + + + +
+
+ + + diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html new file mode 100644 index 0000000000..55bb7aa6ae --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html @@ -0,0 +1,8 @@ + + + + +

page1

+ page2 + + diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html new file mode 100644 index 0000000000..8f589f8bbd --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html @@ -0,0 +1,7 @@ + + + + +

page2

+ + diff --git a/toolkit/components/extensions/test/mochitest/file_with_about_blank.html b/toolkit/components/extensions/test/mochitest/file_with_about_blank.html new file mode 100644 index 0000000000..af51c2e52a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_with_about_blank.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_with_images.html b/toolkit/components/extensions/test/mochitest/file_with_images.html new file mode 100644 index 0000000000..00440cefac --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_with_images.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/head.js b/toolkit/components/extensions/test/mochitest/head.js new file mode 100644 index 0000000000..d92fe1eec4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head.js @@ -0,0 +1,58 @@ +"use strict"; + +/* exported AppConstants, Assert */ + +var {AppConstants} = SpecialPowers.Cu.import("resource://gre/modules/AppConstants.jsm", {}); + +// We run tests under two different configurations, from mochitest.ini and +// mochitest-remote.ini. When running from mochitest-remote.ini, the tests are +// copied to the sub-directory "test-oop-extensions", which we detect here, and +// use to select our configuration. +if (location.pathname.includes("test-oop-extensions")) { + SpecialPowers.pushPrefEnv({set: [ + ["extensions.webextensions.remote", true], + ["layers.popups.compositing.enabled", true], + ]}); + // We don't want to reset this at the end of the test, so that we don't have + // to spawn a new extension child process for each test unit. + SpecialPowers.setIntPref("dom.ipc.keepProcessesAlive.extension", 1); +} + +{ + let chromeScript = SpecialPowers.loadChromeScript( + SimpleTest.getTestFileURL("chrome_cleanup_script.js")); + + SimpleTest.registerCleanupFunction(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + + chromeScript.sendAsyncMessage("check-cleanup"); + + let results = await chromeScript.promiseOneMessage("cleanup-results"); + chromeScript.destroy(); + + if (results.extraWindows.length || results.extraTabs.length) { + ok(false, `Test left extra windows or tabs: ${JSON.stringify(results)}\n`); + } + }); +} + +let Assert = { + rejects(promise, msg) { + return promise.then(() => { + ok(false, msg); + }, () => { + ok(true, msg); + }); + }, +}; + +/* exported waitForLoad */ + +function waitForLoad(win) { + return new Promise(resolve => { + win.addEventListener("load", function() { + resolve(); + }, {capture: true, once: true}); + }); +} + diff --git a/toolkit/components/extensions/test/mochitest/head_cookies.js b/toolkit/components/extensions/test/mochitest/head_cookies.js new file mode 100644 index 0000000000..dd687c6915 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head_cookies.js @@ -0,0 +1,167 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported testCookies */ + +async function testCookies(options) { + // Changing the options object is a bit of a hack, but it allows us to easily + // pass an expiration date to the background script. + options.expiry = Date.now() / 1000 + 3600; + + async function background(backgroundOptions) { + // Ask the parent scope to change some cookies we may or may not have + // permission for. + let awaitChanges = new Promise(resolve => { + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("cookies-changed", msg, "browser.test.onMessage"); + resolve(); + }); + }); + + let changed = []; + browser.cookies.onChanged.addListener(event => { + changed.push(`${event.cookie.name}:${event.cause}`); + }); + browser.test.sendMessage("change-cookies"); + + + // Try to access some cookies in various ways. + let {url, domain, secure} = backgroundOptions; + + let failures = 0; + let tallyFailure = error => { + failures++; + }; + + try { + await awaitChanges; + + let cookie = await browser.cookies.get({url, name: "foo"}); + browser.test.assertEq(backgroundOptions.shouldPass, cookie != null, "should pass == get cookie"); + + let cookies = await browser.cookies.getAll({domain}); + if (backgroundOptions.shouldPass) { + browser.test.assertEq(2, cookies.length, "expected number of cookies"); + } else { + browser.test.assertEq(0, cookies.length, "expected number of cookies"); + } + + await Promise.all([ + browser.cookies.set({url, domain, secure, name: "foo", "value": "baz", expirationDate: backgroundOptions.expiry}).catch(tallyFailure), + browser.cookies.set({url, domain, secure, name: "bar", "value": "quux", expirationDate: backgroundOptions.expiry}).catch(tallyFailure), + browser.cookies.remove({url, name: "deleted"}), + ]); + + if (backgroundOptions.shouldPass) { + // The order of eviction events isn't guaranteed, so just check that + // it's there somewhere. + let evicted = changed.indexOf("evicted:evicted"); + if (evicted < 0) { + browser.test.fail("got no eviction event"); + } else { + browser.test.succeed("got eviction event"); + changed.splice(evicted, 1); + } + + browser.test.assertEq("x:explicit,x:overwrite,x:explicit,x:explicit,foo:overwrite,foo:explicit,bar:explicit,deleted:explicit", + changed.join(","), "expected changes"); + } else { + browser.test.assertEq("", changed.join(","), "expected no changes"); + } + + if (!(backgroundOptions.shouldPass || backgroundOptions.shouldWrite)) { + browser.test.assertEq(2, failures, "Expected failures"); + } else { + browser.test.assertEq(0, failures, "Expected no failures"); + } + + browser.test.notifyPass("cookie-permissions"); + } catch (error) { + browser.test.fail(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("cookie-permissions"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": options.permissions, + }, + + background: `(${background})(${JSON.stringify(options)})`, + }); + + + let cookieSvc = SpecialPowers.Services.cookies; + + let domain = options.domain.replace(/^\.?/, "."); + + // This will be evicted after we add a fourth cookie. + cookieSvc.add(domain, "/", "evicted", "bar", options.secure, false, false, options.expiry); + // This will be modified by the background script. + cookieSvc.add(domain, "/", "foo", "bar", options.secure, false, false, options.expiry); + // This will be deleted by the background script. + cookieSvc.add(domain, "/", "deleted", "bar", options.secure, false, false, options.expiry); + + + await extension.startup(); + + await extension.awaitMessage("change-cookies"); + cookieSvc.add(domain, "/", "x", "y", options.secure, false, false, options.expiry); + cookieSvc.add(domain, "/", "x", "z", options.secure, false, false, options.expiry); + cookieSvc.remove(domain, "x", "/", false, {}); + extension.sendMessage("cookies-changed"); + + await extension.awaitFinish("cookie-permissions"); + await extension.unload(); + + + function getCookies(host) { + let cookies = []; + let enum_ = cookieSvc.getCookiesFromHost(host, {}); + while (enum_.hasMoreElements()) { + cookies.push(enum_.getNext().QueryInterface(SpecialPowers.Ci.nsICookie2)); + } + return cookies.sort((a, b) => a.name.localeCompare(b.name)); + } + + let cookies = getCookies(options.domain); + info(`Cookies: ${cookies.map(c => `${c.name}=${c.value}`)}`); + + if (options.shouldPass) { + is(cookies.length, 2, "expected two cookies for host"); + + is(cookies[0].name, "bar", "correct cookie name"); + is(cookies[0].value, "quux", "correct cookie value"); + + is(cookies[1].name, "foo", "correct cookie name"); + is(cookies[1].value, "baz", "correct cookie value"); + } else if (options.shouldWrite) { + // Note: |shouldWrite| applies only when |shouldPass| is false. + // This is necessary because, unfortunately, websites (and therefore web + // extensions) are allowed to write some cookies which they're not allowed + // to read. + is(cookies.length, 3, "expected three cookies for host"); + + is(cookies[0].name, "bar", "correct cookie name"); + is(cookies[0].value, "quux", "correct cookie value"); + + is(cookies[1].name, "deleted", "correct cookie name"); + + is(cookies[2].name, "foo", "correct cookie name"); + is(cookies[2].value, "baz", "correct cookie value"); + } else { + is(cookies.length, 2, "expected two cookies for host"); + + is(cookies[0].name, "deleted", "correct second cookie name"); + + is(cookies[1].name, "foo", "correct cookie name"); + is(cookies[1].value, "bar", "correct cookie value"); + } + + for (let cookie of cookies) { + cookieSvc.remove(cookie.host, cookie.name, "/", false, {}); + } + // Make sure we don't silently poison subsequent tests if something goes wrong. + is(getCookies(options.domain).length, 0, "cookies cleared"); +} diff --git a/toolkit/components/extensions/test/mochitest/head_webrequest.js b/toolkit/components/extensions/test/mochitest/head_webrequest.js new file mode 100644 index 0000000000..ff45a9f4a3 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head_webrequest.js @@ -0,0 +1,365 @@ +"use strict"; + +let commonEvents = { + "onBeforeRequest": [{urls: [""]}, ["blocking"]], + "onBeforeSendHeaders": [{urls: [""]}, ["blocking", "requestHeaders"]], + "onSendHeaders": [{urls: [""]}, ["requestHeaders"]], + "onBeforeRedirect": [{urls: [""]}], + "onHeadersReceived": [{urls: [""]}, ["blocking", "responseHeaders"]], + // Auth tests will need to set their own events object + // "onAuthRequired": [{urls: [""]}, ["blocking", "responseHeaders"]], + "onResponseStarted": [{urls: [""]}], + "onCompleted": [{urls: [""]}, ["responseHeaders"]], + "onErrorOccurred": [{urls: [""]}], +}; + +function background(events) { + const IP_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; + + let expect; + let ignore; + let defaultOrigin; + let watchAuth = Object.keys(events).includes("onAuthRequired"); + let expectedIp = null; + + browser.test.onMessage.addListener((msg, expected) => { + if (msg !== "set-expected") { + return; + } + expect = expected.expect; + defaultOrigin = expected.origin; + ignore = expected.ignore; + let promises = []; + // Initialize some stuff we'll need in the tests. + for (let entry of Object.values(expect)) { + // a place for the test infrastructure to store some state. + entry.test = {}; + // Each entry in expected gets a Promise that will be resolved in the + // last event for that entry. This will either be onCompleted, or the + // last entry if an events list was provided. + promises.push(new Promise(resolve => { entry.test.resolve = resolve; })); + // If events was left undefined, we're expecting all normal events we're + // listening for, exclude onBeforeRedirect and onErrorOccurred + if (entry.events === undefined) { + entry.events = Object.keys(events).filter(name => name != "onErrorOccurred" && name != "onBeforeRedirect"); + } + if (entry.optional_events === undefined) { + entry.optional_events = []; + } + } + // When every expected entry has finished our test is done. + Promise.all(promises).then(() => { + browser.test.sendMessage("done"); + }); + browser.test.sendMessage("continue"); + }); + + // Retrieve the per-file/test expected values. + function getExpected(details) { + let url = new URL(details.url); + let filename; + if (url.protocol == "data:") { + // pathname is everything after protocol. + filename = url.pathname; + } else { + filename = url.pathname.split("/").pop(); + } + if (ignore && ignore.includes(filename)) { + return; + } + let expected = expect[filename]; + if (!expected) { + browser.test.fail(`unexpected request ${filename}`); + return; + } + // Save filename for redirect verification. + expected.test.filename = filename; + return expected; + } + + // Process any test header modifications that can happen in request or response phases. + // If a test includes headers, it needs a complete header object, no undefined + // objects even if empty: + // request: { + // add: {"HeaderName": "value",}, + // modify: {"HeaderName": "value",}, + // remove: ["HeaderName",], + // }, + // response: { + // add: {"HeaderName": "value",}, + // modify: {"HeaderName": "value",}, + // remove: ["HeaderName",], + // }, + function processHeaders(phase, expected, details) { + // This should only happen once per phase [request|response]. + browser.test.assertFalse(!!expected.test[phase], `First processing of headers for ${phase}`); + expected.test[phase] = true; + + let headers = details[`${phase}Headers`]; + browser.test.assertTrue(Array.isArray(headers), `${phase}Headers array present`); + + let {add, modify, remove} = expected.headers[phase]; + + for (let name in add) { + browser.test.assertTrue(!headers.find(h => h.name === name), `header ${name} to be added not present yet in ${phase}Headers`); + let header = {name: name}; + if (name.endsWith("-binary")) { + header.binaryValue = Array.from(add[name], c => c.charCodeAt(0)); + } else { + header.value = add[name]; + } + headers.push(header); + } + + let modifiedAny = false; + for (let header of headers) { + if (header.name.toLowerCase() in modify) { + header.value = modify[header.name.toLowerCase()]; + modifiedAny = true; + } + } + browser.test.assertTrue(modifiedAny, `at least one ${phase}Headers element to modify`); + + let deletedAny = false; + for (let j = headers.length; j-- > 0;) { + if (remove.includes(headers[j].name.toLowerCase())) { + headers.splice(j, 1); + deletedAny = true; + } + } + browser.test.assertTrue(deletedAny, `at least one ${phase}Headers element to delete`); + + return headers; + } + + // phase is request or response. + function checkHeaders(phase, expected, details) { + if (!/^https?:/.test(details.url)) { + return; + } + + let headers = details[`${phase}Headers`]; + browser.test.assertTrue(Array.isArray(headers), `valid ${phase}Headers array`); + + let {add, modify, remove} = expected.headers[phase]; + for (let name in add) { + let value = headers.find(h => h.name.toLowerCase() === name.toLowerCase()).value; + browser.test.assertEq(value, add[name], `header ${name} correctly injected in ${phase}Headers`); + } + + for (let name in modify) { + let value = headers.find(h => h.name.toLowerCase() === name.toLowerCase()).value; + browser.test.assertEq(value, modify[name], `header ${name} matches modified value`); + } + + for (let name of remove) { + let found = headers.find(h => h.name.toLowerCase() === name.toLowerCase()); + browser.test.assertFalse(!!found, `deleted header ${name} still found in ${phase}Headers`); + } + } + + let listeners = { + onBeforeRequest(expected, details, result) { + // Save some values to test request consistency in later events. + browser.test.assertTrue(details.tabId !== undefined, `tabId ${details.tabId}`); + browser.test.assertTrue(details.requestId !== undefined, `requestId ${details.requestId}`); + // Validate requestId if it's already set, this happens with redirects. + if (expected.test.requestId !== undefined) { + browser.test.assertEq("string", typeof expected.test.requestId, `requestid ${expected.test.requestId} is string`); + browser.test.assertEq("string", typeof details.requestId, `requestid ${details.requestId} is string`); + browser.test.assertEq("number", typeof parseInt(details.requestId, 10), "parsed requestid is number"); + browser.test.assertEq(expected.test.requestId, details.requestId, "redirects will keep the same requestId"); + } else { + // Save any values we want to validate in later events. + expected.test.requestId = details.requestId; + expected.test.tabId = details.tabId; + } + // Tests we don't need to do every event. + browser.test.assertTrue(details.type.toUpperCase() in browser.webRequest.ResourceType, `valid resource type ${details.type}`); + if (details.type == "main_frame") { + browser.test.assertEq(0, details.frameId, "frameId is zero when type is main_frame bug 1329299"); + } + }, + onBeforeSendHeaders(expected, details, result) { + if (expected.headers && expected.headers.request) { + result.requestHeaders = processHeaders("request", expected, details); + } + if (expected.redirect) { + browser.test.log(`${name} redirect request`); + result.redirectUrl = details.url.replace(expected.test.filename, expected.redirect); + } + }, + onBeforeRedirect() {}, + onSendHeaders(expected, details, result) { + if (expected.headers && expected.headers.request) { + checkHeaders("request", expected, details); + } + }, + onResponseStarted() {}, + onHeadersReceived(expected, details, result) { + let expectedStatus = expected.status || 200; + // If authentication is being requested we don't fail on the status code. + if (watchAuth && [401, 407].includes(details.statusCode)) { + expectedStatus = details.statusCode; + } + browser.test.assertEq(expectedStatus, details.statusCode, + `expected HTTP status received for ${details.url} ${details.statusLine}`); + if (expected.headers && expected.headers.response) { + result.responseHeaders = processHeaders("response", expected, details); + } + }, + onAuthRequired(expected, details, result) { + result.authCredentials = expected.authInfo; + }, + onCompleted(expected, details, result) { + // If we have already completed a GET request for this url, + // and it was found, we expect for the response to come fromCache. + // expected.cached may be undefined, force boolean. + if (typeof expected.cached === "boolean") { + let expectCached = expected.cached && details.method === "GET" && details.statusCode != 404; + browser.test.assertEq(expectCached, details.fromCache, "fromCache is correct"); + } + // We can only tell IPs for non-cached HTTP requests. + if (!details.fromCache && /^https?:/.test(details.url)) { + browser.test.assertTrue(IP_PATTERN.test(details.ip), `IP for ${details.url} looks IP-ish: ${details.ip}`); + + // We can't easily predict the IP ahead of time, so just make + // sure they're all consistent. + expectedIp = expectedIp || details.ip; + browser.test.assertEq(expectedIp, details.ip, `correct ip for ${details.url}`); + } + if (expected.headers && expected.headers.response) { + checkHeaders("response", expected, details); + } + }, + onErrorOccurred() {}, + }; + + function getListener(name) { + return details => { + let result = {}; + browser.test.log(`${name} ${details.requestId} ${details.url}`); + let expected = getExpected(details); + if (!expected) { + return result; + } + let expectedEvent = expected.events[0] == name; + if (expectedEvent) { + expected.events.shift(); + } else { + // e10s vs. non-e10s errors can end with either onCompleted or onErrorOccurred + expectedEvent = expected.optional_events.includes(name); + } + browser.test.assertTrue(expectedEvent, `received ${name}`); + browser.test.assertEq(expected.type, details.type, "resource type is correct"); + browser.test.assertEq(expected.origin || defaultOrigin, details.originUrl, "origin is correct"); + // ignore origin test for generated background page + if (!details.originUrl || !details.originUrl.endsWith("_generated_background_page.html")) { + browser.test.assertEq(expected.origin || defaultOrigin, details.originUrl, "origin is correct"); + } + + if (name != "onBeforeRequest") { + // On events after onBeforeRequest, check the previous values. + browser.test.assertEq(expected.test.requestId, details.requestId, "correct requestId"); + browser.test.assertEq(expected.test.tabId, details.tabId, "correct tabId"); + } + try { + listeners[name](expected, details, result); + } catch (e) { + browser.test.fail(`unexpected webrequest failure ${name} ${e}`); + } + + if (expected.cancel && expected.cancel == name) { + browser.test.log(`${name} cancel request`); + browser.test.sendMessage("cancelled"); + result.cancel = true; + } + // If we've used up all the events for this test, resolve the promise. + // If something wrong happens and more events come through, there will be + // failures. + if (expected.events.length <= 0) { + expected.test.resolve(); + } + return result; + }; + } + + for (let [name, args] of Object.entries(events)) { + browser.test.log(`adding listener for ${name}`); + try { + browser.webRequest[name].addListener(getListener(name), ...args); + } catch (e) { + browser.test.assertTrue(/\brequestBody\b/.test(e.message), + "Request body is unsupported"); + + // RequestBody is disabled in release builds. + if (!/\brequestBody\b/.test(e.message)) { + throw e; + } + + args.splice(args.indexOf("requestBody"), 1); + browser.webRequest[name].addListener(getListener(name), ...args); + } + } +} + +/* exported makeExtension */ + +function makeExtension(events = commonEvents) { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "", + ], + }, + background: `(${background})(${JSON.stringify(events)})`, + }); +} + +/* exported addStylesheet */ + +function addStylesheet(file) { + let link = document.createElement("link"); + link.setAttribute("rel", "stylesheet"); + link.setAttribute("href", file); + document.body.appendChild(link); +} + +/* exported addLink */ + +function addLink(file) { + let a = document.createElement("a"); + a.setAttribute("href", file); + a.setAttribute("target", "_blank"); + document.body.appendChild(a); + return a; +} + +/* exported addImage */ + +function addImage(file) { + let img = document.createElement("img"); + img.setAttribute("src", file); + document.body.appendChild(img); +} + +/* exported addScript */ + +function addScript(file) { + let script = document.createElement("script"); + script.setAttribute("type", "text/javascript"); + script.setAttribute("src", file); + document.getElementsByTagName("head").item(0).appendChild(script); +} + +/* exported addFrame */ + +function addFrame(file) { + let frame = document.createElement("iframe"); + frame.setAttribute("width", "200"); + frame.setAttribute("height", "200"); + frame.setAttribute("src", file); + document.body.appendChild(frame); +} diff --git a/toolkit/components/extensions/test/mochitest/mochitest-common.ini b/toolkit/components/extensions/test/mochitest/mochitest-common.ini new file mode 100644 index 0000000000..b7608394fc --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest-common.ini @@ -0,0 +1,122 @@ +[DEFAULT] +support-files = + chrome_cleanup_script.js + head.js + file_mixed.html + head_webrequest.js + file_csp.html + file_csp.html^headers^ + file_to_drawWindow.html + file_WebRequest_page3.html + file_WebRequest_permission_original.html + file_WebRequest_permission_redirected.html + file_WebRequest_permission_original.js + file_WebRequest_permission_redirected.js + file_webNavigation_clientRedirect.html + file_webNavigation_clientRedirect_httpHeaders.html + file_webNavigation_clientRedirect_httpHeaders.html^headers^ + file_webNavigation_frameClientRedirect.html + file_webNavigation_frameRedirect.html + file_webNavigation_manualSubframe.html + file_webNavigation_manualSubframe_page1.html + file_webNavigation_manualSubframe_page2.html + file_WebNavigation_page1.html + file_WebNavigation_page2.html + file_WebNavigation_page3.html + file_with_about_blank.html + file_image_good.png + file_image_bad.png + file_image_redirect.png + file_style_good.css + file_style_bad.css + file_style_redirect.css + file_script_good.js + file_script_bad.js + file_script_redirect.js + file_script_xhr.js + file_remote_frame.html + file_sample.html + file_simple_xhr.html + file_simple_xhr_frame.html + file_simple_xhr_frame2.html + redirection.sjs + file_privilege_escalation.html + file_ext_test_api_injection.js + file_permission_xhr.html + file_teardown_test.js + return_headers.sjs + webrequest_worker.js + !/dom/tests/mochitest/geolocation/network_geolocation.sjs + +[test_ext_clipboard.html] +# skip-if = # disabled test case with_permission_allow_copy, see inline comment. +[test_ext_inIncognitoContext_window.html] +skip-if = os == 'android' # Android does not support multiple windows. +[test_ext_geturl.html] +[test_ext_background_canvas.html] +[test_ext_content_security_policy.html] +[test_ext_contentscript_api_injection.html] +[test_ext_contentscript_async_loading.html] +[test_ext_contentscript_cache.html] +skip-if = (os == 'linux' && debug) || (toolkit == 'android' && debug) # bug 1348241 +[test_ext_contentscript_canvas.html] +[test_ext_contentscript_context.html] +[test_ext_contentscript_create_iframe.html] +[test_ext_contentscript_devtools_metadata.html] +[test_ext_contentscript_exporthelpers.html] +[test_ext_contentscript_incognito.html] +skip-if = os == 'android' # Android does not support multiple windows. +[test_ext_contentscript_css.html] +[test_ext_contentscript_about_blank.html] +skip-if = os == 'android' # bug 1369440 +[test_ext_contentscript_permission.html] +[test_ext_contentscript_teardown.html] +[test_ext_exclude_include_globs.html] +[test_ext_external_messaging.html] +[test_ext_generate.html] +[test_ext_geolocation.html] +skip-if = os == 'android' # Android support Bug 1336194 +[test_ext_notifications.html] +[test_ext_permission_xhr.html] +[test_ext_proxy.html] +[test_ext_runtime_connect.html] +[test_ext_runtime_connect_twoway.html] +[test_ext_runtime_connect2.html] +[test_ext_runtime_disconnect.html] +[test_ext_runtime_id.html] +[test_ext_sandbox_var.html] +[test_ext_sendmessage_doublereply.html] +[test_ext_sendmessage_frameId.html] +[test_ext_sendmessage_no_receiver.html] +[test_ext_sendmessage_reply.html] +[test_ext_sendmessage_reply2.html] +[test_ext_storage_content.html] +[test_ext_storage_tab.html] +[test_ext_storage_manager_capabilities.html] +scheme=https +[test_ext_test.html] +[test_ext_cookies.html] +[test_ext_background_api_injection.html] +[test_ext_background_generated_url.html] +[test_ext_background_teardown.html] +[test_ext_tab_teardown.html] +skip-if = os == 'android' # Bug 1258975 on android. +[test_ext_unload_frame.html] +[test_ext_listener_proxies.html] +[test_ext_web_accessible_resources.html] +[test_ext_webrequest_auth.html] +skip-if = os == 'android' +[test_ext_webrequest_background_events.html] +[test_ext_webrequest_basic.html] +[test_ext_webrequest_filter.html] +[test_ext_webrequest_frameId.html] +[test_ext_webrequest_suspend.html] +[test_ext_webrequest_upload.html] +skip-if = os == 'android' # Currently fails in emulator tests +[test_ext_webrequest_permission.html] +[test_ext_webrequest_websocket.html] +[test_ext_webnavigation.html] +[test_ext_webnavigation_filters.html] +[test_ext_window_postMessage.html] +[test_ext_subframes_privileges.html] +[test_ext_xhr_capabilities.html] diff --git a/toolkit/components/extensions/test/mochitest/mochitest-remote.ini b/toolkit/components/extensions/test/mochitest/mochitest-remote.ini new file mode 100644 index 0000000000..3f31cb04c5 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest-remote.ini @@ -0,0 +1,12 @@ +[DEFAULT] +# This is a horrible hack: +# In order to run tests under two configurations, we create two mochitest +# manifests, and include a manifest with a common set of tests from each. In +# order to detect which manifest we're running from, we install the tests listed +# in this manifest to the sub-directory "test-oop-extensions", and then check +# whether we're running from that directory from head.js +install-to-subdir = test-oop-extensions +tags = webextensions remote-webextensions +skip-if = !e10s + +[include:mochitest-common.ini] diff --git a/toolkit/components/extensions/test/mochitest/mochitest.ini b/toolkit/components/extensions/test/mochitest/mochitest.ini new file mode 100644 index 0000000000..29a79a151e --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest.ini @@ -0,0 +1,4 @@ +[DEFAULT] +tags = webextensions in-process-webextensions + +[include:mochitest-common.ini] diff --git a/toolkit/components/extensions/test/mochitest/oauth.html b/toolkit/components/extensions/test/mochitest/oauth.html new file mode 100644 index 0000000000..428ffb8ebc --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/oauth.html @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/redirect_auto.sjs b/toolkit/components/extensions/test/mochitest/redirect_auto.sjs new file mode 100644 index 0000000000..1f2bac3272 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/redirect_auto.sjs @@ -0,0 +1,17 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +Components.utils.importGlobalProperties(["URLSearchParams", "URL"]); + +function handleRequest(request, response) { + let params = new URLSearchParams(request.queryString); + if (params.has("no_redirect")) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); + } else { + response.setStatusLine(request.httpVersion, 302, "Moved Temporarily"); + let url = new URL(params.get("redirect_uri")); + url.searchParams.set("access_token", "here ya go"); + response.setHeader("Location", url.href); + } +} diff --git a/toolkit/components/extensions/test/mochitest/redirection.sjs b/toolkit/components/extensions/test/mochitest/redirection.sjs new file mode 100644 index 0000000000..370ecd213f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/redirection.sjs @@ -0,0 +1,4 @@ +function handleRequest(aRequest, aResponse) { + aResponse.setStatusLine(aRequest.httpVersion, 302); + aResponse.setHeader("Location", "./dummy_page.html"); +} diff --git a/toolkit/components/extensions/test/mochitest/return_headers.sjs b/toolkit/components/extensions/test/mochitest/return_headers.sjs new file mode 100644 index 0000000000..54e2e5fb4d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/return_headers.sjs @@ -0,0 +1,20 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported handleRequest */ + +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain", false); + + let headers = {}; + // Why on earth is this a nsISimpleEnumerator... + let enumerator = request.headers; + while (enumerator.hasMoreElements()) { + let header = enumerator.getNext().data; + headers[header.toLowerCase()] = request.getHeader(header); + } + + response.write(JSON.stringify(headers)); +} + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_background_page.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_background_page.html new file mode 100644 index 0000000000..52bb052cf6 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_background_page.html @@ -0,0 +1,84 @@ + + + + WebExtension test + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html new file mode 100644 index 0000000000..d975c55674 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html @@ -0,0 +1,98 @@ + + + + Test content script matching a data: URI + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html new file mode 100644 index 0000000000..a296de022f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html @@ -0,0 +1,80 @@ + + + + Test for content script unrecognized property on manifest + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html new file mode 100644 index 0000000000..86279ab850 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html @@ -0,0 +1,131 @@ + + + + Test downloads.download() saveAs option + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_eventpage_warning.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_eventpage_warning.html new file mode 100644 index 0000000000..9d9ab74314 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_eventpage_warning.html @@ -0,0 +1,106 @@ + + + + Test for WebExtension EventPage Warning + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_hybrid_addons.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_hybrid_addons.html new file mode 100644 index 0000000000..c270f8cd15 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_hybrid_addons.html @@ -0,0 +1,141 @@ + + + + Test for hybrid addons: SDK or bootstrap.js + embedded WebExtension + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_identity.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_identity.html new file mode 100644 index 0000000000..adf67b6ffe --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_identity.html @@ -0,0 +1,236 @@ + + + + Test for WebExtension Identity + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_idle.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_idle.html new file mode 100644 index 0000000000..7ea4265aa2 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_idle.html @@ -0,0 +1,66 @@ + + + + WebExtension test + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html new file mode 100644 index 0000000000..7753ad6013 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html @@ -0,0 +1,177 @@ + + + + Test for permissions + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_storage_cleanup.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_storage_cleanup.html new file mode 100644 index 0000000000..c726db1797 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_storage_cleanup.html @@ -0,0 +1,164 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html new file mode 100644 index 0000000000..5588676bf0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html @@ -0,0 +1,100 @@ + + + + Test for simple WebExtension + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_trustworthy_origin.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_trustworthy_origin.html new file mode 100644 index 0000000000..fdeca9333d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_trustworthy_origin.html @@ -0,0 +1,55 @@ + + + + WebExtension test + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html new file mode 100644 index 0000000000..2231393fdf --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html @@ -0,0 +1,83 @@ + + + + Test for simple WebExtension + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html new file mode 100644 index 0000000000..37852a0187 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html @@ -0,0 +1,97 @@ + + + + Test for simple WebExtension + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_errors.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_errors.html new file mode 100644 index 0000000000..772a728620 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_errors.html @@ -0,0 +1,61 @@ + + + + Test for WebRequest errors + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html new file mode 100644 index 0000000000..5e9fcc572f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html @@ -0,0 +1,86 @@ + + + + Test webRequest checks host permissions + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html b/toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html new file mode 100644 index 0000000000..22ae16f515 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html @@ -0,0 +1,61 @@ + + + + WebExtension test + + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js new file mode 100644 index 0000000000..4925cf3e7e --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js @@ -0,0 +1,167 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Tests whether not too many APIs are visible by default. +// This file is used by test_ext_all_apis.html in browser/ and mobile/android/, +// which may modify the following variables to add or remove expected APIs. +/* globals expectedContentApisTargetSpecific */ +/* globals expectedBackgroundApisTargetSpecific */ + +// Generates a list of expectations. +function generateExpectations(list) { + return list.reduce((allApis, path) => { + return allApis.concat(`browser.${path}`, `chrome.${path}`); + }, []).sort(); +} + +let expectedCommonApis = [ + "extension.getURL", + "extension.inIncognitoContext", + "extension.lastError", + "i18n.detectLanguage", + "i18n.getAcceptLanguages", + "i18n.getMessage", + "i18n.getUILanguage", + "runtime.OnInstalledReason", + "runtime.OnRestartRequiredReason", + "runtime.PlatformArch", + "runtime.PlatformOs", + "runtime.RequestUpdateCheckStatus", + "runtime.getManifest", + "runtime.connect", + "runtime.getURL", + "runtime.id", + "runtime.lastError", + "runtime.onConnect", + "runtime.onMessage", + "runtime.sendMessage", + // If you want to add a new powerful test API, please see bug 1287233. + "test.assertEq", + "test.assertFalse", + "test.assertRejects", + "test.assertThrows", + "test.assertTrue", + "test.fail", + "test.log", + "test.notifyFail", + "test.notifyPass", + "test.onMessage", + "test.sendMessage", + "test.succeed", +]; + +let expectedContentApis = [ + ...expectedCommonApis, + ...expectedContentApisTargetSpecific, +]; + +let expectedBackgroundApis = [ + ...expectedCommonApis, + ...expectedBackgroundApisTargetSpecific, + "extension.ViewType", + "extension.getBackgroundPage", + "extension.getViews", + "extension.isAllowedFileSchemeAccess", + "extension.isAllowedIncognitoAccess", + // Note: extensionTypes is not visible in Chrome. + "extensionTypes.CSSOrigin", + "extensionTypes.ImageFormat", + "extensionTypes.RunAt", + "management.ExtensionDisabledReason", + "management.ExtensionInstallType", + "management.ExtensionType", + "management.getSelf", + "management.uninstallSelf", + "permissions.getAll", + "permissions.contains", + "permissions.request", + "permissions.remove", + "runtime.getBackgroundPage", + "runtime.getBrowserInfo", + "runtime.getPlatformInfo", + "runtime.onConnectExternal", + "runtime.onInstalled", + "runtime.onMessageExternal", + "runtime.onStartup", + "runtime.onUpdateAvailable", + "runtime.openOptionsPage", + "runtime.reload", + "runtime.setUninstallURL", + "types.LevelOfControl", + "types.SettingScope", +]; + +function sendAllApis() { + function isEvent(key, val) { + if (!/^on[A-Z]/.test(key)) { + return false; + } + let eventKeys = []; + for (let prop in val) { + eventKeys.push(prop); + } + eventKeys = eventKeys.sort().join(); + return eventKeys === "addListener,hasListener,removeListener"; + } + function mayRecurse(key, val) { + if (Object.keys(val).filter(k => !/^[A-Z\-0-9_]+$/.test(k)).length === 0) { + // Don't recurse on constants and empty objects. + return false; + } + return !isEvent(key, val); + } + + let results = []; + function diveDeeper(path, obj) { + for (let key in obj) { + let val = obj[key]; + if (typeof val == "object" && val !== null && mayRecurse(key, val)) { + diveDeeper(`${path}.${key}`, val); + } else if (val !== undefined) { + results.push(`${path}.${key}`); + } + } + } + diveDeeper("browser", browser); + diveDeeper("chrome", chrome); + browser.test.sendMessage("allApis", results.sort()); +} + +add_task(async function test_enumerate_content_script_apis() { + let extensionData = { + manifest: { + content_scripts: [{ + matches: ["http://mochi.test/*/file_sample.html"], + js: ["contentscript.js"], + run_at: "document_start", + }], + }, + files: { + "contentscript.js": sendAllApis, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let win = window.open("file_sample.html"); + let actualApis = await extension.awaitMessage("allApis"); + win.close(); + let expectedApis = generateExpectations(expectedContentApis); + isDeeply(actualApis, expectedApis, "content script APIs"); + + await extension.unload(); +}); + +add_task(async function test_enumerate_background_script_apis() { + let extensionData = { + background: sendAllApis, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let actualApis = await extension.awaitMessage("allApis"); + let expectedApis = generateExpectations(expectedBackgroundApis); + isDeeply(actualApis, expectedApis, "background script APIs"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/mochitest/test_ext_background_api_injection.html b/toolkit/components/extensions/test/mochitest/test_ext_background_api_injection.html new file mode 100644 index 0000000000..5d520a0c26 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_background_api_injection.html @@ -0,0 +1,46 @@ + + + + Test for privilege escalation into content pages + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html b/toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html new file mode 100644 index 0000000000..e29dc00ea0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html @@ -0,0 +1,47 @@ + + + + Test for background page canvas rendering + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_background_generated_url.html b/toolkit/components/extensions/test/mochitest/test_ext_background_generated_url.html new file mode 100644 index 0000000000..7ebc011247 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_background_generated_url.html @@ -0,0 +1,54 @@ + + + + Test _generated_background_page.html + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_background_teardown.html b/toolkit/components/extensions/test/mochitest/test_ext_background_teardown.html new file mode 100644 index 0000000000..f1a5b0a201 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_background_teardown.html @@ -0,0 +1,76 @@ + + + + Test for background script teardown + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_clipboard.html b/toolkit/components/extensions/test/mochitest/test_ext_clipboard.html new file mode 100644 index 0000000000..1656b06b1b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_clipboard.html @@ -0,0 +1,213 @@ + + + + Clipboard permissions tests + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_content_security_policy.html b/toolkit/components/extensions/test/mochitest/test_ext_content_security_policy.html new file mode 100644 index 0000000000..88453b3955 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_content_security_policy.html @@ -0,0 +1,160 @@ + + + + WebExtension CSP test + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html new file mode 100644 index 0000000000..3d71cc8a45 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html @@ -0,0 +1,117 @@ + + + + Test content script match_about_blank option + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_api_injection.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_api_injection.html new file mode 100644 index 0000000000..ef4f5ef042 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_api_injection.html @@ -0,0 +1,88 @@ + + + + Test for privilege escalation into iframe with content script APIs + + + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_async_loading.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_async_loading.html new file mode 100644 index 0000000000..ee72a32672 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_async_loading.html @@ -0,0 +1,54 @@ + + + + Test content script async loading + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html new file mode 100644 index 0000000000..89c0ce1585 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html @@ -0,0 +1,113 @@ + + + + Test for content script caching + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html new file mode 100644 index 0000000000..fdbf3d8ad9 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html @@ -0,0 +1,109 @@ + + + + Test content script access to canvas drawWindow() + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_context.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_context.html new file mode 100644 index 0000000000..2736e5aada --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_context.html @@ -0,0 +1,81 @@ + + + + Test for content script contexts + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_create_iframe.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_create_iframe.html new file mode 100644 index 0000000000..087082e116 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_create_iframe.html @@ -0,0 +1,165 @@ + + + + Test for content script + + + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_css.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_css.html new file mode 100644 index 0000000000..e7b7c22839 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_css.html @@ -0,0 +1,60 @@ + + + + Test for content script + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html new file mode 100644 index 0000000000..5e8d593526 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html @@ -0,0 +1,81 @@ + + + + Test for Sandbox metadata on WebExtensions ContentScripts + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_exporthelpers.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_exporthelpers.html new file mode 100644 index 0000000000..2d20cda63c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_exporthelpers.html @@ -0,0 +1,95 @@ + + + + Test for content script + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html new file mode 100644 index 0000000000..17481fe56d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html @@ -0,0 +1,89 @@ + + + + Test for content script private browsing ID + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html new file mode 100644 index 0000000000..452e87fa9f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html @@ -0,0 +1,60 @@ + + + + Test for content script + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_teardown.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_teardown.html new file mode 100644 index 0000000000..faca204c3e --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_teardown.html @@ -0,0 +1,96 @@ + + + + Test for content script teardown + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies.html new file mode 100644 index 0000000000..b065502d3b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies.html @@ -0,0 +1,237 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html new file mode 100644 index 0000000000..276c80cba8 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html @@ -0,0 +1,93 @@ + + + + WebExtension test + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html new file mode 100644 index 0000000000..2698b7fc10 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html @@ -0,0 +1,72 @@ + + + + WebExtension test + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html new file mode 100644 index 0000000000..2f4530a8b4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html @@ -0,0 +1,112 @@ + + + + WebExtension test + + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html new file mode 100644 index 0000000000..86d74a40b8 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html @@ -0,0 +1,86 @@ + + + + WebExtension test + + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html b/toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html new file mode 100644 index 0000000000..f1858638b9 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html @@ -0,0 +1,92 @@ + + + + Test for content script + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html b/toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html new file mode 100644 index 0000000000..7b3fc76bcd --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html @@ -0,0 +1,111 @@ + + + + WebExtension external messaging + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_generate.html b/toolkit/components/extensions/test/mochitest/test_ext_generate.html new file mode 100644 index 0000000000..15d3d1b64c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_generate.html @@ -0,0 +1,49 @@ + + + + Test for generating WebExtensions + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_geolocation.html b/toolkit/components/extensions/test/mochitest/test_ext_geolocation.html new file mode 100644 index 0000000000..c056fdd9a0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_geolocation.html @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_geturl.html b/toolkit/components/extensions/test/mochitest/test_ext_geturl.html new file mode 100644 index 0000000000..c9c2541a11 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_geturl.html @@ -0,0 +1,72 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html b/toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html new file mode 100644 index 0000000000..3687dead14 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html @@ -0,0 +1,49 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_jsversion.html b/toolkit/components/extensions/test/mochitest/test_ext_jsversion.html new file mode 100644 index 0000000000..5833700419 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_jsversion.html @@ -0,0 +1,86 @@ + + + + Test for simple WebExtension + + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html b/toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html new file mode 100644 index 0000000000..72e79627f8 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html @@ -0,0 +1,63 @@ + + + + Test for content script + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_notifications.html b/toolkit/components/extensions/test/mochitest/test_ext_notifications.html new file mode 100644 index 0000000000..2d41296237 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_notifications.html @@ -0,0 +1,224 @@ + + + + Test for notifications + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_permission_xhr.html b/toolkit/components/extensions/test/mochitest/test_ext_permission_xhr.html new file mode 100644 index 0000000000..078d06c1a3 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_permission_xhr.html @@ -0,0 +1,119 @@ + + + + WebExtension Test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html b/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html new file mode 100644 index 0000000000..a20736e324 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html @@ -0,0 +1,253 @@ + + + + Test for protocol handlers + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_proxy.html b/toolkit/components/extensions/test/mochitest/test_ext_proxy.html new file mode 100644 index 0000000000..898727d5c9 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_proxy.html @@ -0,0 +1,105 @@ + + + + Tests for the proxy API + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html new file mode 100644 index 0000000000..5202078210 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html @@ -0,0 +1,83 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html new file mode 100644 index 0000000000..2757816dfb --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html @@ -0,0 +1,103 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html new file mode 100644 index 0000000000..1a7f757d7b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html @@ -0,0 +1,127 @@ + + + + WebExtension test + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html new file mode 100644 index 0000000000..80061b68ce --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html @@ -0,0 +1,78 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_id.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_id.html new file mode 100644 index 0000000000..56f5cfee55 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_id.html @@ -0,0 +1,61 @@ + + + + + Test for browser.runtime.id + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sandbox_var.html b/toolkit/components/extensions/test/mochitest/test_ext_sandbox_var.html new file mode 100644 index 0000000000..b62c3581cf --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sandbox_var.html @@ -0,0 +1,60 @@ + + + + Test for content script + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_schema.html b/toolkit/components/extensions/test/mochitest/test_ext_schema.html new file mode 100644 index 0000000000..1a0529f443 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_schema.html @@ -0,0 +1,73 @@ + + + + Test for schema API creation + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html new file mode 100644 index 0000000000..092bb7c08c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html @@ -0,0 +1,101 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html new file mode 100644 index 0000000000..8d0dae6c7a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html @@ -0,0 +1,46 @@ + + + Test sendMessage frameId + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html new file mode 100644 index 0000000000..426e77b6ec --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html @@ -0,0 +1,83 @@ + + + + WebExtension test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html new file mode 100644 index 0000000000..46528bd90c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html @@ -0,0 +1,79 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html new file mode 100644 index 0000000000..da16948fe0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html @@ -0,0 +1,181 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_storage_content.html b/toolkit/components/extensions/test/mochitest/test_ext_storage_content.html new file mode 100644 index 0000000000..59a945df51 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_content.html @@ -0,0 +1,256 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html b/toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html new file mode 100644 index 0000000000..6661c16576 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html @@ -0,0 +1,127 @@ + + + + Test Storage API + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_storage_tab.html b/toolkit/components/extensions/test/mochitest/test_ext_storage_tab.html new file mode 100644 index 0000000000..809608ef55 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_tab.html @@ -0,0 +1,118 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html b/toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html new file mode 100644 index 0000000000..88abb1ab26 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html @@ -0,0 +1,241 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tab_teardown.html b/toolkit/components/extensions/test/mochitest/test_ext_tab_teardown.html new file mode 100644 index 0000000000..d403f9741c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tab_teardown.html @@ -0,0 +1,150 @@ + + + + Test for extension tab teardown + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_test.html b/toolkit/components/extensions/test/mochitest/test_ext_test.html new file mode 100644 index 0000000000..81713e08be --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_test.html @@ -0,0 +1,191 @@ + + + + Testing test + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_unload_frame.html b/toolkit/components/extensions/test/mochitest/test_ext_unload_frame.html new file mode 100644 index 0000000000..56fe1744f2 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_unload_frame.html @@ -0,0 +1,169 @@ + + + + WebExtensions test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html new file mode 100644 index 0000000000..5c939dc986 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html @@ -0,0 +1,353 @@ + + + + Test the web_accessible_resources manifest directive + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html new file mode 100644 index 0000000000..df9919ad35 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html @@ -0,0 +1,611 @@ + + + + Test for simple WebExtension + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html new file mode 100644 index 0000000000..bb4039f5d3 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html @@ -0,0 +1,299 @@ + + + + Test for simple WebExtension + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html new file mode 100644 index 0000000000..192a5a589c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html @@ -0,0 +1,428 @@ + + + + + + + + + + + + + + +
Authorization Test
+ + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html new file mode 100644 index 0000000000..14861e6099 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html @@ -0,0 +1,114 @@ + + + + Test for simple WebExtension + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html new file mode 100644 index 0000000000..2de12bd757 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html @@ -0,0 +1,403 @@ + + + + + + + + + + + + + + +
Sample text
+ + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html new file mode 100644 index 0000000000..98b6f88151 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html new file mode 100644 index 0000000000..e7d4f79743 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + +
Sample text
+ + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_permission.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_permission.html new file mode 100644 index 0000000000..666bc838e2 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_permission.html @@ -0,0 +1,73 @@ + + + + Test for content script + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_suspend.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_suspend.html new file mode 100644 index 0000000000..0f66ff973e --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_suspend.html @@ -0,0 +1,222 @@ + + + + Test for simple WebExtension + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html new file mode 100644 index 0000000000..7d77e47bc8 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html @@ -0,0 +1,205 @@ + + + + + + + + + + + + + +
+ + + + +
+ +
+ + + +
+ + +
+ + +
+ + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_websocket.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_websocket.html new file mode 100644 index 0000000000..411af50f78 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_websocket.html @@ -0,0 +1,56 @@ + + + + + Basic websocket test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html b/toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html new file mode 100644 index 0000000000..d7a637e4ac --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html @@ -0,0 +1,105 @@ + + + + Test for content script + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_xhr_capabilities.html b/toolkit/components/extensions/test/mochitest/test_ext_xhr_capabilities.html new file mode 100644 index 0000000000..3fd9341c4c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_xhr_capabilities.html @@ -0,0 +1,86 @@ + + + + Test XHR capabilities + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js b/toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js new file mode 100644 index 0000000000..6a44fcac2e --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js @@ -0,0 +1,9 @@ +"use strict"; + +/* eslint-env worker */ + +onmessage = function(event) { + fetch("https://example.com/example.txt").then(() => { + postMessage("Done!"); + }); +}; diff --git a/toolkit/components/extensions/test/mochitest/webrequest_test.jsm b/toolkit/components/extensions/test/mochitest/webrequest_test.jsm new file mode 100644 index 0000000000..bfb1483018 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/webrequest_test.jsm @@ -0,0 +1,22 @@ +"use strict"; + +this.EXPORTED_SYMBOLS = ["webrequest_test"]; + +Components.utils.importGlobalProperties(["fetch", "XMLHttpRequest"]); + +this.webrequest_test = { + testFetch(url) { + return fetch(url); + }, + + testXHR(url) { + return new Promise(resolve => { + let xhr = new XMLHttpRequest(); + xhr.open("HEAD", url); + xhr.onload = () => { + resolve(); + }; + xhr.send(); + }); + }, +}; diff --git a/toolkit/components/extensions/test/mochitest/webrequest_worker.js b/toolkit/components/extensions/test/mochitest/webrequest_worker.js new file mode 100644 index 0000000000..dcffd08578 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/webrequest_worker.js @@ -0,0 +1,3 @@ +"use strict"; + +fetch("https://example.com/example.txt"); diff --git a/toolkit/components/extensions/test/xpcshell/.eslintrc.js b/toolkit/components/extensions/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..663594ea9b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/.eslintrc.js @@ -0,0 +1,11 @@ +"use strict"; + +module.exports = { + "extends": "plugin:mozilla/xpcshell-test", + + "env": { + // The tests in this folder are testing based on WebExtensions, so lets + // just define the webextensions environment here. + "webextensions": true + } +}; diff --git a/toolkit/components/extensions/test/xpcshell/data/file_download.html b/toolkit/components/extensions/test/xpcshell/data/file_download.html new file mode 100644 index 0000000000..d970c63259 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_download.html @@ -0,0 +1,12 @@ + + + + + + + + +
Download HTML File
+ + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_download.txt b/toolkit/components/extensions/test/xpcshell/data/file_download.txt new file mode 100644 index 0000000000..6293c7af79 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_download.txt @@ -0,0 +1 @@ +This is a sample file used in download tests. diff --git a/toolkit/components/extensions/test/xpcshell/data/file_iframe.html b/toolkit/components/extensions/test/xpcshell/data/file_iframe.html new file mode 100644 index 0000000000..0cd68be586 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_iframe.html @@ -0,0 +1,9 @@ + + + + + Iframe document + + + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_sample.html b/toolkit/components/extensions/test/xpcshell/data/file_sample.html new file mode 100644 index 0000000000..a20e49a1f0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_sample.html @@ -0,0 +1,12 @@ + + + + + + + + +
Sample text
+ + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html b/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html new file mode 100644 index 0000000000..d93813d0f5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html @@ -0,0 +1,12 @@ + + + + + Top-level frame document + + + + + + + diff --git a/toolkit/components/extensions/test/xpcshell/head.js b/toolkit/components/extensions/test/xpcshell/head.js new file mode 100644 index 0000000000..2fe5160d19 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head.js @@ -0,0 +1,113 @@ +"use strict"; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +/* exported createHttpServer, promiseConsoleOutput, cleanupDir */ + +Components.utils.import("resource://gre/modules/AppConstants.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Timer.jsm"); +Components.utils.import("resource://testing-common/AddonTestUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ContentTask", + "resource://testing-common/ContentTask.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Extension", + "resource://gre/modules/Extension.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionData", + "resource://gre/modules/Extension.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionTestUtils", + "resource://testing-common/ExtensionXPCShellUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "HttpServer", + "resource://testing-common/httpd.js"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Schemas", + "resource://gre/modules/Schemas.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +ExtensionTestUtils.init(this); + +/** + * Creates a new HttpServer for testing, and begins listening on the + * specified port. Automatically shuts down the server when the test + * unit ends. + * + * @param {integer} [port] + * The port to listen on. If omitted, listen on a random + * port. The latter is the preferred behavior. + * + * @returns {HttpServer} + */ +function createHttpServer(port = -1) { + let server = new HttpServer(); + server.start(port); + + do_register_cleanup(() => { + return new Promise(resolve => { + server.stop(resolve); + }); + }); + + return server; +} + +if (AppConstants.platform === "android") { + Services.io.offline = true; +} + +var promiseConsoleOutput = async function(task) { + const DONE = `=== console listener ${Math.random()} done ===`; + + let listener; + let messages = []; + let awaitListener = new Promise(resolve => { + listener = msg => { + if (msg == DONE) { + resolve(); + } else { + void (msg instanceof Ci.nsIConsoleMessage); + messages.push(msg); + } + }; + }); + + Services.console.registerListener(listener); + try { + let result = await task(); + + Services.console.logStringMessage(DONE); + await awaitListener; + + return {messages, result}; + } finally { + Services.console.unregisterListener(listener); + } +}; + +// Attempt to remove a directory. If the Windows OS is still using the +// file sometimes remove() will fail. So try repeatedly until we can +// remove it or we give up. +function cleanupDir(dir) { + let count = 0; + return new Promise((resolve, reject) => { + function tryToRemoveDir() { + count += 1; + try { + dir.remove(true); + } catch (e) { + // ignore + } + if (!dir.exists()) { + return resolve(); + } + if (count >= 25) { + return reject(`Failed to cleanup directory: ${dir}`); + } + setTimeout(tryToRemoveDir, 100); + } + tryToRemoveDir(); + }); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_native_messaging.js b/toolkit/components/extensions/test/xpcshell/head_native_messaging.js new file mode 100644 index 0000000000..b74a67ca19 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_native_messaging.js @@ -0,0 +1,131 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals AppConstants, FileUtils */ +/* exported getSubprocessCount, setupHosts, waitForSubprocessExit */ + +XPCOMUtils.defineLazyModuleGetter(this, "MockRegistry", + "resource://testing-common/MockRegistry.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "setTimeout", + "resource://gre/modules/Timer.jsm"); + +let {Subprocess, SubprocessImpl} = Cu.import("resource://gre/modules/Subprocess.jsm", {}); + + +// It's important that we use a space in this directory name to make sure we +// correctly handle executing batch files with spaces in their path. +let tmpDir = FileUtils.getDir("TmpD", ["Native Messaging"]); +tmpDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +do_register_cleanup(() => { + tmpDir.remove(true); +}); + +function getPath(filename) { + return OS.Path.join(tmpDir.path, filename); +} + +const ID = "native@tests.mozilla.org"; + + +async function setupHosts(scripts) { + const PERMS = {unixMode: 0o755}; + + const env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment); + const pythonPath = await Subprocess.pathSearch(env.get("PYTHON")); + + async function writeManifest(script, scriptPath, path) { + let body = `#!${pythonPath} -u\n${script.script}`; + + await OS.File.writeAtomic(scriptPath, body); + await OS.File.setPermissions(scriptPath, PERMS); + + let manifest = { + name: script.name, + description: script.description, + path, + type: "stdio", + allowed_extensions: [ID], + }; + + let manifestPath = getPath(`${script.name}.json`); + await OS.File.writeAtomic(manifestPath, JSON.stringify(manifest)); + + return manifestPath; + } + + switch (AppConstants.platform) { + case "macosx": + case "linux": + let dirProvider = { + getFile(property) { + if (property == "XREUserNativeMessaging") { + return tmpDir.clone(); + } else if (property == "XRESysNativeMessaging") { + return tmpDir.clone(); + } + return null; + }, + }; + + Services.dirsvc.registerProvider(dirProvider); + do_register_cleanup(() => { + Services.dirsvc.unregisterProvider(dirProvider); + }); + + for (let script of scripts) { + let path = getPath(`${script.name}.py`); + + await writeManifest(script, path, path); + } + break; + + case "win": + const REGKEY = String.raw`Software\Mozilla\NativeMessagingHosts`; + + let registry = new MockRegistry(); + do_register_cleanup(() => { + registry.shutdown(); + }); + + for (let script of scripts) { + // It's important that we use a space in this filename. See directory + // name comment above. + let batPath = getPath(`batch ${script.name}.bat`); + let scriptPath = getPath(`${script.name}.py`); + + let batBody = `@ECHO OFF\n${pythonPath} -u "${scriptPath}" %*\n`; + await OS.File.writeAtomic(batPath, batBody); + + // Create absolute and relative path versions of the entry. + for (let [name, path] of [[script.name, batPath], + [`relative.${script.name}`, OS.Path.basename(batPath)]]) { + script.name = name; + let manifestPath = await writeManifest(script, scriptPath, path); + + registry.setValue(Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGKEY}\\${script.name}`, "", manifestPath); + } + } + break; + + default: + ok(false, `Native messaging is not supported on ${AppConstants.platform}`); + } +} + + +function getSubprocessCount() { + return SubprocessImpl.Process.getWorker().call("getProcesses", []) + .then(result => result.size); +} +function waitForSubprocessExit() { + return SubprocessImpl.Process.getWorker().call("waitForNoProcesses", []).then(() => { + // Return to the main event loop to give IO handlers enough time to consume + // their remaining buffered input. + return new Promise(resolve => setTimeout(resolve, 0)); + }); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_remote.js b/toolkit/components/extensions/test/xpcshell/head_remote.js new file mode 100644 index 0000000000..55ff2164a1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_remote.js @@ -0,0 +1,5 @@ +"use strict"; + +/* globals ExtensionTestUtils */ + +ExtensionTestUtils.remoteContentScripts = true; diff --git a/toolkit/components/extensions/test/xpcshell/head_sync.js b/toolkit/components/extensions/test/xpcshell/head_sync.js new file mode 100644 index 0000000000..d780c3b009 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_sync.js @@ -0,0 +1,67 @@ +/* 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/. */ + +"use strict"; + +/* exported withSyncContext */ + +Components.utils.import("resource://gre/modules/Services.jsm", this); +Components.utils.import("resource://gre/modules/ExtensionCommon.jsm", this); + +var { + BaseContext, +} = ExtensionCommon; + +class Context extends BaseContext { + constructor(principal) { + super(); + Object.defineProperty(this, "principal", { + value: principal, + configurable: true, + }); + this.sandbox = Components.utils.Sandbox(principal, {wantXrays: false}); + this.extension = {id: "test@web.extension"}; + } + + get cloneScope() { + return this.sandbox; + } +} + +/** + * Call the given function with a newly-constructed context. + * Unload the context on the way out. + * + * @param {function} f the function to call + */ +async function withContext(f) { + const ssm = Services.scriptSecurityManager; + const PRINCIPAL1 = ssm.createCodebasePrincipalFromOrigin("http://www.example.org"); + const context = new Context(PRINCIPAL1); + try { + await f(context); + } finally { + await context.unload(); + } +} + +/** + * Like withContext(), but also turn on the "storage.sync" pref for + * the duration of the function. + * Calls to this function can be replaced with calls to withContext + * once the pref becomes on by default. + * + * @param {function} f the function to call + */ +async function withSyncContext(f) { + const STORAGE_SYNC_PREF = "webextensions.storage.sync.enabled"; + let prefs = Services.prefs; + + try { + prefs.setBoolPref(STORAGE_SYNC_PREF, true); + await withContext(f); + } finally { + prefs.clearUserPref(STORAGE_SYNC_PREF); + } +} diff --git a/toolkit/components/extensions/test/xpcshell/native_messaging.ini b/toolkit/components/extensions/test/xpcshell/native_messaging.ini new file mode 100644 index 0000000000..d0e1da163d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/native_messaging.ini @@ -0,0 +1,13 @@ +[DEFAULT] +head = head.js head_native_messaging.js +tail = +firefox-appdir = browser +skip-if = appname == "thunderbird" || os == "android" +subprocess = true +support-files = + data/** +tags = webextensions + +[test_ext_native_messaging.js] +[test_ext_native_messaging_perf.js] +[test_ext_native_messaging_unresponsive.js] diff --git a/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js b/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js new file mode 100644 index 0000000000..84c61102ee --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js @@ -0,0 +1,228 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_MatchPattern_matches() { + function test(url, pattern, normalized = pattern) { + let uri = Services.io.newURI(url); + + pattern = Array.concat(pattern); + normalized = Array.concat(normalized); + + let patterns = pattern.map(pat => new MatchPattern(pat)); + + let set = new MatchPatternSet(pattern); + let set2 = new MatchPatternSet(patterns); + + deepEqual(set2.patterns, patterns, "Patterns in set should equal the input patterns"); + + equal(set.matches(uri), set2.matches(uri), "Single pattern and pattern set should return the same match"); + + for (let [i, pat] of patterns.entries()) { + equal(pat.pattern, normalized[i], "Pattern property should contain correct normalized pattern value"); + } + + if (patterns.length == 1) { + equal(patterns[0].matches(uri), set.matches(uri), "Single pattern and string set should return the same match"); + } + + return set.matches(uri); + } + + function pass({url, pattern, normalized}) { + ok(test(url, pattern, normalized), `Expected match: ${JSON.stringify(pattern)}, ${url}`); + } + + function fail({url, pattern, normalized}) { + ok(!test(url, pattern, normalized), `Expected no match: ${JSON.stringify(pattern)}, ${url}`); + } + + function invalid({pattern}) { + Assert.throws(() => new MatchPattern(pattern), /.*/, + `Invalid pattern '${pattern}' should throw`); + Assert.throws(() => new MatchPatternSet([pattern]), /.*/, + `Invalid pattern '${pattern}' should throw`); + } + + // Invalid pattern. + invalid({pattern: ""}); + + // Pattern must include trailing slash. + invalid({pattern: "http://mozilla.org"}); + + // Protocol not allowed. + invalid({pattern: "gopher://wuarchive.wustl.edu/"}); + + pass({url: "http://mozilla.org", pattern: "http://mozilla.org/"}); + pass({url: "http://mozilla.org/", pattern: "http://mozilla.org/"}); + + pass({url: "http://mozilla.org/", pattern: "*://mozilla.org/"}); + pass({url: "https://mozilla.org/", pattern: "*://mozilla.org/"}); + fail({url: "file://mozilla.org/", pattern: "*://mozilla.org/"}); + fail({url: "ftp://mozilla.org/", pattern: "*://mozilla.org/"}); + + fail({url: "http://mozilla.com", pattern: "http://*mozilla.com*/"}); + fail({url: "http://mozilla.com", pattern: "http://mozilla.*/"}); + invalid({pattern: "http:/mozilla.com/"}); + + pass({url: "http://google.com", pattern: "http://*.google.com/"}); + pass({url: "http://docs.google.com", pattern: "http://*.google.com/"}); + + pass({url: "http://mozilla.org:8080", pattern: "http://mozilla.org/"}); + pass({url: "http://mozilla.org:8080", pattern: "*://mozilla.org/"}); + fail({url: "http://mozilla.org:8080", pattern: "http://mozilla.org:8080/"}); + + // Now try with * in the path. + pass({url: "http://mozilla.org", pattern: "http://mozilla.org/*"}); + pass({url: "http://mozilla.org/", pattern: "http://mozilla.org/*"}); + + pass({url: "http://mozilla.org/", pattern: "*://mozilla.org/*"}); + pass({url: "https://mozilla.org/", pattern: "*://mozilla.org/*"}); + fail({url: "file://mozilla.org/", pattern: "*://mozilla.org/*"}); + fail({url: "http://mozilla.com", pattern: "http://mozilla.*/*"}); + + pass({url: "http://google.com", pattern: "http://*.google.com/*"}); + pass({url: "http://docs.google.com", pattern: "http://*.google.com/*"}); + + // Check path stuff. + fail({url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/"}); + pass({url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*"}); + pass({url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/a*f"}); + pass({url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/a*"}); + pass({url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*f"}); + fail({url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*e"}); + fail({url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*c"}); + + invalid({pattern: "http:///a.html"}); + pass({url: "file:///foo", pattern: "file:///foo*"}); + pass({url: "file:///foo/bar.html", pattern: "file:///foo*"}); + + pass({url: "http://mozilla.org/a", pattern: ""}); + pass({url: "https://mozilla.org/a", pattern: ""}); + pass({url: "ftp://mozilla.org/a", pattern: ""}); + pass({url: "file:///a", pattern: ""}); + fail({url: "gopher://wuarchive.wustl.edu/a", pattern: ""}); + + // Multiple patterns. + pass({url: "http://mozilla.org", pattern: ["http://mozilla.org/"]}); + pass({url: "http://mozilla.org", pattern: ["http://mozilla.org/", "http://mozilla.com/"]}); + pass({url: "http://mozilla.com", pattern: ["http://mozilla.org/", "http://mozilla.com/"]}); + fail({url: "http://mozilla.biz", pattern: ["http://mozilla.org/", "http://mozilla.com/"]}); + + // Match url with fragments. + pass({url: "http://mozilla.org/base#some-fragment", pattern: "http://mozilla.org/base"}); +}); + +add_task(async function test_MatchPattern_overlaps() { + function test(filter, hosts, optional) { + filter = Array.concat(filter); + hosts = Array.concat(hosts); + optional = Array.concat(optional); + + const set = new MatchPatternSet([...hosts, ...optional]); + const pat = new MatchPatternSet(filter); + return set.overlapsAll(pat); + } + + function pass({filter = [], hosts = [], optional = []}) { + ok(test(filter, hosts, optional), `Expected overlap: ${filter}, ${hosts} (${optional})`); + } + + function fail({filter = [], hosts = [], optional = []}) { + ok(!test(filter, hosts, optional), `Expected no overlap: ${filter}, ${hosts} (${optional})`); + } + + // Direct comparison. + pass({hosts: "http://ab.cd/", filter: "http://ab.cd/"}); + fail({hosts: "http://ab.cd/", filter: "ftp://ab.cd/"}); + + // Wildcard protocol. + pass({hosts: "*://ab.cd/", filter: "https://ab.cd/"}); + fail({hosts: "*://ab.cd/", filter: "ftp://ab.cd/"}); + + // Wildcard subdomain. + pass({hosts: "http://*.ab.cd/", filter: "http://ab.cd/"}); + pass({hosts: "http://*.ab.cd/", filter: "http://www.ab.cd/"}); + fail({hosts: "http://*.ab.cd/", filter: "http://ab.cd.ef/"}); + fail({hosts: "http://*.ab.cd/", filter: "http://www.cd/"}); + + // Wildcard subsumed. + pass({hosts: "http://*.ab.cd/", filter: "http://*.cd/"}); + fail({hosts: "http://*.cd/", filter: "http://*.xy/"}); + + // Subdomain vs substring. + fail({hosts: "http://*.ab.cd/", filter: "http://fake-ab.cd/"}); + fail({hosts: "http://*.ab.cd/", filter: "http://*.fake-ab.cd/"}); + + // Wildcard domain. + pass({hosts: "http://*/", filter: "http://ab.cd/"}); + fail({hosts: "http://*/", filter: "https://ab.cd/"}); + + // Wildcard wildcards. + pass({hosts: "", filter: "ftp://ab.cd/"}); + fail({hosts: ""}); + + // Multiple hosts. + pass({hosts: ["http://ab.cd/"], filter: ["http://ab.cd/"]}); + pass({hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.cd/"}); + pass({hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.xy/"}); + fail({hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.zz/"}); + + // Multiple Multiples. + pass({hosts: ["http://*.ab.cd/"], filter: ["http://ab.cd/", "http://www.ab.cd/"]}); + pass({hosts: ["http://ab.cd/", "http://ab.xy/"], filter: ["http://ab.cd/", "http://ab.xy/"]}); + fail({hosts: ["http://ab.cd/", "http://ab.xy/"], filter: ["http://ab.cd/", "http://ab.zz/"]}); + + // Optional. + pass({hosts: [], optional: "http://ab.cd/", filter: "http://ab.cd/"}); + pass({hosts: "http://ab.cd/", optional: "http://ab.xy/", filter: ["http://ab.cd/", "http://ab.xy/"]}); + fail({hosts: "http://ab.cd/", optional: "https://ab.xy/", filter: "http://ab.xy/"}); +}); + +add_task(async function test_MatchGlob() { + function test(url, pattern) { + let m = new MatchGlob(pattern[0]); + return m.matches(Services.io.newURI(url).spec); + } + + function pass({url, pattern}) { + ok(test(url, pattern), `Expected match: ${JSON.stringify(pattern)}, ${url}`); + } + + function fail({url, pattern}) { + ok(!test(url, pattern), `Expected no match: ${JSON.stringify(pattern)}, ${url}`); + } + + let moz = "http://mozilla.org"; + + pass({url: moz, pattern: ["*"]}); + pass({url: moz, pattern: ["http://*"]}); + pass({url: moz, pattern: ["*mozilla*"]}); + // pass({url: moz, pattern: ["*example*", "*mozilla*"]}); + + pass({url: moz, pattern: ["*://*"]}); + pass({url: "https://mozilla.org", pattern: ["*://*"]}); + + // Documentation example + pass({url: "http://www.example.com/foo/bar", pattern: ["http://???.example.com/foo/*"]}); + pass({url: "http://the.example.com/foo/", pattern: ["http://???.example.com/foo/*"]}); + fail({url: "http://my.example.com/foo/bar", pattern: ["http://???.example.com/foo/*"]}); + fail({url: "http://example.com/foo/", pattern: ["http://???.example.com/foo/*"]}); + fail({url: "http://www.example.com/foo", pattern: ["http://???.example.com/foo/*"]}); + + // Matches path + let path = moz + "/abc/def"; + pass({url: path, pattern: ["*def"]}); + pass({url: path, pattern: ["*c/d*"]}); + pass({url: path, pattern: ["*org/abc*"]}); + fail({url: path + "/", pattern: ["*def"]}); + + // Trailing slash + pass({url: moz, pattern: ["*.org/"]}); + fail({url: moz, pattern: ["*.org"]}); + + // Wrong TLD + fail({url: moz, pattern: ["*oz*.com/"]}); + // Case sensitive + fail({url: moz, pattern: ["*.ORG/"]}); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js b/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js new file mode 100644 index 0000000000..b848e64845 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js @@ -0,0 +1,176 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const {newURI} = Services.io; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +let policy = new WebExtensionPolicy({ + id: "foo@bar.baz", + mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2", + baseURL: "file:///foo", + + allowedOrigins: new MatchPatternSet([]), + localizeCallback() {}, +}); + +add_task(async function test_WebExtensinonContentScript_url_matching() { + let contentScript = new WebExtensionContentScript(policy, { + matches: new MatchPatternSet(["http://foo.com/bar", "*://bar.com/baz/*"]), + + excludeMatches: new MatchPatternSet(["*://bar.com/baz/quux"]), + + includeGlobs: ["*flerg*", "*.com/bar", "*/quux"].map(glob => new MatchGlob(glob)), + + excludeGlobs: ["*glorg*"].map(glob => new MatchGlob(glob)), + }); + + ok(contentScript.matchesURI(newURI("http://foo.com/bar")), + "Simple matches include should match"); + + ok(contentScript.matchesURI(newURI("https://bar.com/baz/xflergx")), + "Simple matches include should match"); + + ok(!contentScript.matchesURI(newURI("https://bar.com/baz/xx")), + "Failed includeGlobs match pattern should not match"); + + ok(!contentScript.matchesURI(newURI("https://bar.com/baz/quux")), + "Excluded match pattern should not match"); + + ok(!contentScript.matchesURI(newURI("https://bar.com/baz/xflergxglorgx")), + "Excluded match glob should not match"); +}); + +async function loadURL(url, {frameCount}) { + let windows = new Map(); + let requests = new Map(); + + let resolveLoad; + let loadPromise = new Promise(resolve => { resolveLoad = resolve; }); + + function requestObserver(request) { + request.QueryInterface(Ci.nsIChannel); + if (request.isDocument) { + requests.set(request.name, request); + } + } + function loadObserver(window) { + windows.set(window.location.href, window); + if (windows.size == frameCount) { + resolveLoad(); + } + } + + Services.obs.addObserver(requestObserver, "http-on-examine-response"); + Services.obs.addObserver(loadObserver, "content-document-global-created"); + + let webNav = Services.appShell.createWindowlessBrowser(false); + webNav.loadURI(url, 0, null, null, null); + + await loadPromise; + + Services.obs.removeObserver(requestObserver, "http-on-examine-response"); + Services.obs.removeObserver(loadObserver, "content-document-global-created"); + + return {webNav, windows, requests}; +} + +add_task(async function test_WebExtensinonContentScript_frame_matching() { + if (AppConstants.platform == "linux") { + // The windowless browser currently does not load correctly on Linux on + // infra. + return; + } + + let baseURL = `http://localhost:${server.identity.primaryPort}/data`; + let urls = { + topLevel: `${baseURL}/file_toplevel.html`, + iframe: `${baseURL}/file_iframe.html`, + srcdoc: "about:srcdoc", + aboutBlank: "about:blank", + }; + + let {webNav, windows, requests} = await loadURL(urls.topLevel, {frameCount: 4}); + + let tests = [ + { + contentScript: { + matches: new MatchPatternSet(["http://localhost/data/*"]), + }, + topLevel: true, + iframe: false, + aboutBlank: false, + srcdoc: false, + }, + + { + contentScript: { + matches: new MatchPatternSet(["http://localhost/data/*"]), + frameID: 0, + }, + topLevel: true, + iframe: false, + aboutBlank: false, + srcdoc: false, + }, + + { + contentScript: { + matches: new MatchPatternSet(["http://localhost/data/*"]), + allFrames: true, + }, + topLevel: true, + iframe: true, + aboutBlank: false, + srcdoc: false, + }, + + { + contentScript: { + matches: new MatchPatternSet(["http://localhost/data/*"]), + allFrames: true, + matchAboutBlank: true, + }, + topLevel: true, + iframe: true, + aboutBlank: true, + srcdoc: true, + }, + + { + contentScript: { + matches: new MatchPatternSet(["http://foo.com/data/*"]), + allFrames: true, + matchAboutBlank: true, + }, + topLevel: false, + iframe: false, + aboutBlank: false, + srcdoc: false, + }, + ]; + + for (let [i, test] of tests.entries()) { + let contentScript = new WebExtensionContentScript(policy, test.contentScript); + + for (let [frame, url] of Object.entries(urls)) { + let should = test[frame] ? "should" : "should not"; + + equal(contentScript.matchesWindow(windows.get(url)), + test[frame], + `Script ${i} ${should} match the ${frame} frame`); + + if (url.startsWith("http")) { + let request = requests.get(url); + + equal(contentScript.matchesLoadInfo(request.URI, request.loadInfo), + test[frame], + `Script ${i} ${should} match the request LoadInfo for ${frame} frame`); + } + } + } + + webNav.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js new file mode 100644 index 0000000000..2e2f571e9a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js @@ -0,0 +1,140 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const {newURI} = Services.io; + +add_task(async function test_WebExtensinonPolicy() { + const id = "foo@bar.baz"; + const uuid = "ca9d3f23-125c-4b24-abfc-1ca2692b0610"; + + const baseURL = "file:///foo/"; + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURI = newURI(mozExtURL); + + let policy = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL, + + localizeCallback(str) { + return `<${str}>`; + }, + + allowedOrigins: new MatchPatternSet(["http://foo.bar/", "*://*.baz/"], {ignorePath: true}), + permissions: [""], + webAccessibleResources: ["/foo/*", "/bar.baz"].map(glob => new MatchGlob(glob)), + }); + + equal(policy.active, false, "Active attribute should initially be false"); + + // GetURL + + equal(policy.getURL(), mozExtURL, "getURL() should return the correct root URL"); + equal(policy.getURL("path/foo.html"), `${mozExtURL}path/foo.html`, "getURL(path) should return the correct URL"); + + + // Permissions + + deepEqual(policy.permissions, [""], "Initial permissions should be correct"); + + ok(policy.hasPermission(""), "hasPermission should match existing permission"); + ok(!policy.hasPermission("history"), "hasPermission should not match nonexistent permission"); + + Assert.throws(() => { policy.permissions[0] = "foo"; }, + TypeError, + "Permissions array should be frozen"); + + policy.permissions = ["history"]; + deepEqual(policy.permissions, ["history"], "Permissions should be updateable as a set"); + + ok(policy.hasPermission("history"), "hasPermission should match existing permission"); + ok(!policy.hasPermission(""), "hasPermission should not match nonexistent permission"); + + + // Origins + + ok(policy.canAccessURI(newURI("http://foo.bar/quux")), "Should be able to access whitelisted URI"); + ok(policy.canAccessURI(newURI("https://x.baz/foo")), "Should be able to access whitelisted URI"); + + ok(!policy.canAccessURI(newURI("https://foo.bar/quux")), "Should not be able to access non-whitelisted URI"); + + policy.allowedOrigins = new MatchPatternSet(["https://foo.bar/"], {ignorePath: true}); + + ok(policy.canAccessURI(newURI("https://foo.bar/quux")), "Should be able to access updated whitelisted URI"); + ok(!policy.canAccessURI(newURI("https://x.baz/foo")), "Should not be able to access removed whitelisted URI"); + + + // Web-accessible resources + + ok(policy.isPathWebAccessible("/foo/bar"), "Web-accessible glob should be web-accessible"); + ok(policy.isPathWebAccessible("/bar.baz"), "Web-accessible path should be web-accessible"); + ok(!policy.isPathWebAccessible("/bar.baz/quux"), "Non-web-accessible path should not be web-accessible"); + + + // Localization + + equal(policy.localize("foo"), "", "Localization callback should work as expected"); + + + // Protocol and lookups. + + let proto = Services.io.getProtocolHandler("moz-extension", uuid).QueryInterface(Ci.nsISubstitutingProtocolHandler); + + deepEqual(WebExtensionPolicy.getActiveExtensions(), [], "Should have no active extensions"); + equal(WebExtensionPolicy.getByID(id), null, "ID lookup should not return extension when not active"); + equal(WebExtensionPolicy.getByHostname(uuid), null, "Hostname lookup should not return extension when not active"); + Assert.throws(() => proto.resolveURI(mozExtURI), /NS_ERROR_NOT_AVAILABLE/, + "URL should not resolve when not active"); + + policy.active = true; + equal(policy.active, true, "Active attribute should be updated"); + + let exts = WebExtensionPolicy.getActiveExtensions(); + equal(exts.length, 1, "Should have one active extension"); + equal(exts[0], policy, "Should have the correct active extension"); + + equal(WebExtensionPolicy.getByID(id), policy, "ID lookup should return extension when active"); + equal(WebExtensionPolicy.getByHostname(uuid), policy, "Hostname lookup should return extension when active"); + + equal(proto.resolveURI(mozExtURI), baseURL, "URL should resolve correctly while active"); + + policy.active = false; + equal(policy.active, false, "Active attribute should be updated"); + + deepEqual(WebExtensionPolicy.getActiveExtensions(), [], "Should have no active extensions"); + equal(WebExtensionPolicy.getByID(id), null, "ID lookup should not return extension when not active"); + equal(WebExtensionPolicy.getByHostname(uuid), null, "Hostname lookup should not return extension when not active"); + Assert.throws(() => proto.resolveURI(mozExtURI), /NS_ERROR_NOT_AVAILABLE/, + "URL should not resolve when not active"); + + + // Conflicting policies. + + // This asserts in debug builds, so only test in non-debug builds. + if (!AppConstants.DEBUG) { + policy.active = true; + + let attrs = [{id, uuid}, + {id, uuid: "d916886c-cfdf-482e-b7b1-d7f5b0facfa5"}, + {id: "foo@quux", uuid}]; + + // eslint-disable-next-line no-shadow + for (let {id, uuid} of attrs) { + let policy2 = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL: "file://bar/", + + localizeCallback() {}, + + allowedOrigins: new MatchPatternSet([]), + }); + + Assert.throws(() => { policy2.active = true; }, /NS_ERROR_UNEXPECTED/, + `Should not be able to activate conflicting policy: ${id} ${uuid}`); + } + + policy.active = false; + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js b/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js new file mode 100644 index 0000000000..1fbfd1952e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js @@ -0,0 +1,56 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Cu.import("resource://gre/modules/Preferences.jsm"); + +const ADDON_ID = "test@web.extension"; + +const aps = Cc["@mozilla.org/addons/policy-service;1"] + .getService(Ci.nsIAddonPolicyService); + +let policy = null; + +function setAddonCSP(csp) { + if (policy) { + policy.active = false; + } + + policy = new WebExtensionPolicy({ + id: ADDON_ID, + mozExtensionHostname: ADDON_ID, + baseURL: "file:///", + + allowedOrigins: new MatchPatternSet([]), + localizeCallback() {}, + + contentSecurityPolicy: csp, + }); + + policy.active = true; +} + +do_register_cleanup(() => { + policy.active = false; +}); + +add_task(function* test_addon_csp() { + equal(aps.baseCSP, Preferences.get("extensions.webextensions.base-content-security-policy"), + "Expected base CSP value"); + + equal(aps.defaultCSP, Preferences.get("extensions.webextensions.default-content-security-policy"), + "Expected default CSP value"); + + + const CUSTOM_POLICY = "script-src: 'self' https://xpcshell.test.custom.csp; object-src: 'none'"; + + setAddonCSP(CUSTOM_POLICY); + + equal(aps.getAddonCSP(ADDON_ID), CUSTOM_POLICY, "CSP should point to add-on's custom policy"); + + + setAddonCSP(null); + + equal(aps.getAddonCSP(ADDON_ID), aps.defaultCSP, + "CSP should revert to default when set to null"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_csp_validator.js b/toolkit/components/extensions/test/xpcshell/test_csp_validator.js new file mode 100644 index 0000000000..59a7322bc3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_csp_validator.js @@ -0,0 +1,85 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const cps = Cc["@mozilla.org/addons/content-policy;1"].getService(Ci.nsIAddonContentPolicy); + +add_task(function* test_csp_validator() { + let checkPolicy = (policy, expectedResult, message = null) => { + do_print(`Checking policy: ${policy}`); + + let result = cps.validateAddonCSP(policy); + equal(result, expectedResult); + }; + + checkPolicy("script-src 'self'; object-src 'self';", + null); + + let hash = "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='"; + + checkPolicy(`script-src 'self' https://com https://*.example.com moz-extension://09abcdef blob: filesystem: ${hash} 'unsafe-eval'; ` + + `object-src 'self' https://com https://*.example.com moz-extension://09abcdef blob: filesystem: ${hash}`, + null); + + checkPolicy("", + "Policy is missing a required \u2018script-src\u2019 directive"); + + checkPolicy("object-src 'none';", + "Policy is missing a required \u2018script-src\u2019 directive"); + + + checkPolicy("default-src 'self'", null, + "A valid default-src should count as a valid script-src or object-src"); + + checkPolicy("default-src 'self'; script-src 'self'", null, + "A valid default-src should count as a valid script-src or object-src"); + + checkPolicy("default-src 'self'; object-src 'self'", null, + "A valid default-src should count as a valid script-src or object-src"); + + + checkPolicy("default-src 'self'; script-src http://example.com", + "\u2018script-src\u2019 directive contains a forbidden http: protocol source", + "A valid default-src should not allow an invalid script-src directive"); + + checkPolicy("default-src 'self'; object-src http://example.com", + "\u2018object-src\u2019 directive contains a forbidden http: protocol source", + "A valid default-src should not allow an invalid object-src directive"); + + + checkPolicy("script-src 'self';", + "Policy is missing a required \u2018object-src\u2019 directive"); + + checkPolicy("script-src 'none'; object-src 'none'", + "\u2018script-src\u2019 must include the source 'self'"); + + checkPolicy("script-src 'self'; object-src 'none';", + null); + + checkPolicy("script-src 'self' 'unsafe-inline'; object-src 'self';", + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-inline' keyword"); + + + let directives = ["script-src", "object-src"]; + + for (let [directive, other] of [directives, directives.slice().reverse()]) { + for (let src of ["https://*", "https://*.blogspot.com", "https://*"]) { + checkPolicy(`${directive} 'self' ${src}; ${other} 'self';`, + `https: wildcard sources in \u2018${directive}\u2019 directives must include at least one non-generic sub-domain (e.g., *.example.com rather than *.com)`); + } + + checkPolicy(`${directive} 'self' https:; ${other} 'self';`, + `https: protocol requires a host in \u2018${directive}\u2019 directives`); + + checkPolicy(`${directive} 'self' http://example.com; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden http: protocol source`); + + for (let protocol of ["http", "ftp", "meh"]) { + checkPolicy(`${directive} 'self' ${protocol}:; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden ${protocol}: protocol source`); + } + + checkPolicy(`${directive} 'self' 'nonce-01234'; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden 'nonce-*' keyword`); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js new file mode 100644 index 0000000000..5dd66839c9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js @@ -0,0 +1,215 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_alarm_without_permissions() { + function backgroundScript() { + browser.test.assertTrue(!browser.alarms, + "alarm API is not available when the alarm permission is not required"); + browser.test.notifyPass("alarms_permission"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: [], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarms_permission"); + await extension.unload(); +}); + + +add_task(async function test_alarm_fires() { + function backgroundScript() { + let ALARM_NAME = "test_ext_alarms"; + let timer; + + browser.alarms.onAlarm.addListener(alarm => { + browser.test.assertEq(ALARM_NAME, alarm.name, "alarm has the correct name"); + clearTimeout(timer); + browser.test.notifyPass("alarm-fires"); + }); + + browser.alarms.create(ALARM_NAME, {delayInMinutes: 0.02}); + + timer = setTimeout(async () => { + browser.test.fail("alarm fired within expected time"); + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + browser.test.notifyFail("alarm-fires"); + }, 10000); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-fires"); + + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +}); + +add_task(async function test_alarm_fires_with_when() { + function backgroundScript() { + let ALARM_NAME = "test_ext_alarms"; + let timer; + + browser.alarms.onAlarm.addListener(alarm => { + browser.test.assertEq(ALARM_NAME, alarm.name, "alarm has the expected name"); + clearTimeout(timer); + browser.test.notifyPass("alarm-when"); + }); + + browser.alarms.create(ALARM_NAME, {when: Date.now() + 1000}); + + timer = setTimeout(async () => { + browser.test.fail("alarm fired within expected time"); + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + browser.test.notifyFail("alarm-when"); + }, 10000); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-when"); + + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +}); + +add_task(async function test_alarm_clear_non_matching_name() { + async function backgroundScript() { + let ALARM_NAME = "test_ext_alarms"; + + browser.alarms.create(ALARM_NAME, {when: Date.now() + 2000}); + + let wasCleared = await browser.alarms.clear(ALARM_NAME + "1"); + browser.test.assertFalse(wasCleared, "alarm was not cleared"); + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq(1, alarms.length, "alarm was not removed"); + browser.test.notifyPass("alarm-clear"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-clear"); + await extension.unload(); +}); + +add_task(async function test_alarm_get_and_clear_single_argument() { + async function backgroundScript() { + browser.alarms.create({when: Date.now() + 2000}); + + let alarm = await browser.alarms.get(); + browser.test.assertEq("", alarm.name, "expected alarm returned"); + + let wasCleared = await browser.alarms.clear(); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq(0, alarms.length, "alarm was removed"); + + browser.test.notifyPass("alarm-single-arg"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-single-arg"); + await extension.unload(); +}); + + +add_task(async function test_get_get_all_clear_all_alarms() { + async function backgroundScript() { + const ALARM_NAME = "test_alarm"; + + let suffixes = [0, 1, 2]; + + for (let suffix of suffixes) { + browser.alarms.create(ALARM_NAME + suffix, {when: Date.now() + (suffix + 1) * 10000}); + } + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq(suffixes.length, alarms.length, "expected number of alarms were found"); + alarms.forEach((alarm, index) => { + browser.test.assertEq(ALARM_NAME + index, alarm.name, "alarm has the expected name"); + }); + + + for (let suffix of suffixes) { + let alarm = await browser.alarms.get(ALARM_NAME + suffix); + browser.test.assertEq(ALARM_NAME + suffix, alarm.name, "alarm has the expected name"); + browser.test.sendMessage(`get-${suffix}`); + } + + let wasCleared = await browser.alarms.clear(ALARM_NAME + suffixes[0]); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + alarms = await browser.alarms.getAll(); + browser.test.assertEq(2, alarms.length, "alarm was removed"); + + let alarm = await browser.alarms.get(ALARM_NAME + suffixes[0]); + browser.test.assertEq(undefined, alarm, "non-existent alarm is undefined"); + browser.test.sendMessage(`get-invalid`); + + wasCleared = await browser.alarms.clearAll(); + browser.test.assertTrue(wasCleared, "alarms were cleared"); + + alarms = await browser.alarms.getAll(); + browser.test.assertEq(0, alarms.length, "no alarms exist"); + browser.test.sendMessage("clearAll"); + browser.test.sendMessage("clear"); + browser.test.sendMessage("getAll"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await Promise.all([ + extension.startup(), + extension.awaitMessage("getAll"), + extension.awaitMessage("get-0"), + extension.awaitMessage("get-1"), + extension.awaitMessage("get-2"), + extension.awaitMessage("clear"), + extension.awaitMessage("get-invalid"), + extension.awaitMessage("clearAll"), + ]); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js new file mode 100644 index 0000000000..1816f2484e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js @@ -0,0 +1,33 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_cleared_alarm_does_not_fire() { + async function backgroundScript() { + let ALARM_NAME = "test_ext_alarms"; + + browser.alarms.onAlarm.addListener(alarm => { + browser.test.fail("cleared alarm does not fire"); + browser.test.notifyFail("alarm-cleared"); + }); + browser.alarms.create(ALARM_NAME, {when: Date.now() + 1000}); + + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + browser.test.notifyPass("alarm-cleared"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-cleared"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js new file mode 100644 index 0000000000..179b2a3aad --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js @@ -0,0 +1,44 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_periodic_alarm_fires() { + function backgroundScript() { + const ALARM_NAME = "test_ext_alarms"; + let count = 0; + let timer; + + browser.alarms.onAlarm.addListener(async alarm => { + browser.test.assertEq(alarm.name, ALARM_NAME, "alarm has the expected name"); + if (count++ === 3) { + clearTimeout(timer); + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + browser.test.notifyPass("alarm-periodic"); + } + }); + + browser.alarms.create(ALARM_NAME, {periodInMinutes: 0.02}); + + timer = setTimeout(async () => { + browser.test.fail("alarm fired expected number of times"); + + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + browser.test.notifyFail("alarm-periodic"); + }, 30000); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-periodic"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js new file mode 100644 index 0000000000..3e58638ac0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js @@ -0,0 +1,44 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + + +add_task(async function test_duplicate_alarm_name_replaces_alarm() { + function backgroundScript() { + let count = 0; + + browser.alarms.onAlarm.addListener(async alarm => { + if (alarm.name === "master alarm") { + browser.alarms.create("child alarm", {delayInMinutes: 0.05}); + let results = await browser.alarms.getAll(); + + browser.test.assertEq(2, results.length, "exactly two alarms exist"); + browser.test.assertEq("master alarm", results[0].name, "first alarm has the expected name"); + browser.test.assertEq("child alarm", results[1].name, "second alarm has the expected name"); + + if (count++ === 3) { + await browser.alarms.clear("master alarm"); + await browser.alarms.clear("child alarm"); + + browser.test.notifyPass("alarm-duplicate"); + } + } else { + browser.test.fail("duplicate named alarm replaced existing alarm"); + browser.test.notifyFail("alarm-duplicate"); + } + }); + + browser.alarms.create("master alarm", {delayInMinutes: 0.025, periodInMinutes: 0.025}); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-duplicate"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js new file mode 100644 index 0000000000..f1dcd21b84 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js @@ -0,0 +1,68 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +let {Management} = Cu.import("resource://gre/modules/Extension.jsm", {}); +function getNextContext() { + return new Promise(resolve => { + Management.on("proxy-context-load", function listener(type, context) { + Management.off("proxy-context-load", listener); + resolve(context); + }); + }); +} + +add_task(async function test_storage_api_without_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + // Force API initialization. + try { + browser.storage.onChanged.addListener(() => {}); + } catch (e) { + // Ignore. + } + }, + + manifest: { + permissions: [], + }, + }); + + let contextPromise = getNextContext(); + await extension.startup(); + + let context = await contextPromise; + + // Force API initialization. + void context.apiObj; + + ok(!("storage" in context.apiObj), + "The storage API should not be initialized"); + + await extension.unload(); +}); + +add_task(async function test_storage_api_with_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.storage.onChanged.addListener(() => {}); + }, + + manifest: { + permissions: ["storage"], + }, + }); + + let contextPromise = getNextContext(); + await extension.startup(); + + let context = await contextPromise; + + // Force API initialization. + void context.apiObj; + + equal(typeof context.apiObj.storage, "object", + "The storage API should be initialized"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js new file mode 100644 index 0000000000..cac574b8ca --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js @@ -0,0 +1,23 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* eslint-disable mozilla/balanced-listeners */ + +add_task(async function test_DOMContentLoaded_in_generated_background_page() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + function reportListener(event) { + browser.test.sendMessage("eventname", event.type); + } + document.addEventListener("DOMContentLoaded", reportListener); + window.addEventListener("load", reportListener); + }, + }); + + await extension.startup(); + equal("DOMContentLoaded", await extension.awaitMessage("eventname")); + equal("load", await extension.awaitMessage("eventname")); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js new file mode 100644 index 0000000000..a22db9d582 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js @@ -0,0 +1,24 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_reload_generated_background_page() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + if (location.hash !== "#firstrun") { + browser.test.sendMessage("first run"); + location.hash = "#firstrun"; + browser.test.assertEq("#firstrun", location.hash); + location.reload(); + } else { + browser.test.notifyPass("second run"); + } + }, + }); + + await extension.startup(); + await extension.awaitMessage("first run"); + await extension.awaitFinish("second run"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js new file mode 100644 index 0000000000..7c6ba16c38 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js @@ -0,0 +1,22 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Cu.import("resource://testing-common/PlacesTestUtils.jsm"); + +add_task(async function test_global_history() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("background-loaded", location.href); + }, + }); + + await extension.startup(); + + let backgroundURL = await extension.awaitMessage("background-loaded"); + + await extension.unload(); + + let exists = await PlacesTestUtils.isPageInDB(backgroundURL); + ok(!exists, "Background URL should not be in history database"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js new file mode 100644 index 0000000000..b57a0f22bc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js @@ -0,0 +1,40 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Cu.import("resource://gre/modules/Preferences.jsm"); + +async function testBackgroundPage(expected) { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + browser.test.assertEq(window, browser.extension.getBackgroundPage(), + "Caller should be able to access itself as a background page"); + browser.test.assertEq(window, await browser.runtime.getBackgroundPage(), + "Caller should be able to access itself as a background page"); + + browser.test.sendMessage("incognito", browser.extension.inIncognitoContext); + }, + }); + + await extension.startup(); + + let incognito = await extension.awaitMessage("incognito"); + equal(incognito, expected.incognito, "Expected incognito value"); + + await extension.unload(); +} + +add_task(async function test_background_incognito() { + do_print("Test background page incognito value with permanent private browsing disabled"); + + await testBackgroundPage({incognito: false}); + + do_print("Test background page incognito value with permanent private browsing enabled"); + + Preferences.set("browser.privatebrowsing.autostart", true); + do_register_cleanup(() => { + Preferences.reset("browser.privatebrowsing.autostart"); + }); + + await testBackgroundPage({incognito: true}); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js new file mode 100644 index 0000000000..309c1cfbdb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js @@ -0,0 +1,72 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + let received_ports_number = 0; + + const expected_received_ports_number = 1; + + function countReceivedPorts(port) { + received_ports_number++; + + if (port.name == "check-results") { + browser.runtime.onConnect.removeListener(countReceivedPorts); + + browser.test.assertEq(expected_received_ports_number, received_ports_number, "invalid connect should not create a port"); + + browser.test.notifyPass("runtime.connect invalid params"); + } + } + + browser.runtime.onConnect.addListener(countReceivedPorts); + + let childFrame = document.createElement("iframe"); + childFrame.src = "extensionpage.html"; + document.body.appendChild(childFrame); +} + +function senderScript() { + let detected_invalid_connect_params = 0; + + const invalid_connect_params = [ + // too many params + ["fake-extensions-id", {name: "fake-conn-name"}, "unexpected third params"], + // invalid params format + [{}, {}], + ["fake-extensions-id", "invalid-connect-info-format"], + ]; + const expected_detected_invalid_connect_params = invalid_connect_params.length; + + function assertInvalidConnectParamsException(params) { + try { + browser.runtime.connect(...params); + } catch (e) { + detected_invalid_connect_params++; + browser.test.assertTrue(e.toString().indexOf("Incorrect argument types for runtime.connect.") >= 0, "exception message is correct"); + } + } + for (let params of invalid_connect_params) { + assertInvalidConnectParamsException(params); + } + browser.test.assertEq(expected_detected_invalid_connect_params, detected_invalid_connect_params, "all invalid runtime.connect params detected"); + + browser.runtime.connect(browser.runtime.id, {name: "check-results"}); +} + +let extensionData = { + background: backgroundScript, + files: { + "senderScript.js": senderScript, + "extensionpage.html": ``, + }, +}; + +add_task(async function test_backgroundRuntimeConnectParams() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("runtime.connect invalid params"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js new file mode 100644 index 0000000000..fead6f1a4b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js @@ -0,0 +1,45 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testBackgroundWindow() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.log("background script executed"); + + browser.test.sendMessage("background-script-load"); + + let img = document.createElement("img"); + img.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + document.body.appendChild(img); + + img.onload = () => { + browser.test.log("image loaded"); + + let iframe = document.createElement("iframe"); + iframe.src = "about:blank?1"; + + iframe.onload = () => { + browser.test.log("iframe loaded"); + setTimeout(() => { + browser.test.notifyPass("background sub-window test done"); + }, 0); + }; + document.body.appendChild(iframe); + }; + }, + }); + + let loadCount = 0; + extension.onMessage("background-script-load", () => { + loadCount++; + }); + + await extension.startup(); + + await extension.awaitFinish("background sub-window test done"); + + equal(loadCount, 1, "background script loaded only once"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js new file mode 100644 index 0000000000..bf675a1701 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js @@ -0,0 +1,40 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const HISTOGRAM = "WEBEXT_BACKGROUND_PAGE_LOAD_MS"; + +add_task(async function test_telemetry() { + let extension1 = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("loaded"); + }, + }); + + let extension2 = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("loaded"); + }, + }); + + let histogram = Services.telemetry.getHistogramById(HISTOGRAM); + histogram.clear(); + equal(histogram.snapshot().sum, 0, + `No data recorded for histogram: ${HISTOGRAM}.`); + + await extension1.startup(); + await extension1.awaitMessage("loaded"); + + let histogramSum = histogram.snapshot().sum; + ok(histogramSum > 0, + `Data recorded for first extension for histogram: ${HISTOGRAM}.`); + + await extension2.startup(); + await extension2.awaitMessage("loaded"); + + ok(histogram.snapshot().sum > histogramSum, + `Data recorded for second extension for histogram: ${HISTOGRAM}.`); + + await extension1.unload(); + await extension2.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js new file mode 100644 index 0000000000..f84dd81108 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js @@ -0,0 +1,34 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testBackgroundWindowProperties() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + let expectedValues = { + screenX: 0, + screenY: 0, + outerWidth: 0, + outerHeight: 0, + }; + + for (let k in window) { + try { + if (k in expectedValues) { + browser.test.assertEq(expectedValues[k], window[k], + `should return the expected value for window property: ${k}`); + } else { + void window[k]; + } + } catch (e) { + browser.test.assertEq(null, e, `unexpected exception accessing window property: ${k}`); + } + } + + browser.test.notifyPass("background.testWindowProperties.done"); + }, + }); + await extension.startup(); + await extension.awaitFinish("background.testWindowProperties.done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js new file mode 100644 index 0000000000..c005c36222 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js @@ -0,0 +1,116 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +add_task(async function test_contentscript_runAt() { + function background() { + browser.runtime.onMessage.addListener(([msg, expectedStates, readyState], sender) => { + if (msg == "chrome-namespace-ok") { + browser.test.sendMessage(msg); + return; + } + + browser.test.assertEq("script-run", msg, "message type is correct"); + browser.test.assertTrue(expectedStates.includes(readyState), + `readyState "${readyState}" is one of [${expectedStates}]`); + browser.test.sendMessage("script-run-" + expectedStates[0]); + }); + } + + function contentScriptStart() { + browser.runtime.sendMessage(["script-run", ["loading"], document.readyState]); + } + function contentScriptEnd() { + browser.runtime.sendMessage(["script-run", ["interactive", "complete"], document.readyState]); + } + function contentScriptIdle() { + browser.runtime.sendMessage(["script-run", ["complete"], document.readyState]); + } + + function contentScript() { + let manifest = browser.runtime.getManifest(); + void manifest.applications.gecko.id; + browser.runtime.sendMessage(["chrome-namespace-ok"]); + } + + let extensionData = { + manifest: { + applications: {gecko: {id: "contentscript@tests.mozilla.org"}}, + content_scripts: [ + { + "matches": ["http://*/*/file_sample.html"], + "js": ["content_script_start.js"], + "run_at": "document_start", + }, + { + "matches": ["http://*/*/file_sample.html"], + "js": ["content_script_end.js"], + "run_at": "document_end", + }, + { + "matches": ["http://*/*/file_sample.html"], + "js": ["content_script_idle.js"], + "run_at": "document_idle", + }, + { + "matches": ["http://*/*/file_sample.html"], + "js": ["content_script_idle.js"], + // Test default `run_at`. + }, + { + "matches": ["http://*/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_idle", + }, + ], + }, + background, + + files: { + "content_script_start.js": contentScriptStart, + "content_script_end.js": contentScriptEnd, + "content_script_idle.js": contentScriptIdle, + "content_script.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let loadingCount = 0; + let interactiveCount = 0; + let completeCount = 0; + extension.onMessage("script-run-loading", () => { loadingCount++; }); + extension.onMessage("script-run-interactive", () => { interactiveCount++; }); + + let completePromise = new Promise(resolve => { + extension.onMessage("script-run-complete", () => { + completeCount++; + if (completeCount > 1) { + resolve(); + } + }); + }); + + let chromeNamespacePromise = extension.awaitMessage("chrome-namespace-ok"); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage(`${BASE_URL}/file_sample.html`); + + await Promise.all([completePromise, chromeNamespacePromise]); + + await contentPage.close(); + + equal(loadingCount, 1, "document_start script ran exactly once"); + equal(interactiveCount, 1, "document_end script ran exactly once"); + equal(completeCount, 2, "document_idle script ran exactly twice"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js new file mode 100644 index 0000000000..40511d6a50 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js @@ -0,0 +1,75 @@ +"use strict"; + +Cu.import("resource://gre/modules/Preferences.jsm"); + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; +const XRAY_PREF = "dom.allow_named_properties_object_for_xrays"; + +add_task(async function test_contentscript_xrays() { + async function contentScript() { + let deferred; + browser.test.onMessage.addListener(msg => { + if (msg === "pref-set") { + deferred.resolve(); + } + }); + function setPref(val) { + browser.test.sendMessage("set-pref", val); + return new Promise(resolve => { deferred = {resolve}; }); + } + + let unwrapped = window.wrappedJSObject; + + await setPref(0); + browser.test.assertEq("object", typeof test, "Should have named X-ray property access with pref=0"); + browser.test.assertEq("object", typeof window.test, "Should have named X-ray property access with pref=0"); + browser.test.assertEq("object", typeof unwrapped.test, "Should always have non-X-ray named property access"); + + await setPref(1); + browser.test.assertEq("undefined", typeof test, "Should not have named X-ray property access with pref=1"); + browser.test.assertEq(undefined, window.test, "Should not have named X-ray property access with pref=1"); + browser.test.assertEq("object", typeof unwrapped.test, "Should always have non-X-ray named property access"); + + await setPref(2); + browser.test.assertEq("undefined", typeof test, "Should not have named X-ray property access with pref=2"); + browser.test.assertEq(undefined, window.test, "Should not have named X-ray property access with pref=2"); + browser.test.assertEq("object", typeof unwrapped.test, "Should always have non-X-ray named property access"); + + browser.test.notifyPass("contentScriptXrays"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [{ + "matches": ["http://*/*/file_sample.html"], + "js": ["content_script.js"], + }], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + extension.onMessage("set-pref", value => { + Preferences.set(XRAY_PREF, value); + extension.sendMessage("pref-set"); + }); + + equal(Preferences.get(XRAY_PREF), 1, "Should have pref=1 by default"); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage(`${BASE_URL}/file_sample.html`); + + await extension.awaitFinish("contentScriptXrays"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js b/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js new file mode 100644 index 0000000000..9e6870f293 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js @@ -0,0 +1,169 @@ +"use strict"; + +const global = this; + +Cu.import("resource://gre/modules/Timer.jsm"); + +Cu.import("resource://gre/modules/ExtensionCommon.jsm"); + +var { + BaseContext, + SingletonEventManager, +} = ExtensionCommon; + +class StubContext extends BaseContext { + constructor() { + let fakeExtension = {id: "test@web.extension"}; + super("testEnv", fakeExtension); + this.sandbox = Cu.Sandbox(global); + } + + get cloneScope() { + return this.sandbox; + } +} + + +add_task(async function test_post_unload_promises() { + let context = new StubContext(); + + let fail = result => { + ok(false, `Unexpected callback: ${result}`); + }; + + // Make sure promises resolve normally prior to unload. + let promises = [ + context.wrapPromise(Promise.resolve()), + context.wrapPromise(Promise.reject({message: ""})).catch(() => {}), + ]; + + await Promise.all(promises); + + // Make sure promises that resolve after unload do not trigger + // resolution handlers. + + context.wrapPromise(Promise.resolve("resolved")) + .then(fail); + + context.wrapPromise(Promise.reject({message: "rejected"})) + .then(fail, fail); + + context.unload(); + + // The `setTimeout` ensures that we return to the event loop after + // promise resolution, which means we're guaranteed to return after + // any micro-tasks that get enqueued by the resolution handlers above. + await new Promise(resolve => setTimeout(resolve, 0)); +}); + + +add_task(async function test_post_unload_listeners() { + let context = new StubContext(); + + let fireSingleton; + let onSingleton = new SingletonEventManager(context, "onSingleton", fire => { + fireSingleton = () => { + fire.async(); + }; + return () => {}; + }); + + let fail = event => { + ok(false, `Unexpected event: ${event}`); + }; + + // Check that event listeners isn't called after it has been removed. + onSingleton.addListener(fail); + + let promise = new Promise(resolve => onSingleton.addListener(resolve)); + + fireSingleton("onSingleton"); + + // The `fireSingleton` call ia dispatched asynchronously, so it won't + // have fired by this point. The `fail` listener that we remove now + // should not be called, even though the event has already been + // enqueued. + onSingleton.removeListener(fail); + + // Wait for the remaining listener to be called, which should always + // happen after the `fail` listener would normally be called. + await promise; + + // Check that the event listener isn't called after the context has + // unloaded. + onSingleton.addListener(fail); + + // The `fire` callback always dispatches events + // asynchronously, so we need to test that any pending event callbacks + // aren't fired after the context unloads. We also need to test that + // any `fire` calls that happen *after* the context is unloaded also + // do not trigger callbacks. + fireSingleton("onSingleton"); + Promise.resolve("onSingleton").then(fireSingleton); + + context.unload(); + + // The `setTimeout` ensures that we return to the event loop after + // promise resolution, which means we're guaranteed to return after + // any micro-tasks that get enqueued by the resolution handlers above. + await new Promise(resolve => setTimeout(resolve, 0)); +}); + +class Context extends BaseContext { + constructor(principal) { + let fakeExtension = {id: "test@web.extension"}; + super("testEnv", fakeExtension); + Object.defineProperty(this, "principal", { + value: principal, + configurable: true, + }); + this.sandbox = Cu.Sandbox(principal, {wantXrays: false}); + } + + get cloneScope() { + return this.sandbox; + } +} + +let ssm = Services.scriptSecurityManager; +const PRINCIPAL1 = ssm.createCodebasePrincipalFromOrigin("http://www.example.org"); +const PRINCIPAL2 = ssm.createCodebasePrincipalFromOrigin("http://www.somethingelse.org"); + +// Test that toJSON() works in the json sandbox +add_task(async function test_stringify_toJSON() { + let context = new Context(PRINCIPAL1); + let obj = Cu.evalInSandbox("({hidden: true, toJSON() { return {visible: true}; } })", context.sandbox); + + let stringified = context.jsonStringify(obj); + let expected = JSON.stringify({visible: true}); + equal(stringified, expected, "Stringified object with toJSON() method is as expected"); +}); + +// Test that stringifying in inaccessible property throws +add_task(async function test_stringify_inaccessible() { + let context = new Context(PRINCIPAL1); + let sandbox = context.sandbox; + let sandbox2 = Cu.Sandbox(PRINCIPAL2); + + Cu.waiveXrays(sandbox).subobj = Cu.evalInSandbox("({ subobject: true })", sandbox2); + let obj = Cu.evalInSandbox("({ local: true, nested: subobj })", sandbox); + Assert.throws(() => { + context.jsonStringify(obj); + }); +}); + +add_task(async function test_stringify_accessible() { + // Test that an accessible property from another global is included + let principal = Cu.getObjectPrincipal(Cu.Sandbox([PRINCIPAL1, PRINCIPAL2])); + let context = new Context(principal); + let sandbox = context.sandbox; + let sandbox2 = Cu.Sandbox(PRINCIPAL2); + + Cu.waiveXrays(sandbox).subobj = Cu.evalInSandbox("({ subobject: true })", sandbox2); + let obj = Cu.evalInSandbox("({ local: true, nested: subobj })", sandbox); + let stringified = context.jsonStringify(obj); + + let expected = JSON.stringify({local: true, nested: {subobject: true}}); + equal(stringified, expected, "Stringified object with accessible property is as expected"); +}); + diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js b/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js new file mode 100644 index 0000000000..8eb46213fa --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js @@ -0,0 +1,140 @@ +"use strict"; + +do_get_profile(); + +add_task(async function test_contextualIdentities_without_permissions() { + function backgroundScript() { + browser.test.assertTrue(!browser.contextualIdentities, + "contextualIdentities API is not available when the contextualIdentities permission is not required"); + browser.test.notifyPass("contextualIdentities_permission"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: [], + }, + }); + + await extension.startup(); + await extension.awaitFinish("contextualIdentities_permission"); + await extension.unload(); +}); + + +add_task(async function test_contextualIdentity_no_containers() { + async function backgroundScript() { + let ci = await browser.contextualIdentities.get("foobar"); + browser.test.assertEq(false, ci, "No identity should be returned here"); + + ci = await browser.contextualIdentities.get("firefox-container-1"); + browser.test.assertEq(false, ci, "We don't have any identity"); + + let cis = await browser.contextualIdentities.query({}); + browser.test.assertEq(false, cis, "no containers, 0 containers"); + + ci = await browser.contextualIdentities.create({name: "foobar", color: "red", icon: "icon"}); + browser.test.assertEq(false, ci, "We don't have any identity"); + + ci = await browser.contextualIdentities.update("firefox-container-1", {name: "barfoo", color: "blue", icon: "icon icon"}); + browser.test.assertEq(false, ci, "We don't have any identity"); + + ci = await browser.contextualIdentities.remove("firefox-container-1"); + browser.test.assertEq(false, ci, "We have an identity"); + + browser.test.notifyPass("contextualIdentities"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["contextualIdentities"], + }, + }); + + Services.prefs.setBoolPref("privacy.userContext.enabled", false); + + await extension.startup(); + await extension.awaitFinish("contextualIdentities"); + await extension.unload(); + + Services.prefs.clearUserPref("privacy.userContext.enabled"); +}); + +add_task(async function test_contextualIdentity_with_permissions() { + async function backgroundScript() { + let ci = await browser.contextualIdentities.get("foobar"); + browser.test.assertEq(null, ci, "No identity should be returned here"); + + ci = await browser.contextualIdentities.get("firefox-container-1"); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertTrue("name" in ci, "We have an identity.name"); + browser.test.assertTrue("color" in ci, "We have an identity.color"); + browser.test.assertTrue("icon" in ci, "We have an identity.icon"); + browser.test.assertEq("Personal", ci.name, "identity.name is correct"); + browser.test.assertEq("firefox-container-1", ci.cookieStoreId, "identity.cookieStoreId is correct"); + + let cis = await browser.contextualIdentities.query({}); + browser.test.assertEq(4, cis.length, "by default we should have 4 containers"); + + cis = await browser.contextualIdentities.query({name: "Personal"}); + browser.test.assertEq(1, cis.length, "by default we should have 1 container called Personal"); + + cis = await browser.contextualIdentities.query({name: "foobar"}); + browser.test.assertEq(0, cis.length, "by default we should have 0 container called foobar"); + + ci = await browser.contextualIdentities.create({name: "foobar", color: "red", icon: "icon"}); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("foobar", ci.name, "identity.name is correct"); + browser.test.assertEq("red", ci.color, "identity.color is correct"); + browser.test.assertEq("icon", ci.icon, "identity.icon is correct"); + browser.test.assertTrue(!!ci.cookieStoreId, "identity.cookieStoreId is correct"); + + ci = await browser.contextualIdentities.get(ci.cookieStoreId); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("foobar", ci.name, "identity.name is correct"); + browser.test.assertEq("red", ci.color, "identity.color is correct"); + browser.test.assertEq("icon", ci.icon, "identity.icon is correct"); + + cis = await browser.contextualIdentities.query({}); + browser.test.assertEq(5, cis.length, "now we have 5 identities"); + + ci = await browser.contextualIdentities.update(ci.cookieStoreId, {name: "barfoo", color: "blue", icon: "icon icon"}); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("barfoo", ci.name, "identity.name is correct"); + browser.test.assertEq("blue", ci.color, "identity.color is correct"); + browser.test.assertEq("icon icon", ci.icon, "identity.icon is correct"); + + ci = await browser.contextualIdentities.get(ci.cookieStoreId); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("barfoo", ci.name, "identity.name is correct"); + browser.test.assertEq("blue", ci.color, "identity.color is correct"); + browser.test.assertEq("icon icon", ci.icon, "identity.icon is correct"); + + ci = await browser.contextualIdentities.remove(ci.cookieStoreId); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("barfoo", ci.name, "identity.name is correct"); + browser.test.assertEq("blue", ci.color, "identity.color is correct"); + browser.test.assertEq("icon icon", ci.icon, "identity.icon is correct"); + + cis = await browser.contextualIdentities.query({}); + browser.test.assertEq(4, cis.length, "we are back to 4 identities"); + + browser.test.notifyPass("contextualIdentities"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["contextualIdentities"], + }, + }); + + Services.prefs.setBoolPref("privacy.userContext.enabled", true); + + await extension.startup(); + await extension.awaitFinish("contextualIdentities"); + await extension.unload(); + + Services.prefs.clearUserPref("privacy.userContext.enabled"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js b/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js new file mode 100644 index 0000000000..0c5e3e000d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js @@ -0,0 +1,231 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Cu.import("resource://gre/modules/ExtensionParent.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); + +add_task(async function testExtensionDebuggingUtilsCleanup() { + const extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("background.ready"); + }, + }); + + const expectedEmptyDebugUtils = { + hiddenXULWindow: null, + cacheSize: 0, + }; + + let { + hiddenXULWindow, + debugBrowserPromises, + } = ExtensionParent.DebugUtils; + + deepEqual({hiddenXULWindow, cacheSize: debugBrowserPromises.size}, + expectedEmptyDebugUtils, + "No ExtensionDebugUtils resources has been allocated yet"); + + await extension.startup(); + + await extension.awaitMessage("background.ready"); + + hiddenXULWindow = ExtensionParent.DebugUtils.hiddenXULWindow; + deepEqual({hiddenXULWindow, cacheSize: debugBrowserPromises.size}, + expectedEmptyDebugUtils, + "No debugging resources has been yet allocated once the extension is running"); + + const fakeAddonActor = { + addonId: extension.id, + }; + + const anotherAddonActor = { + addonId: extension.id, + }; + + const waitFirstBrowser = ExtensionParent.DebugUtils.getExtensionProcessBrowser(fakeAddonActor); + const waitSecondBrowser = ExtensionParent.DebugUtils.getExtensionProcessBrowser(anotherAddonActor); + + const addonDebugBrowser = await waitFirstBrowser; + equal(addonDebugBrowser.isRemoteBrowser, extension.extension.remote, + "The addon debugging browser has the expected remote type"); + + equal((await waitSecondBrowser), addonDebugBrowser, + "Two addon debugging actors related to the same addon get the same browser element "); + + equal(debugBrowserPromises.size, 1, "The expected resources has been allocated"); + + const nonExistentAddonActor = { + addonId: "non-existent-addon@test", + }; + + const waitRejection = ExtensionParent.DebugUtils.getExtensionProcessBrowser(nonExistentAddonActor); + + Assert.rejects(waitRejection, /Extension not found/, + "Reject with the expected message for non existent addons"); + + equal(debugBrowserPromises.size, 1, "No additional debugging resources has been allocated"); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser(fakeAddonActor); + + equal(debugBrowserPromises.size, 1, + "The addon debugging browser is cached until all the related actors have released it"); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser(anotherAddonActor); + + hiddenXULWindow = ExtensionParent.DebugUtils.hiddenXULWindow; + + deepEqual({hiddenXULWindow, cacheSize: debugBrowserPromises.size}, + expectedEmptyDebugUtils, + "All the allocated debugging resources has been cleared"); + + await extension.unload(); +}); + +add_task(async function testExtensionDebuggingUtilsAddonReloaded() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "test-reloaded@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background.ready"); + + let fakeAddonActor = { + addonId: extension.id, + }; + + const addonDebugBrowser = await ExtensionParent.DebugUtils + .getExtensionProcessBrowser(fakeAddonActor); + equal(addonDebugBrowser.isRemoteBrowser, extension.extension.remote, + "The addon debugging browser has the expected remote type"); + equal(ExtensionParent.DebugUtils.debugBrowserPromises.size, 1, + "Got the expected number of requested debug browsers"); + + const {chromeDocument} = ExtensionParent.DebugUtils.hiddenXULWindow; + + ok(addonDebugBrowser.parentElement === chromeDocument.documentElement, + "The addon debugging browser is part of the hiddenXULWindow chromeDocument"); + + await extension.unload(); + + // Install an extension with the same id to recreate for the DebugUtils + // conditions similar to an addon reloaded while the Addon Debugger is opened. + extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "test-reloaded@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background.ready"); + + equal(ExtensionParent.DebugUtils.debugBrowserPromises.size, 1, + "Got the expected number of requested debug browsers"); + + const newAddonDebugBrowser = await ExtensionParent.DebugUtils + .getExtensionProcessBrowser(fakeAddonActor); + + equal(addonDebugBrowser, newAddonDebugBrowser, + "The existent debugging browser has been reused"); + + equal(newAddonDebugBrowser.isRemoteBrowser, extension.extension.remote, + "The addon debugging browser has the expected remote type"); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser(fakeAddonActor); + + equal(ExtensionParent.DebugUtils.debugBrowserPromises.size, 0, + "All the addon debugging browsers has been released"); + + await extension.unload(); +}); + +add_task(async function testExtensionDebuggingUtilsWithMultipleAddons() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "test-addon-1@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + let anotherExtension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "test-addon-2@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background.ready"); + + await anotherExtension.startup(); + await anotherExtension.awaitMessage("background.ready"); + + const fakeAddonActor = { + addonId: extension.id, + }; + + const anotherFakeAddonActor = { + addonId: anotherExtension.id, + }; + + const {DebugUtils} = ExtensionParent; + const debugBrowser = await DebugUtils.getExtensionProcessBrowser(fakeAddonActor); + const anotherDebugBrowser = await DebugUtils.getExtensionProcessBrowser(anotherFakeAddonActor); + + const chromeDocument = DebugUtils.hiddenXULWindow.chromeDocument; + + equal(ExtensionParent.DebugUtils.debugBrowserPromises.size, 2, + "Got the expected number of debug browsers requested"); + ok(debugBrowser.parentElement === chromeDocument.documentElement, + "The first debug browser is part of the hiddenXUL chromeDocument"); + ok(anotherDebugBrowser.parentElement === chromeDocument.documentElement, + "The second debug browser is part of the hiddenXUL chromeDocument"); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser(fakeAddonActor); + + equal(ExtensionParent.DebugUtils.debugBrowserPromises.size, 1, + "Got the expected number of debug browsers requested"); + + ok(anotherDebugBrowser.parentElement === chromeDocument.documentElement, + "The second debug browser is still part of the hiddenXUL chromeDocument"); + + ok(debugBrowser.parentElement == null, + "The first debug browser has been removed from the hiddenXUL chromeDocument"); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser(anotherFakeAddonActor); + + ok(anotherDebugBrowser.parentElement == null, + "The second debug browser has been removed from the hiddenXUL chromeDocument"); + equal(ExtensionParent.DebugUtils.debugBrowserPromises.size, 0, + "All the addon debugging browsers has been released"); + + await extension.unload(); + await anotherExtension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js new file mode 100644 index 0000000000..c2da4a573d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js @@ -0,0 +1,76 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_downloads_api_namespace_and_permissions() { + function backgroundScript() { + browser.test.assertTrue(!!browser.downloads, "`downloads` API is present."); + browser.test.assertTrue(!!browser.downloads.FilenameConflictAction, + "`downloads.FilenameConflictAction` enum is present."); + browser.test.assertTrue(!!browser.downloads.InterruptReason, + "`downloads.InterruptReason` enum is present."); + browser.test.assertTrue(!!browser.downloads.DangerType, + "`downloads.DangerType` enum is present."); + browser.test.assertTrue(!!browser.downloads.State, + "`downloads.State` enum is present."); + browser.test.notifyPass("downloads tests"); + } + + let extensionData = { + background: backgroundScript, + manifest: { + permissions: ["downloads", "downloads.open", "downloads.shelf"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("downloads tests"); + await extension.unload(); +}); + +add_task(async function test_downloads_open_permission() { + function backgroundScript() { + browser.test.assertEq(browser.downloads.open, undefined, + "`downloads.open` permission is required."); + browser.test.notifyPass("downloads tests"); + } + + let extensionData = { + background: backgroundScript, + manifest: { + permissions: ["downloads"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("downloads tests"); + await extension.unload(); +}); + +add_task(async function test_downloads_open() { + async function backgroundScript() { + await browser.test.assertRejects( + browser.downloads.open(10), + "Invalid download id 10", + "The error is informative."); + + browser.test.notifyPass("downloads tests"); + + // TODO: Once downloads.{pause,cancel,resume} lands (bug 1245602) test that this gives a good + // error when called with an incompleted download. + } + + let extensionData = { + background: backgroundScript, + manifest: { + permissions: ["downloads", "downloads.open"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("downloads tests"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js new file mode 100644 index 0000000000..d367c05cb0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js @@ -0,0 +1,354 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* global OS */ + +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/Downloads.jsm"); + +const gServer = createHttpServer(); +gServer.registerDirectory("/data/", do_get_file("data")); + +const WINDOWS = AppConstants.platform == "win"; + +const BASE = `http://localhost:${gServer.identity.primaryPort}/data`; +const FILE_NAME = "file_download.txt"; +const FILE_URL = BASE + "/" + FILE_NAME; +const FILE_NAME_UNIQUE = "file_download(1).txt"; +const FILE_LEN = 46; + +let downloadDir; + +function setup() { + downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + do_print(`Using download directory ${downloadDir.path}`); + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, downloadDir); + + do_register_cleanup(() => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + + let entries = downloadDir.directoryEntries; + while (entries.hasMoreElements()) { + let entry = entries.getNext().QueryInterface(Ci.nsIFile); + ok(false, `Leftover file ${entry.path} in download directory`); + entry.remove(false); + } + + downloadDir.remove(false); + }); +} + +function backgroundScript() { + let blobUrl; + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg == "download.request") { + let options = args[0]; + + if (options.blobme) { + let blob = new Blob(options.blobme); + delete options.blobme; + blobUrl = options.url = window.URL.createObjectURL(blob); + } + + try { + let id = await browser.downloads.download(options); + browser.test.sendMessage("download.done", {status: "success", id}); + } catch (error) { + browser.test.sendMessage("download.done", {status: "error", errmsg: error.message}); + } + } else if (msg == "killTheBlob") { + window.URL.revokeObjectURL(blobUrl); + blobUrl = null; + } + }); + + browser.test.sendMessage("ready"); +} + +// This function is a bit of a sledgehammer, it looks at every download +// the browser knows about and waits for all active downloads to complete. +// But we only start one at a time and only do a handful in total, so +// this lets us test download() without depending on anything else. +async function waitForDownloads() { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + let inprogress = downloads.filter(dl => !dl.stopped); + return Promise.all(inprogress.map(dl => dl.whenSucceeded())); +} + +// Create a file in the downloads directory. +function touch(filename) { + let file = downloadDir.clone(); + file.append(filename); + file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); +} + +// Remove a file in the downloads directory. +function remove(filename, recursive = false) { + let file = downloadDir.clone(); + file.append(filename); + file.remove(recursive); +} + +add_task(async function test_downloads() { + setup(); + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["downloads"], + }, + }); + + function download(options) { + extension.sendMessage("download.request", options); + return extension.awaitMessage("download.done"); + } + + async function testDownload(options, localFile, expectedSize, description) { + let msg = await download(options); + equal(msg.status, "success", `downloads.download() works with ${description}`); + + await waitForDownloads(); + + let localPath = downloadDir.clone(); + let parts = Array.isArray(localFile) ? localFile : [localFile]; + + parts.map(p => localPath.append(p)); + equal(localPath.fileSize, expectedSize, "Downloaded file has expected size"); + localPath.remove(false); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + do_print("extension started"); + + // Call download() with just the url property. + await testDownload({url: FILE_URL}, FILE_NAME, FILE_LEN, "just source"); + + // Call download() with a filename property. + await testDownload({ + url: FILE_URL, + filename: "newpath.txt", + }, "newpath.txt", FILE_LEN, "source and filename"); + + // Call download() with a filename with subdirs. + await testDownload({ + url: FILE_URL, + filename: "sub/dir/file", + }, ["sub", "dir", "file"], FILE_LEN, "source and filename with subdirs"); + + // Call download() with a filename with existing subdirs. + await testDownload({ + url: FILE_URL, + filename: "sub/dir/file2", + }, ["sub", "dir", "file2"], FILE_LEN, "source and filename with existing subdirs"); + + // Only run Windows path separator test on Windows. + if (WINDOWS) { + // Call download() with a filename with Windows path separator. + await testDownload({ + url: FILE_URL, + filename: "sub\\dir\\file3", + }, ["sub", "dir", "file3"], FILE_LEN, "filename with Windows path separator"); + } + remove("sub", true); + + // Call download(), filename with subdir, skipping parts. + await testDownload({ + url: FILE_URL, + filename: "skip//part", + }, ["skip", "part"], FILE_LEN, "source, filename, with subdir, skipping parts"); + remove("skip", true); + + // Check conflictAction of "uniquify". + touch(FILE_NAME); + await testDownload({ + url: FILE_URL, + conflictAction: "uniquify", + }, FILE_NAME_UNIQUE, FILE_LEN, "conflictAction=uniquify"); + // todo check that preexisting file was not modified? + remove(FILE_NAME); + + // Check conflictAction of "overwrite". + touch(FILE_NAME); + await testDownload({ + url: FILE_URL, + conflictAction: "overwrite", + }, FILE_NAME, FILE_LEN, "conflictAction=overwrite"); + + // Try to download in invalid url + await download({url: "this is not a valid URL"}).then(msg => { + equal(msg.status, "error", "downloads.download() fails with invalid url"); + ok(/not a valid URL/.test(msg.errmsg), "error message for invalid url is correct"); + }); + + // Try to download to an empty path. + await download({ + url: FILE_URL, + filename: "", + }).then(msg => { + equal(msg.status, "error", "downloads.download() fails with empty filename"); + equal(msg.errmsg, "filename must not be empty", "error message for empty filename is correct"); + }); + + // Try to download to an absolute path. + const absolutePath = OS.Path.join(WINDOWS ? "\\tmp" : "/tmp", "file_download.txt"); + await download({ + url: FILE_URL, + filename: absolutePath, + }).then(msg => { + equal(msg.status, "error", "downloads.download() fails with absolute filename"); + equal(msg.errmsg, "filename must not be an absolute path", `error message for absolute path (${absolutePath}) is correct`); + }); + + if (WINDOWS) { + await download({ + url: FILE_URL, + filename: "C:\\file_download.txt", + }).then(msg => { + equal(msg.status, "error", "downloads.download() fails with absolute filename"); + equal(msg.errmsg, "filename must not be an absolute path", "error message for absolute path with drive letter is correct"); + }); + } + + // Try to download to a relative path containing .. + await download({ + url: FILE_URL, + filename: OS.Path.join("..", "file_download.txt"), + }).then(msg => { + equal(msg.status, "error", "downloads.download() fails with back-references"); + equal(msg.errmsg, "filename must not contain back-references (..)", "error message for back-references is correct"); + }); + + // Try to download to a long relative path containing .. + await download({ + url: FILE_URL, + filename: OS.Path.join("foo", "..", "..", "file_download.txt"), + }).then(msg => { + equal(msg.status, "error", "downloads.download() fails with back-references"); + equal(msg.errmsg, "filename must not contain back-references (..)", "error message for back-references is correct"); + }); + + // Try to download a blob url + const BLOB_STRING = "Hello, world"; + await testDownload({ + blobme: [BLOB_STRING], + filename: FILE_NAME, + }, FILE_NAME, BLOB_STRING.length, "blob url"); + extension.sendMessage("killTheBlob"); + + // Try to download a blob url without a given filename + await testDownload({ + blobme: [BLOB_STRING], + }, "download", BLOB_STRING.length, "blob url with no filename"); + extension.sendMessage("killTheBlob"); + + await extension.unload(); +}); + +add_task(async function test_download_post() { + const server = createHttpServer(); + const url = `http://localhost:${server.identity.primaryPort}/post-log`; + + let received; + server.registerPathHandler("/post-log", request => { + received = request; + }); + + // Confirm received vs. expected values. + function confirm(method, headers = {}, body) { + equal(received.method, method, "method is correct"); + + for (let name in headers) { + ok(received.hasHeader(name), `header ${name} received`); + equal(received.getHeader(name), headers[name], `header ${name} is correct`); + } + + if (body) { + const str = NetUtil.readInputStreamToString(received.bodyInputStream, + received.bodyInputStream.available()); + equal(str, body, "body is correct"); + } + } + + function background() { + browser.test.onMessage.addListener(async options => { + try { + await browser.downloads.download(options); + } catch (err) { + browser.test.sendMessage("done", {err: err.message}); + } + }); + browser.downloads.onChanged.addListener(({state}) => { + if (state && state.current === "complete") { + browser.test.sendMessage("done", {ok: true}); + } + }); + } + + const manifest = {permissions: ["downloads"]}; + const extension = ExtensionTestUtils.loadExtension({background, manifest}); + await extension.startup(); + + function download(options) { + options.url = url; + options.conflictAction = "overwrite"; + + extension.sendMessage(options); + return extension.awaitMessage("done"); + } + + // Test method option. + let result = await download({}); + ok(result.ok, "download works without the method option, defaults to GET"); + confirm("GET"); + + result = await download({method: "PUT"}); + ok(!result.ok, "download rejected with PUT method"); + ok(/method: Invalid enumeration/.test(result.err), "descriptive error message"); + + result = await download({method: "POST"}); + ok(result.ok, "download works with POST method"); + confirm("POST"); + + // Test body option values. + result = await download({body: []}); + ok(!result.ok, "download rejected because of non-string body"); + ok(/body: Expected string/.test(result.err), "descriptive error message"); + + result = await download({method: "POST", body: "of work"}); + ok(result.ok, "download works with POST method and body"); + confirm("POST", {"Content-Length": 7}, "of work"); + + // Test custom headers. + result = await download({headers: [{name: "X-Custom"}]}); + ok(!result.ok, "download rejected because of missing header value"); + ok(/"value" is required/.test(result.err), "descriptive error message"); + + result = await download({headers: [{name: "X-Custom", value: "13"}]}); + ok(result.ok, "download works with a custom header"); + confirm("GET", {"X-Custom": "13"}); + + // Test forbidden headers. + result = await download({headers: [{name: "DNT", value: "1"}]}); + ok(!result.ok, "download rejected because of forbidden header name DNT"); + ok(/Forbidden request header/.test(result.err), "descriptive error message"); + + result = await download({headers: [{name: "Proxy-Connection", value: "keep"}]}); + ok(!result.ok, "download rejected because of forbidden header name prefix Proxy-"); + ok(/Forbidden request header/.test(result.err), "descriptive error message"); + + result = await download({headers: [{name: "Sec-ret", value: "13"}]}); + ok(!result.ok, "download rejected because of forbidden header name prefix Sec-"); + ok(/Forbidden request header/.test(result.err), "descriptive error message"); + + remove("post-log"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js new file mode 100644 index 0000000000..275d5e90d9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js @@ -0,0 +1,862 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Cu.import("resource://gre/modules/Downloads.jsm"); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const ROOT = `http://localhost:${server.identity.primaryPort}`; +const BASE = `${ROOT}/data`; +const TXT_FILE = "file_download.txt"; +const TXT_URL = BASE + "/" + TXT_FILE; + +// Keep these in sync with code in interruptible.sjs +const INT_PARTIAL_LEN = 15; +const INT_TOTAL_LEN = 31; + +const TEST_DATA = "This is 31 bytes of sample data"; +const TOTAL_LEN = TEST_DATA.length; +const PARTIAL_LEN = 15; + +// A handler to let us systematically test pausing/resuming/canceling +// of downloads. This target represents a small text file but a simple +// GET will stall after sending part of the data, to give the test code +// a chance to pause or do other operations on an in-progress download. +// A resumed download (ie, a GET with a Range: header) will allow the +// download to complete. +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain", false); + + if (request.hasHeader("Range")) { + let start, end; + let matches = request.getHeader("Range") + .match(/^\s*bytes=(\d+)?-(\d+)?\s*$/); + if (matches != null) { + start = matches[1] ? parseInt(matches[1], 10) : 0; + end = matches[2] ? parseInt(matches[2], 10) : (TOTAL_LEN - 1); + } + + if (end == undefined || end >= TOTAL_LEN) { + response.setStatusLine(request.httpVersion, 416, "Requested Range Not Satisfiable"); + response.setHeader("Content-Range", `*/${TOTAL_LEN}`, false); + response.finish(); + return; + } + + response.setStatusLine(request.httpVersion, 206, "Partial Content"); + response.setHeader("Content-Range", `${start}-${end}/${TOTAL_LEN}`, false); + response.write(TEST_DATA.slice(start, end + 1)); + } else { + response.processAsync(); + response.setHeader("Content-Length", `${TOTAL_LEN}`, false); + response.write(TEST_DATA.slice(0, PARTIAL_LEN)); + } + + do_register_cleanup(() => { + try { + response.finish(); + } catch (e) { + // This will throw, but we don't care at this point. + } + }); +} + +server.registerPathHandler("/interruptible.html", handleRequest); + +let interruptibleCount = 0; +function getInterruptibleUrl() { + let n = interruptibleCount++; + return `${ROOT}/interruptible.html?count=${n}`; +} + +function backgroundScript() { + let events = new Set(); + let eventWaiter = null; + + browser.downloads.onCreated.addListener(data => { + events.add({type: "onCreated", data}); + if (eventWaiter) { + eventWaiter(); + } + }); + + browser.downloads.onChanged.addListener(data => { + events.add({type: "onChanged", data}); + if (eventWaiter) { + eventWaiter(); + } + }); + + browser.downloads.onErased.addListener(data => { + events.add({type: "onErased", data}); + if (eventWaiter) { + eventWaiter(); + } + }); + + // Returns a promise that will resolve when the given list of expected + // events have all been seen. By default, succeeds only if the exact list + // of expected events is seen in the given order. options.exact can be + // set to false to allow other events and options.inorder can be set to + // false to allow the events to arrive in any order. + function waitForEvents(expected, options = {}) { + function compare(a, b) { + if (typeof b == "object" && b != null) { + if (typeof a != "object") { + return false; + } + return Object.keys(b).every(fld => compare(a[fld], b[fld])); + } + return (a == b); + } + + const exact = ("exact" in options) ? options.exact : true; + const inorder = ("inorder" in options) ? options.inorder : true; + return new Promise((resolve, reject) => { + function check() { + function fail(msg) { + browser.test.fail(msg); + reject(new Error(msg)); + } + if (events.size < expected.length) { + return; + } + if (exact && expected.length < events.size) { + fail(`Got ${events.size} events but only expected ${expected.length}`); + return; + } + + let remaining = new Set(events); + if (inorder) { + for (let event of events) { + if (compare(event, expected[0])) { + expected.shift(); + remaining.delete(event); + } + } + } else { + expected = expected.filter(val => { + for (let remainingEvent of remaining) { + if (compare(remainingEvent, val)) { + remaining.delete(remainingEvent); + return false; + } + } + return true; + }); + } + + // Events that did occur have been removed from expected so if + // expected is empty, we're done. If we didn't see all the + // expected events and we're not looking for an exact match, + // then we just may not have seen the event yet, so return without + // failing and check() will be called again when a new event arrives. + if (expected.length == 0) { + events = remaining; + eventWaiter = null; + resolve(); + } else if (exact) { + fail(`Mismatched event: expecting ${JSON.stringify(expected[0])} but got ${JSON.stringify(Array.from(remaining)[0])}`); + } + } + eventWaiter = check; + check(); + }); + } + + browser.test.onMessage.addListener(async (msg, ...args) => { + let match = msg.match(/(\w+).request$/); + if (!match) { + return; + } + + let what = match[1]; + if (what == "waitForEvents") { + try { + await waitForEvents(...args); + browser.test.sendMessage("waitForEvents.done", {status: "success"}); + } catch (error) { + browser.test.sendMessage("waitForEvents.done", {status: "error", errmsg: error.message}); + } + } else if (what == "clearEvents") { + events = new Set(); + browser.test.sendMessage("clearEvents.done", {status: "success"}); + } else { + try { + let result = await browser.downloads[what](...args); + browser.test.sendMessage(`${what}.done`, {status: "success", result}); + } catch (error) { + browser.test.sendMessage(`${what}.done`, {status: "error", errmsg: error.message}); + } + } + }); + + browser.test.sendMessage("ready"); +} + +let downloadDir; +let extension; + +async function clearDownloads(callback) { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + await Promise.all(downloads.map(download => list.remove(download))); + + return downloads; +} + +function runInExtension(what, ...args) { + extension.sendMessage(`${what}.request`, ...args); + return extension.awaitMessage(`${what}.done`); +} + +// This is pretty simplistic, it looks for a progress update for a +// download of the given url in which the total bytes are exactly equal +// to the given value. Unless you know exactly how data will arrive from +// the server (eg see interruptible.sjs), it probably isn't very useful. +async function waitForProgress(url, bytes) { + let list = await Downloads.getList(Downloads.ALL); + + return new Promise(resolve => { + const view = { + onDownloadChanged(download) { + if (download.source.url == url && download.currentBytes == bytes) { + list.removeView(view); + resolve(); + } + }, + }; + list.addView(view); + }); +} + +add_task(async function setup() { + const nsIFile = Ci.nsIFile; + downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + do_print(`downloadDir ${downloadDir.path}`); + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir); + + do_register_cleanup(() => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + downloadDir.remove(true); + + return clearDownloads(); + }); + + await clearDownloads().then(downloads => { + do_print(`removed ${downloads.length} pre-existing downloads from history`); + }); + + extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["downloads"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); +}); + +add_task(async function test_events() { + let msg = await runInExtension("download", {url: TXT_URL}); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + msg = await runInExtension("waitForEvents", [ + {type: "onCreated", data: {id, url: TXT_URL}}, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + equal(msg.status, "success", "got onCreated and onChanged events"); +}); + +add_task(async function test_cancel() { + let url = getInterruptibleUrl(); + do_print(url); + let msg = await runInExtension("download", {url}); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let progressPromise = waitForProgress(url, INT_PARTIAL_LEN); + + msg = await runInExtension("waitForEvents", [ + {type: "onCreated", data: {id}}, + ]); + equal(msg.status, "success", "got created and changed events"); + + await progressPromise; + do_print(`download reached ${INT_PARTIAL_LEN} bytes`); + + msg = await runInExtension("cancel", id); + equal(msg.status, "success", "cancel() succeeded"); + + // This sequence of events is bogus (bug 1256243) + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + state: { + previous: "in_progress", + current: "interrupted", + }, + paused: { + previous: false, + current: true, + }, + }, + }, { + type: "onChanged", + data: { + id, + error: { + previous: null, + current: "USER_CANCELED", + }, + }, + }, { + type: "onChanged", + data: { + id, + paused: { + previous: true, + current: false, + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged events corresponding to cancel()"); + + msg = await runInExtension("search", {error: "USER_CANCELED"}); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].id, id, "download.id is correct"); + equal(msg.result[0].state, "interrupted", "download.state is correct"); + equal(msg.result[0].paused, false, "download.paused is correct"); + equal(msg.result[0].canResume, false, "download.canResume is correct"); + equal(msg.result[0].error, "USER_CANCELED", "download.error is correct"); + equal(msg.result[0].totalBytes, INT_TOTAL_LEN, "download.totalBytes is correct"); + equal(msg.result[0].exists, false, "download.exists is correct"); + + msg = await runInExtension("pause", id); + equal(msg.status, "error", "cannot pause a canceled download"); + + msg = await runInExtension("resume", id); + equal(msg.status, "error", "cannot resume a canceled download"); +}); + +add_task(async function test_pauseresume() { + let url = getInterruptibleUrl(); + let msg = await runInExtension("download", {url}); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let progressPromise = waitForProgress(url, INT_PARTIAL_LEN); + + msg = await runInExtension("waitForEvents", [ + {type: "onCreated", data: {id}}, + ]); + equal(msg.status, "success", "got created and changed events"); + + await progressPromise; + do_print(`download reached ${INT_PARTIAL_LEN} bytes`); + + msg = await runInExtension("pause", id); + equal(msg.status, "success", "pause() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "interrupted", + }, + paused: { + previous: false, + current: true, + }, + canResume: { + previous: false, + current: true, + }, + }, + }, { + type: "onChanged", + data: { + id, + error: { + previous: null, + current: "USER_CANCELED", + }, + }, + }]); + equal(msg.status, "success", "got onChanged event corresponding to pause"); + + msg = await runInExtension("search", {paused: true}); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].id, id, "download.id is correct"); + equal(msg.result[0].state, "interrupted", "download.state is correct"); + equal(msg.result[0].paused, true, "download.paused is correct"); + equal(msg.result[0].canResume, true, "download.canResume is correct"); + equal(msg.result[0].error, "USER_CANCELED", "download.error is correct"); + equal(msg.result[0].bytesReceived, INT_PARTIAL_LEN, "download.bytesReceived is correct"); + equal(msg.result[0].totalBytes, INT_TOTAL_LEN, "download.totalBytes is correct"); + equal(msg.result[0].exists, false, "download.exists is correct"); + + msg = await runInExtension("search", {error: "USER_CANCELED"}); + equal(msg.status, "success", "search() succeeded"); + let found = msg.result.filter(item => item.id == id); + equal(found.length, 1, "search() by error found the paused download"); + + msg = await runInExtension("pause", id); + equal(msg.status, "error", "cannot pause an already paused download"); + + msg = await runInExtension("resume", id); + equal(msg.status, "success", "resume() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "interrupted", + current: "in_progress", + }, + paused: { + previous: true, + current: false, + }, + canResume: { + previous: true, + current: false, + }, + error: { + previous: "USER_CANCELED", + current: null, + }, + }, + }, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged events for resume and complete"); + + msg = await runInExtension("search", {id}); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].state, "complete", "download.state is correct"); + equal(msg.result[0].paused, false, "download.paused is correct"); + equal(msg.result[0].canResume, false, "download.canResume is correct"); + equal(msg.result[0].error, null, "download.error is correct"); + equal(msg.result[0].bytesReceived, INT_TOTAL_LEN, "download.bytesReceived is correct"); + equal(msg.result[0].totalBytes, INT_TOTAL_LEN, "download.totalBytes is correct"); + equal(msg.result[0].exists, true, "download.exists is correct"); + + msg = await runInExtension("pause", id); + equal(msg.status, "error", "cannot pause a completed download"); + + msg = await runInExtension("resume", id); + equal(msg.status, "error", "cannot resume a completed download"); +}); + +add_task(async function test_pausecancel() { + let url = getInterruptibleUrl(); + let msg = await runInExtension("download", {url}); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let progressPromise = waitForProgress(url, INT_PARTIAL_LEN); + + msg = await runInExtension("waitForEvents", [ + {type: "onCreated", data: {id}}, + ]); + equal(msg.status, "success", "got created and changed events"); + + await progressPromise; + do_print(`download reached ${INT_PARTIAL_LEN} bytes`); + + msg = await runInExtension("pause", id); + equal(msg.status, "success", "pause() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "interrupted", + }, + paused: { + previous: false, + current: true, + }, + canResume: { + previous: false, + current: true, + }, + }, + }, { + type: "onChanged", + data: { + id, + error: { + previous: null, + current: "USER_CANCELED", + }, + }, + }]); + equal(msg.status, "success", "got onChanged event corresponding to pause"); + + msg = await runInExtension("search", {paused: true}); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].id, id, "download.id is correct"); + equal(msg.result[0].state, "interrupted", "download.state is correct"); + equal(msg.result[0].paused, true, "download.paused is correct"); + equal(msg.result[0].canResume, true, "download.canResume is correct"); + equal(msg.result[0].error, "USER_CANCELED", "download.error is correct"); + equal(msg.result[0].bytesReceived, INT_PARTIAL_LEN, "download.bytesReceived is correct"); + equal(msg.result[0].totalBytes, INT_TOTAL_LEN, "download.totalBytes is correct"); + equal(msg.result[0].exists, false, "download.exists is correct"); + + msg = await runInExtension("search", {error: "USER_CANCELED"}); + equal(msg.status, "success", "search() succeeded"); + let found = msg.result.filter(item => item.id == id); + equal(found.length, 1, "search() by error found the paused download"); + + msg = await runInExtension("cancel", id); + equal(msg.status, "success", "cancel() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + paused: { + previous: true, + current: false, + }, + canResume: { + previous: true, + current: false, + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged event for cancel"); + + msg = await runInExtension("search", {id}); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].state, "interrupted", "download.state is correct"); + equal(msg.result[0].paused, false, "download.paused is correct"); + equal(msg.result[0].canResume, false, "download.canResume is correct"); + equal(msg.result[0].error, "USER_CANCELED", "download.error is correct"); + equal(msg.result[0].totalBytes, INT_TOTAL_LEN, "download.totalBytes is correct"); + equal(msg.result[0].exists, false, "download.exists is correct"); +}); + +add_task(async function test_pause_resume_cancel_badargs() { + let BAD_ID = 1000; + + let msg = await runInExtension("pause", BAD_ID); + equal(msg.status, "error", "pause() failed with a bad download id"); + ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive"); + + msg = await runInExtension("resume", BAD_ID); + equal(msg.status, "error", "resume() failed with a bad download id"); + ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive"); + + msg = await runInExtension("cancel", BAD_ID); + equal(msg.status, "error", "cancel() failed with a bad download id"); + ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive"); +}); + +add_task(async function test_file_removal() { + let msg = await runInExtension("download", {url: TXT_URL}); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + msg = await runInExtension("waitForEvents", [ + {type: "onCreated", data: {id, url: TXT_URL}}, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + + equal(msg.status, "success", "got onCreated and onChanged events"); + + msg = await runInExtension("removeFile", id); + equal(msg.status, "success", "removeFile() succeeded"); + + msg = await runInExtension("removeFile", id); + equal(msg.status, "error", "removeFile() fails since the file was already removed."); + ok(/file doesn't exist/.test(msg.errmsg), "removeFile() failed on removed file."); + + msg = await runInExtension("removeFile", 1000); + ok(/Invalid download id/.test(msg.errmsg), "removeFile() failed due to non-existent id"); +}); + +add_task(async function test_removal_of_incomplete_download() { + let url = getInterruptibleUrl(); + let msg = await runInExtension("download", {url}); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let progressPromise = waitForProgress(url, INT_PARTIAL_LEN); + + msg = await runInExtension("waitForEvents", [ + {type: "onCreated", data: {id}}, + ]); + equal(msg.status, "success", "got created and changed events"); + + await progressPromise; + do_print(`download reached ${INT_PARTIAL_LEN} bytes`); + + msg = await runInExtension("pause", id); + equal(msg.status, "success", "pause() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "interrupted", + }, + paused: { + previous: false, + current: true, + }, + canResume: { + previous: false, + current: true, + }, + }, + }, { + type: "onChanged", + data: { + id, + error: { + previous: null, + current: "USER_CANCELED", + }, + }, + }]); + equal(msg.status, "success", "got onChanged event corresponding to pause"); + + msg = await runInExtension("removeFile", id); + equal(msg.status, "error", "removeFile() on paused download failed"); + + ok(/Cannot remove incomplete download/.test(msg.errmsg), "removeFile() failed due to download being incomplete"); + + msg = await runInExtension("resume", id); + equal(msg.status, "success", "resume() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "interrupted", + current: "in_progress", + }, + paused: { + previous: true, + current: false, + }, + canResume: { + previous: true, + current: false, + }, + error: { + previous: "USER_CANCELED", + current: null, + }, + }, + }, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged events for resume and complete"); + + msg = await runInExtension("removeFile", id); + equal(msg.status, "success", "removeFile() succeeded following completion of resumed download."); +}); + +// Test erase(). We don't do elaborate testing of the query handling +// since it uses the exact same engine as search() which is tested +// more thoroughly in test_chrome_ext_downloads_search.html +add_task(async function test_erase() { + await clearDownloads(); + + await runInExtension("clearEvents"); + + async function download() { + let msg = await runInExtension("download", {url: TXT_URL}); + equal(msg.status, "success", "download succeeded"); + let id = msg.result; + + msg = await runInExtension("waitForEvents", [{ + type: "onChanged", data: {id, state: {current: "complete"}}, + }], {exact: false}); + equal(msg.status, "success", "download finished"); + + return id; + } + + let ids = {}; + ids.dl1 = await download(); + ids.dl2 = await download(); + ids.dl3 = await download(); + + let msg = await runInExtension("search", {}); + equal(msg.status, "success", "search succeded"); + equal(msg.result.length, 3, "search found 3 downloads"); + + msg = await runInExtension("clearEvents"); + + msg = await runInExtension("erase", {id: ids.dl1}); + equal(msg.status, "success", "erase by id succeeded"); + + msg = await runInExtension("waitForEvents", [ + {type: "onErased", data: ids.dl1}, + ]); + equal(msg.status, "success", "received onErased event"); + + msg = await runInExtension("search", {}); + equal(msg.status, "success", "search succeded"); + equal(msg.result.length, 2, "search found 2 downloads"); + + msg = await runInExtension("erase", {}); + equal(msg.status, "success", "erase everything succeeded"); + + msg = await runInExtension("waitForEvents", [ + {type: "onErased", data: ids.dl2}, + {type: "onErased", data: ids.dl3}, + ], {inorder: false}); + equal(msg.status, "success", "received 2 onErased events"); + + msg = await runInExtension("search", {}); + equal(msg.status, "success", "search succeded"); + equal(msg.result.length, 0, "search found 0 downloads"); +}); + +function loadImage(img, data) { + return new Promise((resolve) => { + img.src = data; + img.onload = resolve; + }); +} + +add_task(async function test_getFileIcon() { + let webNav = Services.appShell.createWindowlessBrowser(false); + let docShell = webNav.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell); + + let system = Services.scriptSecurityManager.getSystemPrincipal(); + docShell.createAboutBlankContentViewer(system); + + let img = webNav.document.createElement("img"); + + let msg = await runInExtension("download", {url: TXT_URL}); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + msg = await runInExtension("getFileIcon", id); + equal(msg.status, "success", "getFileIcon() succeeded"); + await loadImage(img, msg.result); + equal(img.height, 32, "returns an icon with the right height"); + equal(img.width, 32, "returns an icon with the right width"); + + msg = await runInExtension("waitForEvents", [ + {type: "onCreated", data: {id, url: TXT_URL}}, + {type: "onChanged"}, + ]); + equal(msg.status, "success", "got events"); + + msg = await runInExtension("getFileIcon", id); + equal(msg.status, "success", "getFileIcon() succeeded"); + await loadImage(img, msg.result); + equal(img.height, 32, "returns an icon with the right height after download"); + equal(img.width, 32, "returns an icon with the right width after download"); + + msg = await runInExtension("getFileIcon", id + 100); + equal(msg.status, "error", "getFileIcon() failed"); + ok(msg.errmsg.includes("Invalid download id"), "download id is invalid"); + + msg = await runInExtension("getFileIcon", id, {size: 127}); + equal(msg.status, "success", "getFileIcon() succeeded"); + await loadImage(img, msg.result); + equal(img.height, 127, "returns an icon with the right custom height"); + equal(img.width, 127, "returns an icon with the right custom width"); + + msg = await runInExtension("getFileIcon", id, {size: 1}); + equal(msg.status, "success", "getFileIcon() succeeded"); + await loadImage(img, msg.result); + equal(img.height, 1, "returns an icon with the right custom height"); + equal(img.width, 1, "returns an icon with the right custom width"); + + msg = await runInExtension("getFileIcon", id, {size: "foo"}); + equal(msg.status, "error", "getFileIcon() fails"); + ok(msg.errmsg.includes("Error processing size"), "size is not a number"); + + msg = await runInExtension("getFileIcon", id, {size: 0}); + equal(msg.status, "error", "getFileIcon() fails"); + ok(msg.errmsg.includes("Error processing size"), "size is too small"); + + msg = await runInExtension("getFileIcon", id, {size: 128}); + equal(msg.status, "error", "getFileIcon() fails"); + ok(msg.errmsg.includes("Error processing size"), "size is too big"); + + webNav.close(); +}); + +add_task(async function cleanup() { + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js new file mode 100644 index 0000000000..9432810ba7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js @@ -0,0 +1,402 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Cu.import("resource://gre/modules/Downloads.jsm"); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE = `http://localhost:${server.identity.primaryPort}/data`; +const TXT_FILE = "file_download.txt"; +const TXT_URL = BASE + "/" + TXT_FILE; +const TXT_LEN = 46; +const HTML_FILE = "file_download.html"; +const HTML_URL = BASE + "/" + HTML_FILE; +const HTML_LEN = 117; +const BIG_LEN = 1000; // something bigger both TXT_LEN and HTML_LEN + +function backgroundScript() { + let complete = new Map(); + + function waitForComplete(id) { + if (complete.has(id)) { + return complete.get(id).promise; + } + + let promise = new Promise(resolve => { + complete.set(id, {resolve}); + }); + complete.get(id).promise = promise; + return promise; + } + + browser.downloads.onChanged.addListener(change => { + if (change.state && change.state.current == "complete") { + // Make sure we have a promise. + waitForComplete(change.id); + complete.get(change.id).resolve(); + } + }); + + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg == "download.request") { + try { + let id = await browser.downloads.download(args[0]); + browser.test.sendMessage("download.done", {status: "success", id}); + } catch (error) { + browser.test.sendMessage("download.done", {status: "error", errmsg: error.message}); + } + } else if (msg == "search.request") { + try { + let downloads = await browser.downloads.search(args[0]); + browser.test.sendMessage("search.done", {status: "success", downloads}); + } catch (error) { + browser.test.sendMessage("search.done", {status: "error", errmsg: error.message}); + } + } else if (msg == "waitForComplete.request") { + await waitForComplete(args[0]); + browser.test.sendMessage("waitForComplete.done"); + } + }); + + browser.test.sendMessage("ready"); +} + +async function clearDownloads(callback) { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + await Promise.all(downloads.map(download => list.remove(download))); + + return downloads; +} + +add_task(async function test_search() { + const nsIFile = Ci.nsIFile; + let downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + do_print(`downloadDir ${downloadDir.path}`); + + function downloadPath(filename) { + let path = downloadDir.clone(); + path.append(filename); + return path.path; + } + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir); + + do_register_cleanup(async () => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + await cleanupDir(downloadDir); + await clearDownloads(); + }); + + await clearDownloads().then(downloads => { + do_print(`removed ${downloads.length} pre-existing downloads from history`); + }); + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["downloads"], + }, + }); + + async function download(options) { + extension.sendMessage("download.request", options); + let result = await extension.awaitMessage("download.done"); + + if (result.status == "success") { + do_print(`wait for onChanged event to indicate ${result.id} is complete`); + extension.sendMessage("waitForComplete.request", result.id); + + await extension.awaitMessage("waitForComplete.done"); + } + + return result; + } + + function search(query) { + extension.sendMessage("search.request", query); + return extension.awaitMessage("search.done"); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + // Do some downloads... + const time1 = new Date(); + + let downloadIds = {}; + let msg = await download({url: TXT_URL}); + equal(msg.status, "success", "download() succeeded"); + downloadIds.txt1 = msg.id; + + const TXT_FILE2 = "NewFile.txt"; + msg = await download({url: TXT_URL, filename: TXT_FILE2}); + equal(msg.status, "success", "download() succeeded"); + downloadIds.txt2 = msg.id; + + const time2 = new Date(); + + msg = await download({url: HTML_URL}); + equal(msg.status, "success", "download() succeeded"); + downloadIds.html1 = msg.id; + + const HTML_FILE2 = "renamed.html"; + msg = await download({url: HTML_URL, filename: HTML_FILE2}); + equal(msg.status, "success", "download() succeeded"); + downloadIds.html2 = msg.id; + + const time3 = new Date(); + + // Search for each individual download and check + // the corresponding DownloadItem. + async function checkDownloadItem(id, expect) { + let item = await search({id}); + equal(item.status, "success", "search() succeeded"); + equal(item.downloads.length, 1, "search() found exactly 1 download"); + + Object.keys(expect).forEach(function(field) { + equal(item.downloads[0][field], expect[field], `DownloadItem.${field} is correct"`); + }); + } + await checkDownloadItem(downloadIds.txt1, { + url: TXT_URL, + filename: downloadPath(TXT_FILE), + mime: "text/plain", + state: "complete", + bytesReceived: TXT_LEN, + totalBytes: TXT_LEN, + fileSize: TXT_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.txt2, { + url: TXT_URL, + filename: downloadPath(TXT_FILE2), + mime: "text/plain", + state: "complete", + bytesReceived: TXT_LEN, + totalBytes: TXT_LEN, + fileSize: TXT_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.html1, { + url: HTML_URL, + filename: downloadPath(HTML_FILE), + mime: "text/html", + state: "complete", + bytesReceived: HTML_LEN, + totalBytes: HTML_LEN, + fileSize: HTML_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.html2, { + url: HTML_URL, + filename: downloadPath(HTML_FILE2), + mime: "text/html", + state: "complete", + bytesReceived: HTML_LEN, + totalBytes: HTML_LEN, + fileSize: HTML_LEN, + exists: true, + }); + + async function checkSearch(query, expected, description, exact) { + let item = await search(query); + equal(item.status, "success", "search() succeeded"); + equal(item.downloads.length, expected.length, `search() for ${description} found exactly ${expected.length} downloads`); + + let receivedIds = item.downloads.map(i => i.id); + if (exact) { + receivedIds.forEach((id, idx) => { + equal(id, downloadIds[expected[idx]], `search() for ${description} returned ${expected[idx]} in position ${idx}`); + }); + } else { + Object.keys(downloadIds).forEach(key => { + const id = downloadIds[key]; + const thisExpected = expected.includes(key); + equal(receivedIds.includes(id), thisExpected, + `search() for ${description} ${thisExpected ? "includes" : "does not include"} ${key}`); + }); + } + } + + // Check that search with an invalid id returns nothing. + // NB: for now ids are not persistent and we start numbering them at 1 + // so a sufficiently large number will be unused. + const INVALID_ID = 1000; + await checkSearch({id: INVALID_ID}, [], "invalid id"); + + // Check that search on url works. + await checkSearch({url: TXT_URL}, ["txt1", "txt2"], "url"); + + // Check that regexp on url works. + const HTML_REGEX = "[downlad]{8}\.html+$"; + await checkSearch({urlRegex: HTML_REGEX}, ["html1", "html2"], "url regexp"); + + // Check that compatible url+regexp works + await checkSearch({url: HTML_URL, urlRegex: HTML_REGEX}, ["html1", "html2"], "compatible url+urlRegex"); + + // Check that incompatible url+regexp works + await checkSearch({url: TXT_URL, urlRegex: HTML_REGEX}, [], "incompatible url+urlRegex"); + + // Check that search on filename works. + await checkSearch({filename: downloadPath(TXT_FILE)}, ["txt1"], "filename"); + + // Check that regexp on filename works. + await checkSearch({filenameRegex: HTML_REGEX}, ["html1"], "filename regex"); + + // Check that compatible filename+regexp works + await checkSearch({filename: downloadPath(HTML_FILE), filenameRegex: HTML_REGEX}, ["html1"], "compatible filename+filename regex"); + + // Check that incompatible filename+regexp works + await checkSearch({filename: downloadPath(TXT_FILE), filenameRegex: HTML_REGEX}, [], "incompatible filename+filename regex"); + + // Check that simple positive search terms work. + await checkSearch({query: ["file_download"]}, ["txt1", "txt2", "html1", "html2"], + "term file_download"); + await checkSearch({query: ["NewFile"]}, ["txt2"], "term NewFile"); + + // Check that positive search terms work case-insensitive. + await checkSearch({query: ["nEwfILe"]}, ["txt2"], "term nEwfiLe"); + + // Check that negative search terms work. + await checkSearch({query: ["-txt"]}, ["html1", "html2"], "term -txt"); + + // Check that positive and negative search terms together work. + await checkSearch({query: ["html", "-renamed"]}, ["html1"], "postive and negative terms"); + + async function checkSearchWithDate(query, expected, description) { + const fields = Object.keys(query); + if (fields.length != 1 || !(query[fields[0]] instanceof Date)) { + throw new Error("checkSearchWithDate expects exactly one Date field"); + } + const field = fields[0]; + const date = query[field]; + + let newquery = {}; + + // Check as a Date + newquery[field] = date; + await checkSearch(newquery, expected, `${description} as Date`); + + // Check as numeric milliseconds + newquery[field] = date.valueOf(); + await checkSearch(newquery, expected, `${description} as numeric ms`); + + // Check as stringified milliseconds + newquery[field] = date.valueOf().toString(); + await checkSearch(newquery, expected, `${description} as string ms`); + + // Check as ISO string + newquery[field] = date.toISOString(); + await checkSearch(newquery, expected, `${description} as iso string`); + } + + // Check startedBefore + await checkSearchWithDate({startedBefore: time1}, [], "before time1"); + await checkSearchWithDate({startedBefore: time2}, ["txt1", "txt2"], "before time2"); + await checkSearchWithDate({startedBefore: time3}, ["txt1", "txt2", "html1", "html2"], "before time3"); + + // Check startedAfter + await checkSearchWithDate({startedAfter: time1}, ["txt1", "txt2", "html1", "html2"], "after time1"); + await checkSearchWithDate({startedAfter: time2}, ["html1", "html2"], "after time2"); + await checkSearchWithDate({startedAfter: time3}, [], "after time3"); + + // Check simple search on totalBytes + await checkSearch({totalBytes: TXT_LEN}, ["txt1", "txt2"], "totalBytes"); + await checkSearch({totalBytes: HTML_LEN}, ["html1", "html2"], "totalBytes"); + + // Check simple test on totalBytes{Greater,Less} + // (NB: TXT_LEN < HTML_LEN < BIG_LEN) + await checkSearch({totalBytesGreater: 0}, ["txt1", "txt2", "html1", "html2"], "totalBytesGreater than 0"); + await checkSearch({totalBytesGreater: TXT_LEN}, ["html1", "html2"], `totalBytesGreater than ${TXT_LEN}`); + await checkSearch({totalBytesGreater: HTML_LEN}, [], `totalBytesGreater than ${HTML_LEN}`); + await checkSearch({totalBytesLess: TXT_LEN}, [], `totalBytesLess than ${TXT_LEN}`); + await checkSearch({totalBytesLess: HTML_LEN}, ["txt1", "txt2"], `totalBytesLess than ${HTML_LEN}`); + await checkSearch({totalBytesLess: BIG_LEN}, ["txt1", "txt2", "html1", "html2"], `totalBytesLess than ${BIG_LEN}`); + + // Check good combinations of totalBytes*. + await checkSearch({totalBytes: HTML_LEN, totalBytesGreater: TXT_LEN}, ["html1", "html2"], "totalBytes and totalBytesGreater"); + await checkSearch({totalBytes: TXT_LEN, totalBytesLess: HTML_LEN}, ["txt1", "txt2"], "totalBytes and totalBytesGreater"); + await checkSearch({totalBytes: HTML_LEN, totalBytesLess: BIG_LEN, totalBytesGreater: 0}, ["html1", "html2"], "totalBytes and totalBytesLess and totalBytesGreater"); + + // Check bad combination of totalBytes*. + await checkSearch({totalBytesLess: TXT_LEN, totalBytesGreater: HTML_LEN}, [], "bad totalBytesLess, totalBytesGreater combination"); + await checkSearch({totalBytes: TXT_LEN, totalBytesGreater: HTML_LEN}, [], "bad totalBytes, totalBytesGreater combination"); + await checkSearch({totalBytes: HTML_LEN, totalBytesLess: TXT_LEN}, [], "bad totalBytes, totalBytesLess combination"); + + // Check mime. + await checkSearch({mime: "text/plain"}, ["txt1", "txt2"], "mime text/plain"); + await checkSearch({mime: "text/html"}, ["html1", "html2"], "mime text/htmlplain"); + await checkSearch({mime: "video/webm"}, [], "mime video/webm"); + + // Check fileSize. + await checkSearch({fileSize: TXT_LEN}, ["txt1", "txt2"], "fileSize"); + await checkSearch({fileSize: HTML_LEN}, ["html1", "html2"], "fileSize"); + + // Fields like bytesReceived, paused, state, exists are meaningful + // for downloads that are in progress but have not yet completed. + // todo: add tests for these when we have better support for in-progress + // downloads (e.g., after pause(), resume() and cancel() are implemented) + + // Check multiple query properties. + // We could make this testing arbitrarily complicated... + // We already tested combining fields with obvious interactions above + // (e.g., filename and filenameRegex or startTime and startedBefore/After) + // so now just throw as many fields as we can at a single search and + // make sure a simple case still works. + await checkSearch({ + url: TXT_URL, + urlRegex: "download", + filename: downloadPath(TXT_FILE), + filenameRegex: "download", + query: ["download"], + startedAfter: time1.valueOf().toString(), + startedBefore: time2.valueOf().toString(), + totalBytes: TXT_LEN, + totalBytesGreater: 0, + totalBytesLess: BIG_LEN, + mime: "text/plain", + fileSize: TXT_LEN, + }, ["txt1"], "many properties"); + + // Check simple orderBy (forward and backward). + await checkSearch({orderBy: ["startTime"]}, ["txt1", "txt2", "html1", "html2"], "orderBy startTime", true); + await checkSearch({orderBy: ["-startTime"]}, ["html2", "html1", "txt2", "txt1"], "orderBy -startTime", true); + + // Check orderBy with multiple fields. + // NB: TXT_URL and HTML_URL differ only in extension and .html precedes .txt + await checkSearch({orderBy: ["url", "-startTime"]}, ["html2", "html1", "txt2", "txt1"], "orderBy with multiple fields", true); + + // Check orderBy with limit. + await checkSearch({orderBy: ["url"], limit: 1}, ["html1"], "orderBy with limit", true); + + // Check bad arguments. + async function checkBadSearch(query, pattern, description) { + let item = await search(query); + equal(item.status, "error", "search() failed"); + ok(pattern.test(item.errmsg), `error message for ${description} was correct (${item.errmsg}).`); + } + + await checkBadSearch("myquery", /Incorrect argument type/, "query is not an object"); + await checkBadSearch({bogus: "boo"}, /Unexpected property/, "query contains an unknown field"); + await checkBadSearch({query: "query string"}, /Expected array/, "query.query is a string"); + await checkBadSearch({startedBefore: "i am not a time"}, /Type error/, "query.startedBefore is not a valid time"); + await checkBadSearch({startedAfter: "i am not a time"}, /Type error/, "query.startedAfter is not a valid time"); + await checkBadSearch({endedBefore: "i am not a time"}, /Type error/, "query.endedBefore is not a valid time"); + await checkBadSearch({endedAfter: "i am not a time"}, /Type error/, "query.endedAfter is not a valid time"); + await checkBadSearch({urlRegex: "["}, /Invalid urlRegex/, "query.urlRegexp is not a valid regular expression"); + await checkBadSearch({filenameRegex: "["}, /Invalid filenameRegex/, "query.filenameRegexp is not a valid regular expression"); + await checkBadSearch({orderBy: "startTime"}, /Expected array/, "query.orderBy is not an array"); + await checkBadSearch({orderBy: ["bogus"]}, /Invalid orderBy field/, "query.orderBy references a non-existent field"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js b/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js new file mode 100644 index 0000000000..890f8c0978 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js @@ -0,0 +1,175 @@ +"use strict"; + +/* globals browser */ + +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); + +function promiseAddonStartup() { + const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {}); + + return new Promise(resolve => { + let listener = (evt, extension) => { + Management.off("startup", listener); + resolve(extension); + }; + + Management.on("startup", listener); + }); +} + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_experiments_api() { + let apiAddonFile = Extension.generateZipFile({ + "install.rdf": ` + + + + + + + + + `, + + "api.js": String.raw` + Components.utils.import("resource://gre/modules/Services.jsm"); + + Services.obs.notifyObservers(null, "webext-api-loaded", ""); + + class API extends ExtensionAPI { + getAPI(context) { + return { + meh: { + hello(text) { + console.log('meh.hello API called', text); + Services.obs.notifyObservers(null, "webext-api-hello", text); + } + } + } + } + } + `, + + "schema.json": [ + { + "namespace": "meh", + "description": "All full of meh.", + "permissions": ["experiments.meh"], + "functions": [ + { + "name": "hello", + "type": "function", + "description": "Hates you. This is all.", + "parameters": [ + {"type": "string", "name": "text"}, + ], + }, + ], + }, + ], + }); + + let addonFile = Extension.generateXPI({ + manifest: { + applications: {gecko: {id: "meh@web.extension"}}, + permissions: ["experiments.meh"], + }, + + background() { + // The test code below checks that hello() is called at the right + // time with the string "Here I am". Verify that the api schema is + // being correctly interpreted by calling hello() with bad arguments + // and only calling hello() with the magic string if the call with + // bad arguments throws. + try { + browser.meh.hello("I should not see this", "since two arguments are bad"); + } catch (err) { + browser.meh.hello("Here I am"); + } + }, + }); + + let boringAddonFile = Extension.generateXPI({ + manifest: { + applications: {gecko: {id: "boring@web.extension"}}, + }, + background() { + if (browser.meh) { + browser.meh.hello("Here I should not be"); + } + }, + }); + + do_register_cleanup(() => { + for (let file of [apiAddonFile, addonFile, boringAddonFile]) { + Services.obs.notifyObservers(file, "flush-cache-entry"); + file.remove(false); + } + }); + + + let resolveHello; + let observer = (subject, topic, data) => { + if (topic == "webext-api-loaded") { + ok(!!resolveHello, "Should not see API loaded until dependent extension loads"); + } else if (topic == "webext-api-hello") { + resolveHello(data); + } + }; + + Services.obs.addObserver(observer, "webext-api-loaded"); + Services.obs.addObserver(observer, "webext-api-hello"); + do_register_cleanup(() => { + Services.obs.removeObserver(observer, "webext-api-loaded"); + Services.obs.removeObserver(observer, "webext-api-hello"); + }); + + + // Install API add-on. + let apiAddon = await AddonManager.installTemporaryAddon(apiAddonFile); + + let {ExtensionAPIs} = Cu.import("resource://gre/modules/ExtensionAPI.jsm", {}); + ok(ExtensionAPIs.apis.has("meh"), "Should have meh API."); + + + // Install boring WebExtension add-on. + let boringAddon = await AddonManager.installTemporaryAddon(boringAddonFile); + await promiseAddonStartup(); + + + // Install interesting WebExtension add-on. + let promise = new Promise(resolve => { + resolveHello = resolve; + }); + + let addon = await AddonManager.installTemporaryAddon(addonFile); + await promiseAddonStartup(); + + let hello = await promise; + equal(hello, "Here I am", "Should get hello from add-on"); + + // Cleanup. + apiAddon.uninstall(); + + boringAddon.userDisabled = true; + await new Promise(do_execute_soon); + + equal(addon.appDisabled, true, "Add-on should be app-disabled after its dependency is removed."); + + addon.uninstall(); + boringAddon.uninstall(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension.js new file mode 100644 index 0000000000..97ca3c9ee4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension.js @@ -0,0 +1,55 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_is_allowed_incognito_access() { + async function background() { + let allowed = await browser.extension.isAllowedIncognitoAccess(); + + browser.test.assertEq(true, allowed, "isAllowedIncognitoAccess is true"); + browser.test.notifyPass("isAllowedIncognitoAccess"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: {}, + }); + + await extension.startup(); + await extension.awaitFinish("isAllowedIncognitoAccess"); + await extension.unload(); +}); + +add_task(async function test_in_incognito_context_false() { + function background() { + browser.test.assertEq(false, browser.extension.inIncognitoContext, "inIncognitoContext returned false"); + browser.test.notifyPass("inIncognitoContext"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: {}, + }); + + await extension.startup(); + await extension.awaitFinish("inIncognitoContext"); + await extension.unload(); +}); + +add_task(async function test_is_allowed_file_scheme_access() { + async function background() { + let allowed = await browser.extension.isAllowedFileSchemeAccess(); + + browser.test.assertEq(false, allowed, "isAllowedFileSchemeAccess is false"); + browser.test.notifyPass("isAllowedFileSchemeAccess"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: {}, + }); + + await extension.startup(); + await extension.awaitFinish("isAllowedFileSchemeAccess"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js b/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js new file mode 100644 index 0000000000..e86f4a85f9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js @@ -0,0 +1,241 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionPreferencesManager", + "resource://gre/modules/ExtensionPreferencesManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSettingsStore", + "resource://gre/modules/ExtensionSettingsStore.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Preferences", + "resource://gre/modules/Preferences.jsm"); + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +const STORE_TYPE = "prefs"; + +// Test settings to use with the preferences manager. +const SETTINGS = { + multiple_prefs: { + prefNames: ["my.pref.1", "my.pref.2", "my.pref.3"], + + initalValues: ["value1", "value2", "value3"], + + valueFn(pref, value) { + return `${pref}-${value}`; + }, + + setCallback(value) { + let prefs = {}; + for (let pref of this.prefNames) { + prefs[pref] = this.valueFn(pref, value); + } + return prefs; + }, + }, + + singlePref: { + prefNames: [ + "my.single.pref", + ], + + initalValues: ["value1"], + + valueFn(pref, value) { + return value; + }, + + setCallback(value) { + return {[this.prefNames[0]]: this.valueFn(null, value)}; + }, + }, +}; + +ExtensionPreferencesManager.addSetting("multiple_prefs", SETTINGS.multiple_prefs); +ExtensionPreferencesManager.addSetting("singlePref", SETTINGS.singlePref); + +// Set initial values for prefs. +for (let setting in SETTINGS) { + setting = SETTINGS[setting]; + for (let i = 0; i < setting.prefNames.length; i++) { + Preferences.set(setting.prefNames[i], setting.initalValues[i]); + } +} + +function checkPrefs(settingObj, value, msg) { + for (let pref of settingObj.prefNames) { + equal(Preferences.get(pref), settingObj.valueFn(pref, value), msg); + } +} + +add_task(async function test_preference_manager() { + await promiseStartupManager(); + + // Create an array of test framework extension wrappers to install. + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + // Create an array actual Extension objects which correspond to the + // test framework extension wrappers. + let extensions = testExtensions.map(extension => extension.extension); + + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + let newValue1 = "newValue1"; + let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + extensions[1], setting); + equal(levelOfControl, "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl with no settings set."); + + let prefsChanged = await ExtensionPreferencesManager.setSetting( + extensions[1], setting, newValue1); + ok(prefsChanged, "setSetting returns true when the pref(s) have been set."); + checkPrefs(settingObj, newValue1, + "setSetting sets the prefs for the first extension."); + levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(extensions[1], setting); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl when a pref has been set."); + + let newValue2 = "newValue2"; + prefsChanged = await ExtensionPreferencesManager.setSetting(extensions[0], setting, newValue2); + ok(!prefsChanged, "setSetting returns false when the pref(s) have not been set."); + checkPrefs(settingObj, newValue1, + "setSetting does not set the pref(s) for an earlier extension."); + + prefsChanged = await ExtensionPreferencesManager.disableSetting(extensions[0], setting); + ok(!prefsChanged, "disableSetting returns false when the pref(s) have not been set."); + checkPrefs(settingObj, newValue1, + "disableSetting does not change the pref(s) for the non-top extension."); + + prefsChanged = await ExtensionPreferencesManager.enableSetting(extensions[0], setting); + ok(!prefsChanged, "enableSetting returns false when the pref(s) have not been set."); + checkPrefs(settingObj, newValue1, + "enableSetting does not change the pref(s) for the non-top extension."); + + prefsChanged = await ExtensionPreferencesManager.removeSetting(extensions[0], setting); + ok(!prefsChanged, "removeSetting returns false when the pref(s) have not been set."); + checkPrefs(settingObj, newValue1, + "removeSetting does not change the pref(s) for the non-top extension."); + + prefsChanged = await ExtensionPreferencesManager.setSetting(extensions[0], setting, newValue2); + ok(!prefsChanged, "setSetting returns false when the pref(s) have not been set."); + checkPrefs(settingObj, newValue1, + "setSetting does not set the pref(s) for an earlier extension."); + + prefsChanged = await ExtensionPreferencesManager.disableSetting(extensions[1], setting); + ok(prefsChanged, "disableSetting returns true when the pref(s) have been set."); + checkPrefs(settingObj, newValue2, + "disableSetting sets the pref(s) to the next value when disabling the top extension."); + + prefsChanged = await ExtensionPreferencesManager.enableSetting(extensions[1], setting); + ok(prefsChanged, "enableSetting returns true when the pref(s) have been set."); + checkPrefs(settingObj, newValue1, + "enableSetting sets the pref(s) to the previous value(s)."); + + prefsChanged = await ExtensionPreferencesManager.removeSetting(extensions[1], setting); + ok(prefsChanged, "removeSetting returns true when the pref(s) have been set."); + checkPrefs(settingObj, newValue2, + "removeSetting sets the pref(s) to the next value when removing the top extension."); + + prefsChanged = await ExtensionPreferencesManager.removeSetting(extensions[0], setting); + ok(prefsChanged, "removeSetting returns true when the pref(s) have been set."); + for (let i = 0; i < settingObj.prefNames.length; i++) { + equal(Preferences.get(settingObj.prefNames[i]), settingObj.initalValues[i], + "removeSetting sets the pref(s) to the initial value(s) when removing the last extension."); + } + } + + // Tests for unsetAll. + let newValue3 = "newValue3"; + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + await ExtensionPreferencesManager.setSetting(extensions[0], setting, newValue3); + checkPrefs(settingObj, newValue3, "setSetting set the pref."); + } + + let setSettings = await ExtensionSettingsStore.getAllForExtension(extensions[0], STORE_TYPE); + deepEqual(setSettings, Object.keys(SETTINGS), "Expected settings were set for extension."); + await ExtensionPreferencesManager.disableAll(extensions[0]); + + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + for (let i = 0; i < settingObj.prefNames.length; i++) { + equal(Preferences.get(settingObj.prefNames[i]), settingObj.initalValues[i], + "disableAll unset the pref."); + } + } + + setSettings = await ExtensionSettingsStore.getAllForExtension(extensions[0], STORE_TYPE); + deepEqual(setSettings, Object.keys(SETTINGS), "disableAll retains the settings."); + + await ExtensionPreferencesManager.enableAll(extensions[0]); + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + checkPrefs(settingObj, newValue3, "enableAll re-set the pref."); + } + + await ExtensionPreferencesManager.removeAll(extensions[0]); + + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + for (let i = 0; i < settingObj.prefNames.length; i++) { + equal(Preferences.get(settingObj.prefNames[i]), settingObj.initalValues[i], + "removeAll unset the pref."); + } + } + + setSettings = await ExtensionSettingsStore.getAllForExtension(extensions[0], STORE_TYPE); + deepEqual(setSettings, [], "removeAll removed all settings."); + + // Test with an uninitialized pref. + let setting = "singlePref"; + let settingObj = SETTINGS[setting]; + let pref = settingObj.prefNames[0]; + let newValue = "newValue"; + Preferences.reset(pref); + await ExtensionPreferencesManager.setSetting(extensions[1], setting, newValue); + equal(Preferences.get(pref), settingObj.valueFn(pref, newValue), + "Uninitialized pref is set."); + await ExtensionPreferencesManager.removeSetting(extensions[1], setting); + ok(!Preferences.has(pref), "removeSetting removed the pref."); + + // Test levelOfControl with a locked pref. + setting = "multiple_prefs"; + let prefToLock = SETTINGS[setting].prefNames[0]; + Preferences.lock(prefToLock, 1); + ok(Preferences.locked(prefToLock), `Preference ${prefToLock} is locked.`); + let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(extensions[1], setting); + equal( + levelOfControl, + "not_controllable", + "getLevelOfControl returns correct levelOfControl when a pref is locked."); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js b/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js new file mode 100644 index 0000000000..88d5c218e9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js @@ -0,0 +1,422 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSettingsStore", + "resource://gre/modules/ExtensionSettingsStore.jsm"); + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +// Allow for unsigned addons. +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +const ITEMS = { + key1: [ + {key: "key1", value: "val1"}, + {key: "key1", value: "val2"}, + {key: "key1", value: "val3"}, + ], + key2: [ + {key: "key2", value: "val1-2"}, + {key: "key2", value: "val2-2"}, + {key: "key2", value: "val3-2"}, + ], +}; +const KEY_LIST = Object.keys(ITEMS); +const TEST_TYPE = "myType"; + +let callbackCount = 0; + +function initialValue(key) { + callbackCount++; + return `key:${key}`; +} + +add_task(async function test_settings_store() { + await promiseStartupManager(); + + // Create an array of test framework extension wrappers to install. + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + // Create an array actual Extension objects which correspond to the + // test framework extension wrappers. + let extensions = testExtensions.map(extension => extension.extension); + + let expectedCallbackCount = 0; + + // Add a setting for the second oldest extension, where it is the only setting for a key. + for (let key of KEY_LIST) { + let extensionIndex = 1; + let itemToAdd = ITEMS[key][extensionIndex]; + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex], TEST_TYPE, key); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl with no settings set for a key."); + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex], TEST_TYPE, itemToAdd.key, itemToAdd.value, initialValue); + expectedCallbackCount++; + equal(callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times."); + deepEqual(item, itemToAdd, "Adding initial item for a key returns that item."); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + itemToAdd, + "getSetting returns correct item with only one item in the list."); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex], TEST_TYPE, key); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl with only one item in the list."); + } + + // Add a setting for the oldest extension. + for (let key of KEY_LIST) { + let extensionIndex = 0; + let itemToAdd = ITEMS[key][extensionIndex]; + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex], TEST_TYPE, itemToAdd.key, itemToAdd.value, initialValue); + equal(callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times."); + equal(item, null, "An older extension adding a setting for a key returns null"); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][1], + "getSetting returns correct item with more than one item in the list."); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex], TEST_TYPE, key); + equal( + levelOfControl, + "controlled_by_other_extensions", + "getLevelOfControl returns correct levelOfControl when another extension is in control."); + } + + // Add a setting for the newest extension. + for (let key of KEY_LIST) { + let extensionIndex = 2; + let itemToAdd = ITEMS[key][extensionIndex]; + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex], TEST_TYPE, key); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl for a more recent extension."); + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex], TEST_TYPE, itemToAdd.key, itemToAdd.value, initialValue); + equal(callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times."); + deepEqual(item, itemToAdd, "Adding item for most recent extension returns that item."); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item with more than one item in the list."); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex], TEST_TYPE, key); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl when this extension is in control."); + } + + for (let extension of extensions) { + let items = await ExtensionSettingsStore.getAllForExtension(extension, TEST_TYPE); + deepEqual(items, KEY_LIST, "getAllForExtension returns expected keys."); + } + + // Attempting to remove a setting that has not been set should *not* throw an exception. + let removeResult = await ExtensionSettingsStore.removeSetting(extensions[0], "myType", "unset_key"); + equal(removeResult, null, "Removing a setting that was not previously set returns null."); + + // Attempting to disable a setting that has not been set should throw an exception. + await Assert.rejects( + ExtensionSettingsStore.disable(extensions[0], "myType", "unset_key"), + /Cannot alter the setting for myType:unset_key as it does not exist/, + "disable rejects with an unset key."); + + // Attempting to enable a setting that has not been set should throw an exception. + await Assert.rejects( + ExtensionSettingsStore.enable(extensions[0], "myType", "unset_key"), + /Cannot alter the setting for myType:unset_key as it does not exist/, + "enable rejects with an unset key."); + + let expectedKeys = KEY_LIST; + // Disable the non-top item for a key. + for (let key of KEY_LIST) { + let extensionIndex = 0; + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex], TEST_TYPE, key, "new value", initialValue); + equal(callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times."); + equal(item, null, "Updating non-top item for a key returns null"); + item = await ExtensionSettingsStore.disable(extensions[extensionIndex], TEST_TYPE, key); + equal(item, null, "Disabling non-top item for a key returns null."); + let allForExtension = await ExtensionSettingsStore.getAllForExtension(extensions[extensionIndex], TEST_TYPE); + deepEqual(allForExtension, expectedKeys, "getAllForExtension returns expected keys after a disable."); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item after a disable."); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex], TEST_TYPE, key); + equal( + levelOfControl, + "controlled_by_other_extensions", + "getLevelOfControl returns correct levelOfControl after disabling of non-top item."); + } + + // Re-enable the non-top item for a key. + for (let key of KEY_LIST) { + let extensionIndex = 0; + let item = await ExtensionSettingsStore.enable(extensions[extensionIndex], TEST_TYPE, key); + equal(item, null, "Enabling non-top item for a key returns null."); + let allForExtension = await ExtensionSettingsStore.getAllForExtension(extensions[extensionIndex], TEST_TYPE); + deepEqual(allForExtension, expectedKeys, "getAllForExtension returns expected keys after an enable."); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item after an enable."); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex], TEST_TYPE, key); + equal( + levelOfControl, + "controlled_by_other_extensions", + "getLevelOfControl returns correct levelOfControl after enabling of non-top item."); + } + + // Remove the non-top item for a key. + for (let key of KEY_LIST) { + let extensionIndex = 0; + let item = await ExtensionSettingsStore.removeSetting(extensions[extensionIndex], TEST_TYPE, key); + equal(item, null, "Removing non-top item for a key returns null."); + expectedKeys = expectedKeys.filter(expectedKey => expectedKey != key); + let allForExtension = await ExtensionSettingsStore.getAllForExtension(extensions[extensionIndex], TEST_TYPE); + deepEqual(allForExtension, expectedKeys, "getAllForExtension returns expected keys after a removal."); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item after a removal."); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[extensionIndex], TEST_TYPE, key); + equal( + levelOfControl, + "controlled_by_other_extensions", + "getLevelOfControl returns correct levelOfControl after removal of non-top item."); + } + + for (let key of KEY_LIST) { + // Disable the top item for a key. + let item = await ExtensionSettingsStore.disable(extensions[2], TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][1], + "Disabling top item for a key returns the new top item."); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][1], + "getSetting returns correct item after a disable."); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[2], TEST_TYPE, key); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl after disabling of top item."); + + // Re-enable the top item for a key. + item = await ExtensionSettingsStore.enable(extensions[2], TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "Re-enabling top item for a key returns the old top item."); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item after an enable."); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[2], TEST_TYPE, key); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl after re-enabling top item."); + + // Remove the top item for a key. + item = await ExtensionSettingsStore.removeSetting(extensions[2], TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][1], + "Removing top item for a key returns the new top item."); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][1], + "getSetting returns correct item after a removal."); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[2], TEST_TYPE, key); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl after removal of top item."); + + // Add a setting for the current top item. + let itemToAdd = {key, value: `new-${key}`}; + item = await ExtensionSettingsStore.addSetting( + extensions[1], TEST_TYPE, itemToAdd.key, itemToAdd.value, initialValue); + equal(callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times."); + deepEqual( + item, + itemToAdd, + "Updating top item for a key returns that item."); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + itemToAdd, + "getSetting returns correct item after updating."); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[1], TEST_TYPE, key); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl after updating."); + + // Disable the last remaining item for a key. + let expectedItem = {key, initialValue: initialValue(key)}; + // We're using the callback to set the expected value, so we need to increment the + // expectedCallbackCount. + expectedCallbackCount++; + item = await ExtensionSettingsStore.disable(extensions[1], TEST_TYPE, key); + deepEqual( + item, + expectedItem, + "Disabling last item for a key returns the initial value."); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + expectedItem, + "getSetting returns the initial value after all are disabled."); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[1], TEST_TYPE, key); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl after all are disabled."); + + // Re-enable the last remaining item for a key. + item = await ExtensionSettingsStore.enable(extensions[1], TEST_TYPE, key); + deepEqual( + item, + itemToAdd, + "Re-enabling last item for a key returns the old value."); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + itemToAdd, + "getSetting returns expected value after re-enabling."); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[1], TEST_TYPE, key); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl after re-enabling."); + + // Remove the last remaining item for a key. + item = await ExtensionSettingsStore.removeSetting(extensions[1], TEST_TYPE, key); + deepEqual( + item, + expectedItem, + "Removing last item for a key returns the initial value."); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + null, + "getSetting returns null after all are removed."); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[1], TEST_TYPE, key); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl after all are removed."); + + // Attempting to remove a setting that has had all extensions removed should *not* throw an exception. + removeResult = await ExtensionSettingsStore.removeSetting(extensions[1], TEST_TYPE, key); + equal(removeResult, null, "Removing a setting that has had all extensions removed returns null."); + } + + // Test adding a setting with a value in callbackArgument. + let extensionIndex = 0; + let testKey = "callbackArgumentKey"; + let callbackArgumentValue = Date.now(); + // Add the setting. + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex], TEST_TYPE, testKey, 1, initialValue, callbackArgumentValue); + expectedCallbackCount++; + equal(callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times."); + // Remove the setting which should return the initial value. + let expectedItem = {key: testKey, initialValue: initialValue(callbackArgumentValue)}; + // We're using the callback to set the expected value, so we need to increment the + // expectedCallbackCount. + expectedCallbackCount++; + item = await ExtensionSettingsStore.removeSetting(extensions[extensionIndex], TEST_TYPE, testKey); + deepEqual( + item, + expectedItem, + "Removing last item for a key returns the initial value."); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, testKey); + deepEqual( + item, + null, + "getSetting returns null after all are removed."); + + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, "not a key"); + equal( + item, + null, + "getSetting returns a null item if the setting does not have any records."); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(extensions[1], TEST_TYPE, "not a key"); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl if the setting does not have any records."); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); + +add_task(async function test_exceptions() { + await Assert.rejects( + ExtensionSettingsStore.addSetting( + 1, TEST_TYPE, "key_not_a_function", "val1", "not a function"), + /initialValueCallback must be a function/, + "addSetting rejects with a callback that is not a function."); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js new file mode 100644 index 0000000000..0fc1b1b7f5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js @@ -0,0 +1,29 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const HISTOGRAM = "WEBEXT_EXTENSION_STARTUP_MS"; + +add_task(async function test_telemetry() { + let extension1 = ExtensionTestUtils.loadExtension({}); + let extension2 = ExtensionTestUtils.loadExtension({}); + + let histogram = Services.telemetry.getHistogramById(HISTOGRAM); + histogram.clear(); + equal(histogram.snapshot().sum, 0, + `No data recorded for histogram: ${HISTOGRAM}.`); + + await extension1.startup(); + + let histogramSum = histogram.snapshot().sum; + ok(histogramSum > 0, + `Data recorded for first extension for histogram: ${HISTOGRAM}.`); + + await extension2.startup(); + + ok(histogram.snapshot().sum > histogramSum, + `Data recorded for second extension for histogram: ${HISTOGRAM}.`); + + await extension1.unload(); + await extension2.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js b/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js new file mode 100644 index 0000000000..6498b8212b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js @@ -0,0 +1,415 @@ +"use strict"; + +Cu.import("resource://gre/modules/Preferences.jsm"); + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +do_register_cleanup(() => { + Preferences.reset("intl.accept_languages"); + Preferences.reset("general.useragent.locale"); +}); + + +add_task(async function test_i18n() { + function runTests(assertEq) { + let _ = browser.i18n.getMessage.bind(browser.i18n); + + let url = browser.runtime.getURL("/"); + assertEq(url, `moz-extension://${_("@@extension_id")}/`, "@@extension_id builtin message"); + + assertEq("Foo.", _("Foo"), "Simple message in selected locale."); + + assertEq("(bar)", _("bar"), "Simple message fallback in default locale."); + + assertEq("", _("some-unknown-locale-string"), "Unknown locale string."); + + assertEq("", _("@@unknown_builtin_string"), "Unknown built-in string."); + assertEq("", _("@@bidi_unknown_builtin_string"), "Unknown built-in bidi string."); + + assertEq("Føo.", _("Föo"), "Multi-byte message in selected locale."); + + let substitutions = []; + substitutions[4] = "5"; + substitutions[13] = "14"; + + assertEq("'$0' '14' '' '5' '$$$$' '$'.", _("basic_substitutions", substitutions), + "Basic numeric substitutions"); + + assertEq("'$0' '' 'just a string' '' '$$$$' '$'.", _("basic_substitutions", "just a string"), + "Basic numeric substitutions, with non-array value"); + + let values = _("named_placeholder_substitutions", ["(subst $1 $2)", "(2 $1 $2)"]).split("\n"); + + assertEq("_foo_ (subst $1 $2) _bar_", values[0], "Named and numeric substitution"); + + assertEq("(2 $1 $2)", values[1], "Numeric substitution amid named placeholders"); + + assertEq("$bad name$", values[2], "Named placeholder with invalid key"); + + assertEq("", values[3], "Named placeholder with an invalid value"); + + assertEq("Accepted, but shouldn't break.", values[4], "Named placeholder with a strange content value"); + + assertEq("$foo", values[5], "Non-placeholder token that should be ignored"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "default_locale": "jp", + + content_scripts: [ + {"matches": ["http://*/*/file_sample.html"], + "js": ["content.js"]}, + ], + }, + + + files: { + "_locales/en_US/messages.json": { + "foo": { + "message": "Foo.", + "description": "foo", + }, + + "föo": { + "message": "Føo.", + "description": "foo", + }, + + "basic_substitutions": { + "message": "'$0' '$14' '$1' '$5' '$$$$$' '$$'.", + "description": "foo", + }, + + "Named_placeholder_substitutions": { + "message": "$Foo$\n$2\n$bad name$\n$bad_value$\n$bad_content_value$\n$foo", + "description": "foo", + "placeholders": { + "foO": { + "content": "_foo_ $1 _bar_", + "description": "foo", + }, + + "bad name": { + "content": "Nope.", + "description": "bad name", + }, + + "bad_value": "Nope.", + + "bad_content_value": { + "content": ["Accepted, but shouldn't break."], + "description": "bad value", + }, + }, + }, + + "broken_placeholders": { + "message": "$broken$", + "description": "broken placeholders", + "placeholders": "foo.", + }, + }, + + "_locales/jp/messages.json": { + "foo": { + "message": "(foo)", + "description": "foo", + }, + + "bar": { + "message": "(bar)", + "description": "bar", + }, + }, + + "content.js": "new " + function(runTestsFn) { + runTestsFn((...args) => { + browser.runtime.sendMessage(["assertEq", ...args]); + }); + + browser.runtime.sendMessage(["content-script-finished"]); + } + `(${runTests})`, + }, + + background: "new " + function(runTestsFn) { + browser.runtime.onMessage.addListener(([msg, ...args]) => { + if (msg == "assertEq") { + browser.test.assertEq(...args); + } else { + browser.test.sendMessage(msg, ...args); + } + }); + + runTestsFn(browser.test.assertEq.bind(browser.test)); + } + `(${runTests})`, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage(`${BASE_URL}/file_sample.html`); + await extension.awaitMessage("content-script-finished"); + await contentPage.close(); + + await extension.unload(); +}); + +add_task(async function test_get_accept_languages() { + function checkResults(source, results, expected) { + browser.test.assertEq( + expected.length, + results.length, + `got expected number of languages in ${source}`); + results.forEach((lang, index) => { + browser.test.assertEq( + expected[index], + lang, + `got expected language in ${source}`); + }); + } + + function background(checkResultsFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + browser.i18n.getAcceptLanguages().then(results => { + checkResultsFn("background", results, expected); + + browser.test.sendMessage("background-done"); + }); + }); + } + + function content(checkResultsFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + browser.i18n.getAcceptLanguages().then(results => { + checkResultsFn("contentScript", results, expected); + + browser.test.sendMessage("content-done"); + }); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "content_scripts": [{ + "matches": ["http://*/*/file_sample.html"], + "run_at": "document_start", + "js": ["content_script.js"], + }], + }, + + background: `(${background})(${checkResults})`, + + files: { + "content_script.js": `(${content})(${checkResults})`, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage(`${BASE_URL}/file_sample.html`); + + await extension.startup(); + + let expectedLangs = ["en-US", "en"]; + extension.sendMessage(["expect-results", expectedLangs]); + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + expectedLangs = ["en-US", "en", "fr-CA", "fr"]; + Preferences.set("intl.accept_languages", expectedLangs.toString()); + extension.sendMessage(["expect-results", expectedLangs]); + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + Preferences.reset("intl.accept_languages"); + + await contentPage.close(); + + await extension.unload(); +}); + +add_task(async function test_get_ui_language() { + function getResults() { + return { + getUILanguage: browser.i18n.getUILanguage(), + getMessage: browser.i18n.getMessage("@@ui_locale"), + }; + } + + function checkResults(source, results, expected) { + browser.test.assertEq( + expected, + results.getUILanguage, + `Got expected getUILanguage result in ${source}` + ); + browser.test.assertEq( + expected, + results.getMessage, + `Got expected getMessage result in ${source}` + ); + } + + function background(getResultsFn, checkResultsFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + checkResultsFn("background", getResultsFn(), expected); + + browser.test.sendMessage("background-done"); + }); + } + + function content(getResultsFn, checkResultsFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + checkResultsFn("contentScript", getResultsFn(), expected); + + browser.test.sendMessage("content-done"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "content_scripts": [{ + "matches": ["http://*/*/file_sample.html"], + "run_at": "document_start", + "js": ["content_script.js"], + }], + }, + + background: `(${background})(${getResults}, ${checkResults})`, + + files: { + "content_script.js": `(${content})(${getResults}, ${checkResults})`, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage(`${BASE_URL}/file_sample.html`); + + await extension.startup(); + + extension.sendMessage(["expect-results", "en_US"]); + + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + Preferences.set("general.useragent.locale", "he"); + + extension.sendMessage(["expect-results", "he"]); + + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + await contentPage.close(); + + await extension.unload(); +}); + + +add_task(async function test_detect_language() { + if (AppConstants.MOZ_BUILD_APP !== "browser") { + // This is not supported on Android. + return; + } + + const af_string = " aam skukuza die naam beteken hy wat skoonvee of hy wat alles onderstebo keer wysig " + + "bosveldkampe boskampe is kleiner afgeleë ruskampe wat oor min fasiliteite beskik daar is geen restaurante " + + "of winkels nie en slegs oornagbesoekers word toegelaat bateleur"; + // String with intermixed French/English text + const fr_en_string = "France is the largest country in Western Europe and the third-largest in Europe as a whole. " + + "A accès aux chiens et aux frontaux qui lui ont été il peut consulter et modifier ses collections et exporter " + + "Cet article concerne le pays européen aujourd’hui appelé République française. Pour d’autres usages du nom France, " + + "Pour une aide rapide et effective, veuiller trouver votre aide dans le menu ci-dessus." + + "Motoring events began soon after the construction of the first successful gasoline-fueled automobiles. The quick brown fox jumped over the lazy dog"; + + function checkResult(source, result, expected) { + browser.test.assertEq(expected.isReliable, result.isReliable, "result.confident is true"); + browser.test.assertEq( + expected.languages.length, + result.languages.length, + `result.languages contains the expected number of languages in ${source}`); + expected.languages.forEach((lang, index) => { + browser.test.assertEq( + lang.percentage, + result.languages[index].percentage, + `element ${index} of result.languages array has the expected percentage in ${source}`); + browser.test.assertEq( + lang.language, + result.languages[index].language, + `element ${index} of result.languages array has the expected language in ${source}`); + }); + } + + function backgroundScript(checkResultFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + browser.i18n.detectLanguage(msg).then(result => { + checkResultFn("background", result, expected); + browser.test.sendMessage("background-done"); + }); + }); + } + + function content(checkResultFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + browser.i18n.detectLanguage(msg).then(result => { + checkResultFn("contentScript", result, expected); + browser.test.sendMessage("content-done"); + }); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "content_scripts": [{ + "matches": ["http://*/*/file_sample.html"], + "run_at": "document_start", + "js": ["content_script.js"], + }], + }, + + background: `(${backgroundScript})(${checkResult})`, + + files: { + "content_script.js": `(${content})(${checkResult})`, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage(`${BASE_URL}/file_sample.html`); + + await extension.startup(); + + let expected = { + isReliable: true, + languages: [ + { + language: "fr", + percentage: 67, + }, + { + language: "en", + percentage: 32, + }, + ], + }; + extension.sendMessage([fr_en_string, expected]); + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + expected = { + isReliable: true, + languages: [ + { + language: "af", + percentage: 99, + }, + ], + }; + extension.sendMessage([af_string, expected]); + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js b/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js new file mode 100644 index 0000000000..0045984658 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js @@ -0,0 +1,121 @@ +"use strict"; + +Cu.import("resource://gre/modules/Preferences.jsm"); + + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +const XMLHttpRequest = Components.Constructor("@mozilla.org/xmlextras/xmlhttprequest;1", "nsIXMLHttpRequest"); + +add_task(async function test_i18n_css() { + let extension = ExtensionTestUtils.loadExtension({ + background: function() { + function backgroundFetch(url) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.overrideMimeType("text/plain"); + xhr.open("GET", url); + xhr.onload = () => { resolve(xhr.responseText); }; + xhr.onerror = reject; + xhr.send(); + }); + } + + Promise.all([backgroundFetch("foo.css"), backgroundFetch("bar.CsS?x#y"), backgroundFetch("foo.txt")]).then(results => { + browser.test.assertEq("body { max-width: 42px; }", results[0], "CSS file localized"); + browser.test.assertEq("body { max-width: 42px; }", results[1], "CSS file localized"); + + browser.test.assertEq("body { __MSG_foo__; }", results[2], "Text file not localized"); + + browser.test.notifyPass("i18n-css"); + }); + + browser.test.sendMessage("ready", browser.runtime.getURL("foo.css")); + }, + + manifest: { + "web_accessible_resources": ["foo.css", "foo.txt", "locale.css"], + + "content_scripts": [{ + "matches": ["http://*/*/file_sample.html"], + "css": ["foo.css"], + }], + + "default_locale": "en", + }, + + files: { + "_locales/en/messages.json": JSON.stringify({ + "foo": { + "message": "max-width: 42px", + "description": "foo", + }, + }), + + "foo.css": "body { __MSG_foo__; }", + "bar.CsS": "body { __MSG_foo__; }", + "foo.txt": "body { __MSG_foo__; }", + "locale.css": '* { content: "__MSG_@@ui_locale__ __MSG_@@bidi_dir__ __MSG_@@bidi_reversed_dir__ __MSG_@@bidi_start_edge__ __MSG_@@bidi_end_edge__" }', + }, + }); + + await extension.startup(); + let cssURL = await extension.awaitMessage("ready"); + + function fetch(url) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.overrideMimeType("text/plain"); + xhr.open("GET", url); + xhr.onload = () => { resolve(xhr.responseText); }; + xhr.onerror = reject; + xhr.send(); + }); + } + + let css = await fetch(cssURL); + + equal(css, "body { max-width: 42px; }", "CSS file localized in mochitest scope"); + + let contentPage = await ExtensionTestUtils.loadContentPage(`${BASE_URL}/file_sample.html`); + + let maxWidth = await ContentTask.spawn(contentPage.browser, {}, async function() { + /* globals content */ + let style = content.getComputedStyle(content.document.body); + + return style.maxWidth; + }); + + equal(maxWidth, "42px", "stylesheet correctly applied"); + + await contentPage.close(); + + cssURL = cssURL.replace(/foo.css$/, "locale.css"); + + css = await fetch(cssURL); + equal(css, '* { content: "en_US ltr rtl left right" }', "CSS file localized in mochitest scope"); + + const LOCALE = "general.useragent.locale"; + const DIR = "intl.uidirection"; + const DIR_LEGACY = "intl.uidirection.en"; // Needed for Android until bug 1215247 is resolved + + // We don't wind up actually switching the chrome registry locale, since we + // don't have a chrome package for Hebrew. So just override it, and force + // RTL directionality. + Preferences.set(LOCALE, "he"); + Preferences.set(DIR, 1); + Preferences.set(DIR_LEGACY, "rtl"); + + css = await fetch(cssURL); + equal(css, '* { content: "he rtl ltr right left" }', "CSS file localized in mochitest scope"); + + Preferences.reset(LOCALE); + Preferences.reset(DIR); + Preferences.reset(DIR_LEGACY); + + await extension.awaitFinish("i18n-css"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_idle.js b/toolkit/components/extensions/test/xpcshell/test_ext_idle.js new file mode 100644 index 0000000000..aed442607f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_idle.js @@ -0,0 +1,213 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Cu.import("resource://testing-common/MockRegistrar.jsm"); + +let idleService = { + _observers: new Set(), + _activity: { + addCalls: [], + removeCalls: [], + observerFires: [], + }, + _reset: function() { + this._observers.clear(); + this._activity.addCalls = []; + this._activity.removeCalls = []; + this._activity.observerFires = []; + }, + _fireObservers: function(state) { + for (let observer of this._observers.values()) { + observer.observe(observer, state, null); + this._activity.observerFires.push(state); + } + }, + QueryInterface: XPCOMUtils.generateQI([Ci.nsIIdleService]), + idleTime: 19999, + addIdleObserver: function(observer, time) { + this._observers.add(observer); + this._activity.addCalls.push(time); + }, + removeIdleObserver: function(observer, time) { + this._observers.delete(observer); + this._activity.removeCalls.push(time); + }, +}; + +function checkActivity(expectedActivity) { + let {expectedAdd, expectedRemove, expectedFires} = expectedActivity; + let {addCalls, removeCalls, observerFires} = idleService._activity; + equal(expectedAdd.length, addCalls.length, "idleService.addIdleObserver was called the expected number of times"); + equal(expectedRemove.length, removeCalls.length, "idleService.removeIdleObserver was called the expected number of times"); + equal(expectedFires.length, observerFires.length, "idle observer was fired the expected number of times"); + deepEqual(addCalls, expectedAdd, "expected interval passed to idleService.addIdleObserver"); + deepEqual(removeCalls, expectedRemove, "expected interval passed to idleService.removeIdleObserver"); + deepEqual(observerFires, expectedFires, "expected topic passed to idle observer"); +} + +add_task(async function setup() { + let fakeIdleService = MockRegistrar.register("@mozilla.org/widget/idleservice;1", idleService); + do_register_cleanup(() => { + MockRegistrar.unregister(fakeIdleService); + }); +}); + +add_task(async function testQueryStateActive() { + function background() { + browser.idle.queryState(20).then(status => { + browser.test.assertEq("active", status, "Idle status is active"); + browser.test.notifyPass("idle"); + }, + err => { + browser.test.fail(`Error: ${err} :: ${err.stack}`); + browser.test.notifyFail("idle"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("idle"); + await extension.unload(); +}); + +add_task(async function testQueryStateIdle() { + function background() { + browser.idle.queryState(15).then(status => { + browser.test.assertEq("idle", status, "Idle status is idle"); + browser.test.notifyPass("idle"); + }, + err => { + browser.test.fail(`Error: ${err} :: ${err.stack}`); + browser.test.notifyFail("idle"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("idle"); + await extension.unload(); +}); + +add_task(async function testOnlySetDetectionInterval() { + function background() { + browser.idle.setDetectionInterval(99); + browser.test.sendMessage("detectionIntervalSet"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + idleService._reset(); + await extension.startup(); + await extension.awaitMessage("detectionIntervalSet"); + idleService._fireObservers("idle"); + checkActivity({expectedAdd: [], expectedRemove: [], expectedFires: []}); + await extension.unload(); +}); + +add_task(async function testSetDetectionIntervalBeforeAddingListener() { + function background() { + browser.idle.setDetectionInterval(99); + browser.idle.onStateChanged.addListener(newState => { + browser.test.assertEq("idle", newState, "listener fired with the expected state"); + browser.test.sendMessage("listenerFired"); + }); + browser.test.sendMessage("listenerAdded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + idleService._reset(); + await extension.startup(); + await extension.awaitMessage("listenerAdded"); + idleService._fireObservers("idle"); + await extension.awaitMessage("listenerFired"); + checkActivity({expectedAdd: [99], expectedRemove: [], expectedFires: ["idle"]}); + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +}); + +add_task(async function testSetDetectionIntervalAfterAddingListener() { + function background() { + browser.idle.onStateChanged.addListener(newState => { + browser.test.assertEq("idle", newState, "listener fired with the expected state"); + browser.test.sendMessage("listenerFired"); + }); + browser.idle.setDetectionInterval(99); + browser.test.sendMessage("detectionIntervalSet"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + idleService._reset(); + await extension.startup(); + await extension.awaitMessage("detectionIntervalSet"); + idleService._fireObservers("idle"); + await extension.awaitMessage("listenerFired"); + checkActivity({expectedAdd: [60, 99], expectedRemove: [60], expectedFires: ["idle"]}); + + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +}); + +add_task(async function testOnlyAddingListener() { + function background() { + browser.idle.onStateChanged.addListener(newState => { + browser.test.assertEq("active", newState, "listener fired with the expected state"); + browser.test.sendMessage("listenerFired"); + }); + browser.test.sendMessage("listenerAdded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + idleService._reset(); + await extension.startup(); + await extension.awaitMessage("listenerAdded"); + idleService._fireObservers("active"); + await extension.awaitMessage("listenerFired"); + // check that "idle-daily" topic does not cause a listener to fire + idleService._fireObservers("idle-daily"); + checkActivity({expectedAdd: [60], expectedRemove: [], expectedFires: ["active", "idle-daily"]}); + + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js b/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js new file mode 100644 index 0000000000..94fe45523d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js @@ -0,0 +1,37 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_json_parser() { + const ID = "json@test.web.extension"; + + let xpi = Extension.generateXPI({ + files: { + "manifest.json": String.raw`{ + // This is a manifest. + "applications": {"gecko": {"id": "${ID}"}}, + "name": "This \" is // not a comment", + "version": "0.1\\" // , "description": "This is not a description" + }`, + }, + }); + + let expectedManifest = { + "applications": {"gecko": {"id": ID}}, + "name": "This \" is // not a comment", + "version": "0.1\\", + }; + + let fileURI = Services.io.newFileURI(xpi); + let uri = NetUtil.newURI(`jar:${fileURI.spec}!/`); + + let extension = new ExtensionData(uri); + + await extension.parseManifest(); + + Assert.deepEqual(extension.rawManifest, expectedManifest, + "Manifest with correctly-filtered comments"); + + Services.obs.notifyObservers(xpi, "flush-cache-entry"); + xpi.remove(false); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_legacy_extension_context.js b/toolkit/components/extensions/test/xpcshell/test_ext_legacy_extension_context.js new file mode 100644 index 0000000000..d4582c5894 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_legacy_extension_context.js @@ -0,0 +1,168 @@ +"use strict"; + +/* globals browser */ + +Cu.import("resource://gre/modules/Extension.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +const {LegacyExtensionContext} = Cu.import("resource://gre/modules/LegacyExtensionsUtils.jsm", {}); + +/** + * This test case ensures that LegacyExtensionContext instances: + * - expose the expected API object and can join the messaging + * of a webextension given its addon id + * - the exposed API object can receive a port related to a `runtime.connect` + * Port created in the webextension's background page + * - the received Port instance can exchange messages with the background page + * - the received Port receive a disconnect event when the webextension is + * shutting down + */ +add_task(async function test_legacy_extension_context() { + function background() { + let bgURL = window.location.href; + + let extensionInfo = { + bgURL, + // Extract the assigned uuid from the background page url. + uuid: window.location.hostname, + }; + + browser.test.sendMessage("webextension-ready", extensionInfo); + + let port; + + browser.test.onMessage.addListener(async msg => { + if (msg == "do-send-message") { + let reply = await browser.runtime.sendMessage("webextension -> legacy_extension message"); + + browser.test.assertEq("legacy_extension -> webextension reply", reply, + "Got the expected message from the LegacyExtensionContext"); + browser.test.sendMessage("got-reply-message"); + } else if (msg == "do-connect") { + port = browser.runtime.connect(); + + port.onMessage.addListener(portMsg => { + browser.test.assertEq("legacy_extension -> webextension port message", portMsg, + "Got the expected message from the LegacyExtensionContext"); + port.postMessage("webextension -> legacy_extension port message"); + }); + } else if (msg == "do-disconnect") { + port.disconnect(); + } + }); + } + + let extensionData = { + background, + }; + + let extension = Extension.generate(extensionData); + + let waitForExtensionInfo = new Promise((resolve, reject) => { + extension.on("test-message", function testMessageListener(kind, msg, ...args) { + if (msg != "webextension-ready") { + reject(new Error(`Got an unexpected test-message: ${msg}`)); + } else { + extension.off("test-message", testMessageListener); + resolve(args[0]); + } + }); + }); + + // Connect to the target extension as an external context + // using the given custom sender info. + let legacyContext; + + extension.on("startup", function onStartup() { + extension.off("startup", onStartup); + legacyContext = new LegacyExtensionContext(extension); + extension.callOnClose({ + close: () => legacyContext.unload(), + }); + }); + + await extension.startup(); + + let extensionInfo = await waitForExtensionInfo; + + equal(legacyContext.envType, "legacy_extension", + "LegacyExtensionContext instance has the expected type"); + + ok(legacyContext.api, "Got the expected API object"); + ok(legacyContext.api.browser, "Got the expected browser property"); + + let waitMessage = new Promise(resolve => { + const {browser} = legacyContext.api; + browser.runtime.onMessage.addListener((singleMsg, msgSender) => { + resolve({singleMsg, msgSender}); + + // Send a reply to the sender. + return Promise.resolve("legacy_extension -> webextension reply"); + }); + }); + + extension.testMessage("do-send-message"); + + let {singleMsg, msgSender} = await waitMessage; + equal(singleMsg, "webextension -> legacy_extension message", + "Got the expected message"); + ok(msgSender, "Got a message sender object"); + + equal(msgSender.id, extension.id, "The sender has the expected id property"); + equal(msgSender.url, extensionInfo.bgURL, "The sender has the expected url property"); + + // Wait confirmation that the reply has been received. + await new Promise((resolve, reject) => { + extension.on("test-message", function testMessageListener(kind, msg, ...args) { + if (msg != "got-reply-message") { + reject(new Error(`Got an unexpected test-message: ${msg}`)); + } else { + extension.off("test-message", testMessageListener); + resolve(); + } + }); + }); + + let waitConnectPort = new Promise(resolve => { + let {browser} = legacyContext.api; + browser.runtime.onConnect.addListener(port => { + resolve(port); + }); + }); + + extension.testMessage("do-connect"); + + let port = await waitConnectPort; + + ok(port, "Got the Port API object"); + ok(port.sender, "The port has a sender property"); + equal(port.sender.id, extension.id, + "The port sender has the expected id property"); + equal(port.sender.url, extensionInfo.bgURL, + "The port sender has the expected url property"); + + let waitPortMessage = new Promise(resolve => { + port.onMessage.addListener((msg) => { + resolve(msg); + }); + }); + + port.postMessage("legacy_extension -> webextension port message"); + + let msg = await waitPortMessage; + + equal(msg, "webextension -> legacy_extension port message", + "LegacyExtensionContext received the expected message from the webextension"); + + let waitForDisconnect = new Promise(resolve => { + port.onDisconnect.addListener(resolve); + }); + + extension.testMessage("do-disconnect"); + + await waitForDisconnect; + + do_print("Got the disconnect event on unload"); + + await extension.shutdown(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_legacy_extension_embedding.js b/toolkit/components/extensions/test/xpcshell/test_ext_legacy_extension_embedding.js new file mode 100644 index 0000000000..60a6c76437 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_legacy_extension_embedding.js @@ -0,0 +1,188 @@ +"use strict"; + +/* globals browser */ + +Cu.import("resource://gre/modules/LegacyExtensionsUtils.jsm"); + +// Import EmbeddedExtensionManager to be able to check that the +// tacked instances are cleared after the embedded extension shutdown. +const { + EmbeddedExtensionManager, +} = Cu.import("resource://gre/modules/LegacyExtensionsUtils.jsm", {}); + +/** + * This test case ensures that the LegacyExtensionsUtils.EmbeddedExtension: + * - load the embedded webextension resources from a "/webextension/" dir + * inside the XPI. + * - EmbeddedExtension.prototype.api returns an API object which exposes + * a working `runtime.onConnect` event object (e.g. the API can receive a port + * when the embedded webextension is started and it can exchange messages + * with the background page). + * - EmbeddedExtension.prototype.startup/shutdown methods manage the embedded + * webextension lifecycle as expected. + */ +add_task(async function test_embedded_webextension_utils() { + function backgroundScript() { + let port = browser.runtime.connect(); + + port.onMessage.addListener((msg) => { + if (msg == "legacy_extension -> webextension") { + port.postMessage("webextension -> legacy_extension"); + port.disconnect(); + } + }); + } + + const id = "@test.embedded.web.extension"; + + // Extensions.generateXPI is used here (and in the other hybrid addons tests in this same + // test dir) to be able to generate an xpi with the directory layout that we expect from + // an hybrid legacy+webextension addon (where all the embedded webextension resources are + // loaded from a 'webextension/' directory). + let fakeHybridAddonFile = Extension.generateZipFile({ + "webextension/manifest.json": { + applications: {gecko: {id}}, + name: "embedded webextension name", + manifest_version: 2, + version: "1.0", + background: { + scripts: ["bg.js"], + }, + }, + "webextension/bg.js": `new ${backgroundScript}`, + }); + + // Remove the generated xpi file and flush the its jar cache + // on cleanup. + do_register_cleanup(() => { + Services.obs.notifyObservers(fakeHybridAddonFile, "flush-cache-entry"); + fakeHybridAddonFile.remove(false); + }); + + let fileURI = Services.io.newFileURI(fakeHybridAddonFile); + let resourceURI = Services.io.newURI(`jar:${fileURI.spec}!/`); + + let embeddedExtension = LegacyExtensionsUtils.getEmbeddedExtensionFor({ + id, resourceURI, + }); + + ok(embeddedExtension, "Got the embeddedExtension object"); + + equal(EmbeddedExtensionManager.embeddedExtensionsByAddonId.size, 1, + "Got the expected number of tracked embedded extension instances"); + + do_print("waiting embeddedExtension.startup"); + let embeddedExtensionAPI = await embeddedExtension.startup(); + ok(embeddedExtensionAPI, "Got the embeddedExtensionAPI object"); + + let waitConnectPort = new Promise(resolve => { + let {browser} = embeddedExtensionAPI; + browser.runtime.onConnect.addListener(port => { + resolve(port); + }); + }); + + let port = await waitConnectPort; + + ok(port, "Got the Port API object"); + + let waitPortMessage = new Promise(resolve => { + port.onMessage.addListener((msg) => { + resolve(msg); + }); + }); + + port.postMessage("legacy_extension -> webextension"); + + let msg = await waitPortMessage; + + equal(msg, "webextension -> legacy_extension", + "LegacyExtensionContext received the expected message from the webextension"); + + let waitForDisconnect = new Promise(resolve => { + port.onDisconnect.addListener(resolve); + }); + + do_print("Wait for the disconnect port event"); + await waitForDisconnect; + do_print("Got the disconnect port event"); + + await embeddedExtension.shutdown(); + + equal(EmbeddedExtensionManager.embeddedExtensionsByAddonId.size, 0, + "EmbeddedExtension instances has been untracked from the EmbeddedExtensionManager"); +}); + +async function createManifestErrorTestCase(id, xpi, expectedError) { + // Remove the generated xpi file and flush the its jar cache + // on cleanup. + do_register_cleanup(() => { + Services.obs.notifyObservers(xpi, "flush-cache-entry"); + xpi.remove(false); + }); + + let fileURI = Services.io.newFileURI(xpi); + let resourceURI = Services.io.newURI(`jar:${fileURI.spec}!/`); + + let embeddedExtension = LegacyExtensionsUtils.getEmbeddedExtensionFor({ + id, resourceURI, + }); + + await Assert.rejects(embeddedExtension.startup(), expectedError, + "embedded extension startup rejected"); + + // Shutdown a "never-started" addon with an embedded webextension should not + // raise any exception, and if it does this test will fail. + await embeddedExtension.shutdown(); +} + +add_task(async function test_startup_error_empty_manifest() { + const id = "empty-manifest@test.embedded.web.extension"; + const files = { + "webextension/manifest.json": ``, + }; + const expectedError = "(NS_BASE_STREAM_CLOSED)"; + + let fakeHybridAddonFile = Extension.generateZipFile(files); + + await createManifestErrorTestCase(id, fakeHybridAddonFile, expectedError); +}); + +add_task(async function test_startup_error_invalid_json_manifest() { + const id = "invalid-json-manifest@test.embedded.web.extension"; + const files = { + "webextension/manifest.json": `{ "name": }`, + }; + const expectedError = "JSON.parse:"; + + let fakeHybridAddonFile = Extension.generateZipFile(files); + + await createManifestErrorTestCase(id, fakeHybridAddonFile, expectedError); +}); + +add_task(async function test_startup_error_blocking_validation_errors() { + const id = "blocking-manifest-validation-error@test.embedded.web.extension"; + const files = { + "webextension/manifest.json": { + name: "embedded webextension name", + manifest_version: 2, + version: "1.0", + background: { + scripts: {}, + }, + }, + }; + + function expectedError(actual) { + if (actual.errors && actual.errors.length == 1 && + actual.errors[0].startsWith("Reading manifest:")) { + return true; + } + + return false; + } + + let fakeHybridAddonFile = Extension.generateZipFile(files); + + await createManifestErrorTestCase(id, fakeHybridAddonFile, expectedError); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js b/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js new file mode 100644 index 0000000000..c2f06058bc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js @@ -0,0 +1,50 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + let hasRun = localStorage.getItem("has-run"); + let result; + if (!hasRun) { + localStorage.setItem("has-run", "yup"); + localStorage.setItem("test-item", "item1"); + result = "item1"; + } else { + let data = localStorage.getItem("test-item"); + if (data == "item1") { + localStorage.setItem("test-item", "item2"); + result = "item2"; + } else if (data == "item2") { + localStorage.removeItem("test-item"); + result = "deleted"; + } else if (!data) { + localStorage.clear(); + result = "cleared"; + } + } + browser.test.sendMessage("result", result); + browser.test.notifyPass("localStorage"); +} + +const ID = "test-webextension@mozilla.com"; +let extensionData = { + manifest: {applications: {gecko: {id: ID}}}, + background: backgroundScript, +}; + +add_task(async function test_localStorage() { + const RESULTS = ["item1", "item2", "deleted", "cleared", "item1"]; + + for (let expected of RESULTS) { + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + let actual = await extension.awaitMessage("result"); + + await extension.awaitFinish("localStorage"); + await extension.unload(); + + equal(actual, expected, "got expected localStorage data"); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_management.js b/toolkit/components/extensions/test/xpcshell/test_ext_management.js new file mode 100644 index 0000000000..a2c090cc14 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_management.js @@ -0,0 +1,30 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_management_schema() { + async function background() { + browser.test.assertTrue(browser.management, "browser.management API exists"); + let self = await browser.management.getSelf(); + browser.test.assertEq(browser.runtime.id, self.id, "got self"); + browser.test.notifyPass("management-schema"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["management"], + }, + background: `(${background})()`, + useAddonManager: "temporary", + }); + await extension.startup(); + await extension.awaitFinish("management-schema"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js b/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js new file mode 100644 index 0000000000..3259d8d297 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js @@ -0,0 +1,135 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Cu.import("resource://gre/modules/AddonManager.jsm"); +Cu.import("resource://testing-common/AddonTestUtils.jsm"); +Cu.import("resource://testing-common/MockRegistrar.jsm"); + +const id = "uninstall_self_test@tests.mozilla.com"; + +const manifest = { + applications: { + gecko: { + id, + }, + }, + name: "test extension name", + version: "1.0", +}; + +const waitForUninstalled = () => new Promise(resolve => { + const listener = { + onUninstalled: (addon) => { + equal(addon.id, id, "The expected add-on has been uninstalled"); + AddonManager.getAddonByID(addon.id, checkedAddon => { + equal(checkedAddon, null, "Add-on no longer exists"); + AddonManager.removeAddonListener(listener); + resolve(); + }); + }, + }; + AddonManager.addAddonListener(listener); +}); + +let promptService = { + _response: null, + QueryInterface: XPCOMUtils.generateQI([Ci.nsIPromptService]), + confirmEx: function(...args) { + this._confirmExArgs = args; + return this._response; + }, +}; + +add_task(async function setup() { + let fakePromptService = MockRegistrar.register("@mozilla.org/embedcomp/prompt-service;1", promptService); + do_register_cleanup(() => { + MockRegistrar.unregister(fakePromptService); + }); + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_management_uninstall_no_prompt() { + function background() { + browser.test.onMessage.addListener(msg => { + browser.management.uninstallSelf(); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest, + background, + useAddonManager: "temporary", + }); + + await extension.startup(); + let addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on is installed"); + extension.sendMessage("uninstall"); + await waitForUninstalled(); + Services.obs.notifyObservers(extension.extension.file, "flush-cache-entry"); +}); + +add_task(async function test_management_uninstall_prompt_uninstall() { + promptService._response = 0; + + function background() { + browser.test.onMessage.addListener(msg => { + browser.management.uninstallSelf({showConfirmDialog: true}); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest, + background, + useAddonManager: "temporary", + }); + + await extension.startup(); + let addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on is installed"); + extension.sendMessage("uninstall"); + await waitForUninstalled(); + + // Test localization strings + equal(promptService._confirmExArgs[1], `Uninstall ${manifest.name}`); + equal(promptService._confirmExArgs[2], + `The extension “${manifest.name}” is requesting to be uninstalled. What would you like to do?`); + equal(promptService._confirmExArgs[4], "Uninstall"); + equal(promptService._confirmExArgs[5], "Keep Installed"); + Services.obs.notifyObservers(extension.extension.file, "flush-cache-entry"); +}); + +add_task(async function test_management_uninstall_prompt_keep() { + promptService._response = 1; + + function background() { + browser.test.onMessage.addListener(async msg => { + await browser.test.assertRejects( + browser.management.uninstallSelf({showConfirmDialog: true}), + "User cancelled uninstall of extension", + "Expected rejection when user declines uninstall"); + + browser.test.sendMessage("uninstall-rejected"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest, + background, + useAddonManager: "temporary", + }); + + await extension.startup(); + + let addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on is installed"); + + extension.sendMessage("uninstall"); + await extension.awaitMessage("uninstall-rejected"); + + addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on remains installed"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js new file mode 100644 index 0000000000..442e47dfb2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js @@ -0,0 +1,30 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + + +add_task(async function test_manifest_csp() { + let normalized = await ExtensionTestUtils.normalizeManifest({ + "content_security_policy": "script-src 'self'; object-src 'none'", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); + equal(normalized.value.content_security_policy, + "script-src 'self'; object-src 'none'", + "Should have the expected poilcy string"); + + + normalized = await ExtensionTestUtils.normalizeManifest({ + "content_security_policy": "object-src 'none'", + }); + + equal(normalized.error, undefined, "Should not have an error"); + + Assert.deepEqual(normalized.errors, + ["Error processing content_security_policy: SyntaxError: Policy is missing a required \u2018script-src\u2019 directive"], + "Should have the expected warning"); + + equal(normalized.value.content_security_policy, null, + "Invalid policy string should be omitted"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js new file mode 100644 index 0000000000..12e07ad5af --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js @@ -0,0 +1,27 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + + +add_task(async function test_manifest_incognito() { + let normalized = await ExtensionTestUtils.normalizeManifest({ + "incognito": "spanning", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); + equal(normalized.value.incognito, + "spanning", + "Should have the expected incognito string"); + + normalized = await ExtensionTestUtils.normalizeManifest({ + "incognito": "split", + }); + + equal(normalized.error, undefined, "Should not have an error"); + Assert.deepEqual(normalized.errors, + ['Error processing incognito: Invalid enumeration value "split"'], + "Should have the expected warning"); + equal(normalized.value.incognito, null, + "Invalid incognito string should be omitted"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js new file mode 100644 index 0000000000..9d3cc85ea3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js @@ -0,0 +1,13 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + + +add_task(async function test_manifest_minimum_chrome_version() { + let normalized = await ExtensionTestUtils.normalizeManifest({ + "minimum_chrome_version": "42", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js new file mode 100644 index 0000000000..62fe316fe5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js @@ -0,0 +1,31 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function test_theme_property(property) { + let normalized = await ExtensionTestUtils.normalizeManifest({ + "theme": { + [property]: { + "unrecognized_key": "unrecognized_value", + }, + }, + }); + + let expectedWarning; + if (property === "unrecognized_key") { + expectedWarning = `Error processing theme.${property}`; + } else { + expectedWarning = `Error processing theme.${property}.unrecognized_key`; + } + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 1, "Should have a warning"); + ok(normalized.errors[0].includes(expectedWarning), + `The manifest warning ${JSON.stringify(normalized.errors[0])} must contain ${JSON.stringify(expectedWarning)}`); +} + +add_task(async function test_manifest_themes() { + await test_theme_property("images"); + await test_theme_property("colors"); + await test_theme_property("icons"); + await test_theme_property("unrecognized_key"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js new file mode 100644 index 0000000000..f1c6a75f59 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js @@ -0,0 +1,515 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals chrome */ + +const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes"; +const PREF_MAX_WRITE = "webextensions.native-messaging.max-output-message-bytes"; + +const ECHO_BODY = String.raw` + import struct + import sys + + while True: + rawlen = sys.stdin.read(4) + if len(rawlen) == 0: + sys.exit(0) + msglen = struct.unpack('@I', rawlen)[0] + msg = sys.stdin.read(msglen) + + sys.stdout.write(struct.pack('@I', msglen)) + sys.stdout.write(msg) +`; + +const INFO_BODY = String.raw` + import json + import os + import struct + import sys + + msg = json.dumps({"args": sys.argv, "cwd": os.getcwd()}) + sys.stdout.write(struct.pack('@I', len(msg))) + sys.stdout.write(msg) + sys.exit(0) +`; + +const STDERR_LINES = ["hello stderr", "this should be a separate line"]; +let STDERR_MSG = STDERR_LINES.join("\\n"); + +const STDERR_BODY = String.raw` + import sys + sys.stderr.write("${STDERR_MSG}") +`; + +const SCRIPTS = [ + { + name: "echo", + description: "a native app that echoes back messages it receives", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + }, + { + name: "info", + description: "a native app that gives some info about how it was started", + script: INFO_BODY.replace(/^ {2}/gm, ""), + }, + { + name: "stderr", + description: "a native app that writes to stderr and then exits", + script: STDERR_BODY.replace(/^ {2}/gm, ""), + }, +]; + +add_task(async function setup() { + await setupHosts(SCRIPTS); +}); + +// Test the basic operation of native messaging with a simple +// script that echoes back whatever message is sent to it. +add_task(async function test_happy_path() { + function background() { + let port = browser.runtime.connectNative("echo"); + port.onMessage.addListener(msg => { + browser.test.sendMessage("message", msg); + }); + browser.test.onMessage.addListener((what, payload) => { + if (what == "send") { + if (payload._json) { + let json = payload._json; + payload.toJSON = () => json; + delete payload._json; + } + port.postMessage(payload); + } + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: {gecko: {id: ID}}, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + const tests = [ + { + data: "this is a string", + what: "simple string", + }, + { + data: "Это юникода", + what: "unicode string", + }, + { + data: {test: "hello"}, + what: "simple object", + }, + { + data: { + what: "An object with a few properties", + number: 123, + bool: true, + nested: {what: "another object"}, + }, + what: "object with several properties", + }, + + { + data: { + ignoreme: true, + _json: {data: "i have a tojson method"}, + }, + expected: {data: "i have a tojson method"}, + what: "object with toJSON() method", + }, + ]; + for (let test of tests) { + extension.sendMessage("send", test.data); + let response = await extension.awaitMessage("message"); + let expected = test.expected || test.data; + deepEqual(response, expected, `Echoed a message of type ${test.what}`); + } + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is still running"); + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; +}); + +if (AppConstants.platform == "win") { + // "relative.echo" has a relative path in the host manifest. + add_task(async function test_relative_path() { + function background() { + let port = browser.runtime.connectNative("relative.echo"); + let MSG = "test relative echo path"; + port.onMessage.addListener(msg => { + browser.test.assertEq(MSG, msg, "Got expected message back"); + browser.test.sendMessage("done"); + }); + port.postMessage(MSG); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: {gecko: {id: ID}}, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is still running"); + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; + }); +} + +// Test sendNativeMessage() +add_task(async function test_sendNativeMessage() { + async function background() { + let MSG = {test: "hello world"}; + + // Check error handling + await browser.test.assertRejects( + browser.runtime.sendNativeMessage("nonexistent", MSG), + /Attempt to postMessage on disconnected port/, + "sendNativeMessage() to a nonexistent app failed"); + + // Check regular message exchange + let reply = await browser.runtime.sendNativeMessage("echo", MSG); + + let expected = JSON.stringify(MSG); + let received = JSON.stringify(reply); + browser.test.assertEq(expected, received, "Received echoed native message"); + + browser.test.sendMessage("finished"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: {gecko: {id: ID}}, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + + // With sendNativeMessage(), the subprocess should be disconnected + // after exchanging a single message. + await waitForSubprocessExit(); + + await extension.unload(); +}); + +// Test calling Port.disconnect() +add_task(async function test_disconnect() { + function background() { + let port = browser.runtime.connectNative("echo"); + port.onMessage.addListener((msg, msgPort) => { + browser.test.assertEq(port, msgPort, "onMessage handler should receive the port as the second argument"); + browser.test.sendMessage("message", msg); + }); + port.onDisconnect.addListener(msgPort => { + browser.test.fail("onDisconnect should not be called for disconnect()"); + }); + browser.test.onMessage.addListener((what, payload) => { + if (what == "send") { + if (payload._json) { + let json = payload._json; + payload.toJSON = () => json; + delete payload._json; + } + port.postMessage(payload); + } else if (what == "disconnect") { + try { + port.disconnect(); + browser.test.sendMessage("disconnect-result", {success: true}); + } catch (err) { + browser.test.sendMessage("disconnect-result", { + success: false, + errmsg: err.message, + }); + } + } + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: {gecko: {id: ID}}, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage("send", "test"); + let response = await extension.awaitMessage("message"); + equal(response, "test", "Echoed a string"); + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is running"); + + extension.sendMessage("disconnect"); + response = await extension.awaitMessage("disconnect-result"); + equal(response.success, true, "disconnect succeeded"); + + do_print("waiting for subprocess to exit"); + await waitForSubprocessExit(); + procCount = await getSubprocessCount(); + equal(procCount, 0, "subprocess is no longer running"); + + extension.sendMessage("disconnect"); + response = await extension.awaitMessage("disconnect-result"); + equal(response.success, true, "second call to disconnect silently ignored"); + + await extension.unload(); +}); + +// Test the limit on message size for writing +add_task(async function test_write_limit() { + Services.prefs.setIntPref(PREF_MAX_WRITE, 10); + function clearPref() { + Services.prefs.clearUserPref(PREF_MAX_WRITE); + } + do_register_cleanup(clearPref); + + function background() { + const PAYLOAD = "0123456789A"; + let port = browser.runtime.connectNative("echo"); + try { + port.postMessage(PAYLOAD); + browser.test.sendMessage("result", null); + } catch (ex) { + browser.test.sendMessage("result", ex.message); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: {gecko: {id: ID}}, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let errmsg = await extension.awaitMessage("result"); + notEqual(errmsg, null, "native postMessage() failed for overly large message"); + + await extension.unload(); + await waitForSubprocessExit(); + + clearPref(); +}); + +// Test the limit on message size for reading +add_task(async function test_read_limit() { + Services.prefs.setIntPref(PREF_MAX_READ, 10); + function clearPref() { + Services.prefs.clearUserPref(PREF_MAX_READ); + } + do_register_cleanup(clearPref); + + function background() { + const PAYLOAD = "0123456789A"; + let port = browser.runtime.connectNative("echo"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq(port, msgPort, "onDisconnect handler should receive the port as the first argument"); + browser.test.assertEq("Native application tried to send a message of 13 bytes, which exceeds the limit of 10 bytes.", port.error && port.error.message); + browser.test.sendMessage("result", "disconnected"); + }); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", "message"); + }); + port.postMessage(PAYLOAD); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: {gecko: {id: ID}}, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let result = await extension.awaitMessage("result"); + equal(result, "disconnected", "native port disconnected on receiving large message"); + + await extension.unload(); + await waitForSubprocessExit(); + + clearPref(); +}); + +// Test that an extension without the nativeMessaging permission cannot +// use native messaging. +add_task(async function test_ext_permission() { + function background() { + browser.test.assertEq(chrome.runtime.connectNative, undefined, "chrome.runtime.connectNative does not exist without nativeMessaging permission"); + browser.test.assertEq(browser.runtime.connectNative, undefined, "browser.runtime.connectNative does not exist without nativeMessaging permission"); + browser.test.assertEq(chrome.runtime.sendNativeMessage, undefined, "chrome.runtime.sendNativeMessage does not exist without nativeMessaging permission"); + browser.test.assertEq(browser.runtime.sendNativeMessage, undefined, "browser.runtime.sendNativeMessage does not exist without nativeMessaging permission"); + browser.test.sendMessage("finished"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: {}, + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + await extension.unload(); +}); + +// Test that an extension that is not listed in allowed_extensions for +// a native application cannot use that application. +add_task(async function test_app_permission() { + function background() { + let port = browser.runtime.connectNative("echo"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq(port, msgPort, "onDisconnect handler should receive the port as the first argument"); + browser.test.assertEq("This extension does not have permission to use native application echo (or the application is not installed)", port.error && port.error.message); + browser.test.sendMessage("result", "disconnected"); + }); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", "message"); + }); + port.postMessage({test: "test"}); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["nativeMessaging"], + }, + }, "somethingelse@tests.mozilla.org"); + + await extension.startup(); + + let result = await extension.awaitMessage("result"); + equal(result, "disconnected", "connectNative() failed without native app permission"); + + await extension.unload(); + + let procCount = await getSubprocessCount(); + equal(procCount, 0, "No child process was started"); +}); + +// Test that the command-line arguments and working directory for the +// native application are as expected. +add_task(async function test_child_process() { + function background() { + let port = browser.runtime.connectNative("info"); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", msg); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: {gecko: {id: ID}}, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let msg = await extension.awaitMessage("result"); + equal(msg.args.length, 3, "Received two command line arguments"); + equal(msg.args[1], getPath("info.json"), "Command line argument is the path to the native host manifest"); + equal(msg.args[2], ID, "Second command line argument is the ID of the calling extension"); + equal(msg.cwd.replace(/^\/private\//, "/"), tmpDir.path, + "Working directory is the directory containing the native appliation"); + + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; +}); + +add_task(async function test_stderr() { + function background() { + let port = browser.runtime.connectNative("stderr"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq(port, msgPort, "onDisconnect handler should receive the port as the first argument"); + browser.test.assertEq(null, port.error, "Normal application exit is not an error"); + browser.test.sendMessage("finished"); + }); + } + + let {messages} = await promiseConsoleOutput(async function() { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: {gecko: {id: ID}}, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + await extension.unload(); + + await waitForSubprocessExit(); + }); + + let lines = STDERR_LINES.map(line => messages.findIndex(msg => msg.message.includes(line))); + notEqual(lines[0], -1, "Saw first line of stderr output on the console"); + notEqual(lines[1], -1, "Saw second line of stderr output on the console"); + notEqual(lines[0], lines[1], "Stderr output lines are separated in the console"); +}); + +// Test that calling connectNative() multiple times works +// (bug 1313980 was a previous regression in this area) +add_task(async function test_multiple_connects() { + async function background() { + function once() { + return new Promise(resolve => { + let MSG = "hello"; + let port = browser.runtime.connectNative("echo"); + + port.onMessage.addListener(msg => { + browser.test.assertEq(MSG, msg, "Got expected message back"); + port.disconnect(); + resolve(); + }); + port.postMessage(MSG); + }); + } + + await once(); + await once(); + browser.test.notifyPass("multiple-connect"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: {gecko: {id: ID}}, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("multiple-connect"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js new file mode 100644 index 0000000000..b775761ad9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js @@ -0,0 +1,128 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "MockRegistry", + "resource://testing-common/MockRegistry.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); + +Cu.import("resource://gre/modules/Subprocess.jsm"); + +const MAX_ROUND_TRIP_TIME_MS = AppConstants.DEBUG || AppConstants.ASAN ? 60 : 30; +const MAX_RETRIES = 5; + + +const ECHO_BODY = String.raw` + import struct + import sys + + while True: + rawlen = sys.stdin.read(4) + if len(rawlen) == 0: + sys.exit(0) + + msglen = struct.unpack('@I', rawlen)[0] + msg = sys.stdin.read(msglen) + + sys.stdout.write(struct.pack('@I', msglen)) + sys.stdout.write(msg) +`; + +const SCRIPTS = [ + { + name: "echo", + description: "A native app that echoes back messages it receives", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + }, +]; + +add_task(async function setup() { + await setupHosts(SCRIPTS); +}); + +add_task(async function test_round_trip_perf() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener(msg => { + if (msg != "run-tests") { + return; + } + + let port = browser.runtime.connectNative("echo"); + + function next() { + port.postMessage({ + "Lorem": { + "ipsum": { + "dolor": [ + "sit amet", + "consectetur adipiscing elit", + "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + ], + "Ut enim": [ + "ad minim veniam", + "quis nostrud exercitation ullamco", + "laboris nisi ut aliquip ex ea commodo consequat.", + ], + "Duis": [ + "aute irure dolor in reprehenderit in", + "voluptate velit esse cillum dolore eu", + "fugiat nulla pariatur.", + ], + "Excepteur": [ + "sint occaecat cupidatat non proident", + "sunt in culpa qui officia deserunt", + "mollit anim id est laborum.", + ], + }, + }, + }); + } + + const COUNT = 1000; + let now; + function finish() { + let roundTripTime = (Date.now() - now) / COUNT; + + port.disconnect(); + browser.test.sendMessage("result", roundTripTime); + } + + let count = 0; + port.onMessage.addListener(() => { + if (count == 0) { + // Skip the first round, since it includes the time it takes + // the app to start up. + now = Date.now(); + } + + if (count++ <= COUNT) { + next(); + } else { + finish(); + } + }); + + next(); + }); + }, + manifest: { + applications: {gecko: {id: ID}}, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let roundTripTime = Infinity; + for (let i = 0; i < MAX_RETRIES && roundTripTime > MAX_ROUND_TRIP_TIME_MS; i++) { + extension.sendMessage("run-tests"); + roundTripTime = await extension.awaitMessage("result"); + } + + await extension.unload(); + + ok(roundTripTime <= MAX_ROUND_TRIP_TIME_MS, + `Expected round trip time (${roundTripTime}ms) to be less than ${MAX_ROUND_TRIP_TIME_MS}ms`); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js new file mode 100644 index 0000000000..3e98877e06 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js @@ -0,0 +1,82 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const WONTDIE_BODY = String.raw` + import signal + import struct + import sys + import time + + signal.signal(signal.SIGTERM, signal.SIG_IGN) + + def spin(): + while True: + try: + signal.pause() + except AttributeError: + time.sleep(5) + + while True: + rawlen = sys.stdin.read(4) + if len(rawlen) == 0: + spin() + + msglen = struct.unpack('@I', rawlen)[0] + msg = sys.stdin.read(msglen) + + sys.stdout.write(struct.pack('@I', msglen)) + sys.stdout.write(msg) +`; + +const SCRIPTS = [ + { + name: "wontdie", + description: "a native app that does not exit when stdin closes or on SIGTERM", + script: WONTDIE_BODY.replace(/^ {2}/gm, ""), + }, +]; + +add_task(async function setup() { + await setupHosts(SCRIPTS); +}); + + +// Test that an unresponsive native application still gets killed eventually +add_task(async function test_unresponsive_native_app() { + // XXX expose GRACEFUL_SHUTDOWN_TIME as a pref and reduce it + // just for this test? + + function background() { + let port = browser.runtime.connectNative("wontdie"); + + const MSG = "echo me"; + // bounce a message to make sure the process actually starts + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, MSG, "Received echoed message"); + browser.test.sendMessage("ready"); + }); + port.postMessage(MSG); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: {gecko: {id: ID}}, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is running"); + + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; + + procCount = await getSubprocessCount(); + equal(procCount, 0, "subprocess was succesfully killed"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js b/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js new file mode 100644 index 0000000000..7da12b40aa --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js @@ -0,0 +1,30 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + function listener() { + browser.test.notifyFail("listener should not be invoked"); + } + + browser.runtime.onMessage.addListener(listener); + browser.runtime.onMessage.removeListener(listener); + browser.runtime.sendMessage("hello"); + + // Make sure that, if we somehow fail to remove the listener, then we'll run + // the listener before the test is marked as passing. + setTimeout(function() { + browser.test.notifyPass("onmessage_removelistener"); + }, 0); +} + +let extensionData = { + background: backgroundScript, +}; + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("onmessage_removelistener"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js new file mode 100644 index 0000000000..c65de423c4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js @@ -0,0 +1,265 @@ +"use strict"; + +XPCOMUtils.defineLazyGetter(this, "ExtensionManager", () => { + const {ExtensionManager} + = Cu.import("resource://gre/modules/ExtensionChild.jsm", {}); + return ExtensionManager; +}); +Cu.import("resource://gre/modules/ExtensionPermissions.jsm"); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +// Find the DOMWindowUtils for the background page for the given +// extension (wrapper) +function findWinUtils(extension) { + let extensionChild = ExtensionManager.extensions.get(extension.extension.id); + let bgwin = null; + for (let view of extensionChild.views) { + if (view.viewType == "background") { + bgwin = view.contentWindow; + } + } + notEqual(bgwin, null, "Found background window for the test extension"); + return bgwin.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); +} + +add_task(async function test_permissions() { + const REQUIRED_PERMISSIONS = ["downloads"]; + const REQUIRED_ORIGINS = ["*://site.com/", "*://*.domain.com/"]; + const REQUIRED_ORIGINS_NORMALIZED = ["*://site.com/*", "*://*.domain.com/*"]; + + const OPTIONAL_PERMISSIONS = ["idle", "clipboardWrite"]; + const OPTIONAL_ORIGINS = ["http://optionalsite.com/", "https://*.optionaldomain.com/"]; + const OPTIONAL_ORIGINS_NORMALIZED = ["http://optionalsite.com/*", "https://*.optionaldomain.com/*"]; + + let acceptPrompt = false; + const observer = { + observe(subject, topic, data) { + if (topic == "webextension-optional-permission-prompt") { + let {resolve} = subject.wrappedJSObject; + resolve(acceptPrompt); + } + }, + }; + + Services.prefs.setBoolPref("extensions.webextOptionalPermissionPrompts", true); + Services.obs.addObserver(observer, "webextension-optional-permission-prompt"); + do_register_cleanup(() => { + Services.obs.removeObserver(observer, "webextension-optional-permission-prompt"); + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); + + await AddonTestUtils.promiseStartupManager(); + + function background() { + browser.test.onMessage.addListener(async (method, arg) => { + if (method == "getAll") { + let perms = await browser.permissions.getAll(); + browser.test.sendMessage("getAll.result", perms); + } else if (method == "contains") { + let result = await browser.permissions.contains(arg); + browser.test.sendMessage("contains.result", result); + } else if (method == "request") { + try { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("request.result", {status: "success", result}); + } catch (err) { + browser.test.sendMessage("request.result", {status: "error", message: err.message}); + } + } else if (method == "remove") { + let result = await browser.permissions.remove(arg); + browser.test.sendMessage("remove.result", result); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: [...REQUIRED_PERMISSIONS, ...REQUIRED_ORIGINS], + optional_permissions: [...OPTIONAL_PERMISSIONS, ...OPTIONAL_ORIGINS], + }, + useAddonManager: "permanent", + }); + + await extension.startup(); + let winUtils = findWinUtils(extension); + + function call(method, arg) { + extension.sendMessage(method, arg); + return extension.awaitMessage(`${method}.result`); + } + + let result = await call("getAll"); + deepEqual(result.permissions, REQUIRED_PERMISSIONS); + deepEqual(result.origins, REQUIRED_ORIGINS_NORMALIZED); + + for (let perm of REQUIRED_PERMISSIONS) { + result = await call("contains", {permissions: [perm]}); + equal(result, true, `contains() returns true for fixed permission ${perm}`); + } + for (let origin of REQUIRED_ORIGINS) { + result = await call("contains", {origins: [origin]}); + equal(result, true, `contains() returns true for fixed origin ${origin}`); + } + + // None of the optional permissions should be available yet + for (let perm of OPTIONAL_PERMISSIONS) { + result = await call("contains", {permissions: [perm]}); + equal(result, false, `contains() returns false for permission ${perm}`); + } + for (let origin of OPTIONAL_ORIGINS) { + result = await call("contains", {origins: [origin]}); + equal(result, false, `conains() returns false for origin ${origin}`); + } + + result = await call("contains", { + permissions: [...REQUIRED_PERMISSIONS, ...OPTIONAL_PERMISSIONS], + }); + equal(result, false, "contains() returns false for a mix of available and unavailable permissions"); + + let perm = OPTIONAL_PERMISSIONS[0]; + result = await call("request", {permissions: [perm]}); + equal(result.status, "error", "request() fails if not called from an event handler"); + ok(/May only request permissions from a user input handler/.test(result.message), + "error message for calling request() outside an event handler is reasonable"); + result = await call("contains", {permissions: [perm]}); + equal(result, false, "Permission requested outside an event handler was not granted"); + + let userInputHandle = winUtils.setHandlingUserInput(true); + + result = await call("request", {permissions: ["notifications"]}); + equal(result.status, "error", "request() for permission not in optional_permissions should fail"); + ok(/since it was not declared in optional_permissions/.test(result.message), + "error message for undeclared optional_permission is reasonable"); + + // Check request() when the prompt is canceled. + acceptPrompt = false; + result = await call("request", {permissions: [perm]}); + equal(result.status, "success", "request() returned cleanly"); + equal(result.result, false, "request() returned false for rejected permission"); + + result = await call("contains", {permissions: [perm]}); + equal(result, false, "Rejected permission was not granted"); + + // Call request() and accept the prompt + acceptPrompt = true; + let allOptional = { + permissions: OPTIONAL_PERMISSIONS, + origins: OPTIONAL_ORIGINS, + }; + result = await call("request", allOptional); + equal(result.status, "success", "request() returned cleanly"); + equal(result.result, true, "request() returned true for accepted permissions"); + userInputHandle.destruct(); + + let allPermissions = { + permissions: [...REQUIRED_PERMISSIONS, ...OPTIONAL_PERMISSIONS], + origins: [...REQUIRED_ORIGINS_NORMALIZED, ...OPTIONAL_ORIGINS_NORMALIZED], + }; + + result = await call("getAll"); + deepEqual(result, allPermissions, "getAll() returns required and runtime requested permissions"); + + result = await call("contains", allPermissions); + equal(result, true, "contains() returns true for runtime requested permissions"); + + // Restart, verify permissions are still present + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + result = await call("getAll"); + deepEqual(result, allPermissions, "Runtime requested permissions are still present after restart"); + + // Check remove() + result = await call("remove", {permissions: OPTIONAL_PERMISSIONS}); + equal(result, true, "remove() succeeded"); + + let perms = { + permissions: REQUIRED_PERMISSIONS, + origins: [...REQUIRED_ORIGINS_NORMALIZED, ...OPTIONAL_ORIGINS_NORMALIZED], + }; + result = await call("getAll"); + deepEqual(result, perms, "Expected permissions remain after removing some"); + + result = await call("remove", {origins: OPTIONAL_ORIGINS}); + equal(result, true, "remove() succeeded"); + + perms.origins = REQUIRED_ORIGINS_NORMALIZED; + result = await call("getAll"); + deepEqual(result, perms, "Back to default permissions after removing more"); + + await extension.unload(); +}); + +add_task(async function test_startup() { + async function background() { + browser.test.onMessage.addListener(async (perms) => { + await browser.permissions.request(perms); + browser.test.sendMessage("requested"); + }); + + let all = await browser.permissions.getAll(); + browser.test.sendMessage("perms", all); + } + + const PERMS1 = { + permissions: ["clipboardRead", "tabs"], + }; + const PERMS2 = { + origins: ["https://site2.com/*"], + }; + + let extension1 = ExtensionTestUtils.loadExtension({ + background, + manifest: {optional_permissions: PERMS1.permissions}, + useAddonManager: "permanent", + }); + let extension2 = ExtensionTestUtils.loadExtension({ + background, + manifest: {optional_permissions: PERMS2.origins}, + useAddonManager: "permanent", + }); + + await extension1.startup(); + await extension2.startup(); + + let perms = await extension1.awaitMessage("perms"); + dump(`perms1 ${JSON.stringify(perms)}\n`); + perms = await extension2.awaitMessage("perms"); + dump(`perms2 ${JSON.stringify(perms)}\n`); + + let winUtils = findWinUtils(extension1); + let handle = winUtils.setHandlingUserInput(true); + extension1.sendMessage(PERMS1); + await extension1.awaitMessage("requested"); + handle.destruct(); + + winUtils = findWinUtils(extension2); + handle = winUtils.setHandlingUserInput(true); + extension2.sendMessage(PERMS2); + await extension2.awaitMessage("requested"); + handle.destruct(); + + // Restart everything, and force the permissions store to be + // re-read on startup + ExtensionPermissions._uninit(); + await AddonTestUtils.promiseRestartManager(); + await extension1.awaitStartup(); + await extension2.awaitStartup(); + + async function checkPermissions(extension, permissions) { + perms = await extension.awaitMessage("perms"); + let expect = Object.assign({permissions: [], origins: []}, permissions); + deepEqual(perms, expect, "Extension got correct permissions on startup"); + } + + await checkPermissions(extension1, PERMS1); + await checkPermissions(extension2, PERMS2); + + await extension1.unload(); + await extension2.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js new file mode 100644 index 0000000000..798278d6c4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js @@ -0,0 +1,342 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionPreferencesManager", + "resource://gre/modules/ExtensionPreferencesManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Preferences", + "resource://gre/modules/Preferences.jsm"); + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +add_task(async function test_privacy() { + // Create an object to hold the values to which we will initialize the prefs. + const SETTINGS = { + "network.networkPredictionEnabled": { + "network.predictor.enabled": true, + "network.prefetch-next": true, + "network.http.speculative-parallel-limit": 10, + "network.dns.disablePrefetch": false, + }, + "websites.hyperlinkAuditingEnabled": { + "browser.send_pings": true, + }, + }; + + async function background() { + browser.test.onMessage.addListener(async (msg, ...args) => { + let data = args[0]; + // The second argument is the end of the api name, + // e.g., "network.networkPredictionEnabled". + let apiObj = args[1].split(".").reduce((o, i) => o[i], browser.privacy); + let settingData; + switch (msg) { + case "get": + settingData = await apiObj.get(data); + browser.test.sendMessage("gotData", settingData); + break; + + case "set": + await apiObj.set(data); + settingData = await apiObj.get({}); + browser.test.sendMessage("afterSet", settingData); + break; + + case "clear": + await apiObj.clear(data); + settingData = await apiObj.get({}); + browser.test.sendMessage("afterClear", settingData); + break; + } + }); + } + + // Set prefs to our initial values. + for (let setting in SETTINGS) { + for (let pref in SETTINGS[setting]) { + Preferences.set(pref, SETTINGS[setting][pref]); + } + } + + do_register_cleanup(() => { + // Reset the prefs. + for (let setting in SETTINGS) { + for (let pref in SETTINGS[setting]) { + Preferences.reset(pref); + } + } + }); + + // Create an array of extensions to install. + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + + ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + ]; + + await promiseStartupManager(); + + for (let extension of testExtensions) { + await extension.startup(); + } + + for (let setting in SETTINGS) { + testExtensions[0].sendMessage("get", {}, setting); + let data = await testExtensions[0].awaitMessage("gotData"); + ok(data.value, "get returns expected value."); + equal(data.levelOfControl, "controllable_by_this_extension", + "get returns expected levelOfControl."); + + testExtensions[0].sendMessage("get", {incognito: true}, setting); + data = await testExtensions[0].awaitMessage("gotData"); + ok(data.value, "get returns expected value with incognito."); + equal(data.levelOfControl, "not_controllable", + "get returns expected levelOfControl with incognito."); + + // Change the value to false. + testExtensions[0].sendMessage("set", {value: false}, setting); + data = await testExtensions[0].awaitMessage("afterSet"); + ok(!data.value, "get returns expected value after setting."); + equal(data.levelOfControl, "controlled_by_this_extension", + "get returns expected levelOfControl after setting."); + + // Verify the prefs have been set to match the "false" setting. + for (let pref in SETTINGS[setting]) { + let msg = `${pref} set correctly for ${setting}`; + if (pref === "network.http.speculative-parallel-limit") { + equal(Preferences.get(pref), 0, msg); + } else { + equal(Preferences.get(pref), !SETTINGS[setting][pref], msg); + } + } + + // Change the value with a newer extension. + testExtensions[1].sendMessage("set", {value: true}, setting); + data = await testExtensions[1].awaitMessage("afterSet"); + ok(data.value, "get returns expected value after setting via newer extension."); + equal(data.levelOfControl, "controlled_by_this_extension", + "get returns expected levelOfControl after setting."); + + // Verify the prefs have been set to match the "true" setting. + for (let pref in SETTINGS[setting]) { + let msg = `${pref} set correctly for ${setting}`; + if (pref === "network.http.speculative-parallel-limit") { + equal(Preferences.get(pref), ExtensionPreferencesManager.getDefaultValue(pref), msg); + } else { + equal(Preferences.get(pref), SETTINGS[setting][pref], msg); + } + } + + // Change the value with an older extension. + testExtensions[0].sendMessage("set", {value: false}, setting); + data = await testExtensions[0].awaitMessage("afterSet"); + ok(data.value, "Newer extension remains in control."); + equal(data.levelOfControl, "controlled_by_other_extensions", + "get returns expected levelOfControl when controlled by other."); + + // Clear the value of the newer extension. + testExtensions[1].sendMessage("clear", {}, setting); + data = await testExtensions[1].awaitMessage("afterClear"); + ok(!data.value, "Older extension gains control."); + equal(data.levelOfControl, "controllable_by_this_extension", + "Expected levelOfControl returned after clearing."); + + testExtensions[0].sendMessage("get", {}, setting); + data = await testExtensions[0].awaitMessage("gotData"); + ok(!data.value, "Current, older extension has control."); + equal(data.levelOfControl, "controlled_by_this_extension", + "Expected levelOfControl returned after clearing."); + + // Set the value again with the newer extension. + testExtensions[1].sendMessage("set", {value: true}, setting); + data = await testExtensions[1].awaitMessage("afterSet"); + ok(data.value, "get returns expected value after setting via newer extension."); + equal(data.levelOfControl, "controlled_by_this_extension", + "get returns expected levelOfControl after setting."); + + // Unload the newer extension. Expect the older extension to regain control. + await testExtensions[1].unload(); + testExtensions[0].sendMessage("get", {}, setting); + data = await testExtensions[0].awaitMessage("gotData"); + ok(!data.value, "Older extension regained control."); + equal(data.levelOfControl, "controlled_by_this_extension", + "Expected levelOfControl returned after unloading."); + + // Reload the extension for the next iteration of the loop. + testExtensions[1] = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }); + await testExtensions[1].startup(); + + // Clear the value of the older extension. + testExtensions[0].sendMessage("clear", {}, setting); + data = await testExtensions[0].awaitMessage("afterClear"); + ok(data.value, "Setting returns to original value when all are cleared."); + equal(data.levelOfControl, "controllable_by_this_extension", + "Expected levelOfControl returned after clearing."); + + // Verify that our initial values were restored. + for (let pref in SETTINGS[setting]) { + equal(Preferences.get(pref), SETTINGS[setting][pref], `${pref} was reset to its initial value.`); + } + } + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); + +// This test can be used for any settings that are added which utilize only +// boolean prefs. +add_task(async function test_privacy_boolean_prefs() { + // Create an object to hold the values to which we will initialize the prefs. + const SETTINGS = { + "network.webRTCIPHandlingPolicy": { + "media.peerconnection.ice.default_address_only": false, + "media.peerconnection.ice.no_host": false, + "media.peerconnection.ice.proxy_only": false, + }, + "network.peerConnectionEnabled": { + "media.peerconnection.enabled": true, + }, + }; + + async function background() { + browser.test.onMessage.addListener(async (msg, ...args) => { + let data = args[0]; + // The second argument is the end of the api name, + // e.g., "network.webRTCIPHandlingPolicy". + let apiObj = args[1].split(".").reduce((o, i) => o[i], browser.privacy); + let settingData; + switch (msg) { + case "set": + await apiObj.set(data); + settingData = await apiObj.get({}); + browser.test.sendMessage("settingData", settingData); + break; + + case "clear": + await apiObj.clear(data); + settingData = await apiObj.get({}); + browser.test.sendMessage("settingData", settingData); + break; + } + }); + } + + // Set prefs to our initial values. + for (let setting in SETTINGS) { + for (let pref in SETTINGS[setting]) { + Preferences.set(pref, SETTINGS[setting][pref]); + } + } + + do_register_cleanup(() => { + // Reset the prefs. + for (let setting in SETTINGS) { + for (let pref in SETTINGS[setting]) { + Preferences.reset(pref); + } + } + }); + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }); + + await promiseStartupManager(); + await extension.startup(); + + async function testSetting(setting, value, truePrefs) { + extension.sendMessage("set", {value: value}, setting); + let data = await extension.awaitMessage("settingData"); + equal(data.value, value); + for (let pref in SETTINGS[setting]) { + let prefValue = Preferences.get(pref); + equal(prefValue, truePrefs.includes(pref), `${pref} set correctly for ${value}`); + } + } + + await testSetting( + "network.webRTCIPHandlingPolicy", + "default_public_and_private_interfaces", + ["media.peerconnection.ice.default_address_only"]); + + await testSetting( + "network.webRTCIPHandlingPolicy", + "default_public_interface_only", + ["media.peerconnection.ice.default_address_only", "media.peerconnection.ice.no_host"]); + + await testSetting( + "network.webRTCIPHandlingPolicy", + "disable_non_proxied_udp", + ["media.peerconnection.ice.proxy_only"]); + + await testSetting("network.webRTCIPHandlingPolicy", "default", []); + + await testSetting("network.peerConnectionEnabled", false, []); + await testSetting("network.peerConnectionEnabled", true, ["media.peerconnection.enabled"]); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +add_task(async function test_exceptions() { + async function background() { + await browser.test.assertRejects( + browser.privacy.network.networkPredictionEnabled.set({value: true, scope: "regular_only"}), + "Firefox does not support the regular_only settings scope.", + "Expected rejection calling set with invalid scope."); + + await browser.test.assertRejects( + browser.privacy.network.networkPredictionEnabled.clear({scope: "incognito_persistent"}), + "Firefox does not support the incognito_persistent settings scope.", + "Expected rejection calling clear with invalid scope."); + + browser.test.notifyPass("exceptionTests"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("exceptionTests"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js new file mode 100644 index 0000000000..c685b23ddb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js @@ -0,0 +1,185 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +XPCOMUtils.defineLazyGetter(this, "Management", () => { + const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {}); + return Management; +}); + +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionPreferencesManager", + "resource://gre/modules/ExtensionPreferencesManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Preferences", + "resource://gre/modules/Preferences.jsm"); + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +function awaitEvent(eventName) { + return new Promise(resolve => { + let listener = (_eventName, ...args) => { + if (_eventName === eventName) { + Management.off(eventName, listener); + resolve(...args); + } + }; + + Management.on(eventName, listener); + }); +} + +function awaitPrefChange(prefName) { + return new Promise(resolve => { + let listener = (args) => { + Preferences.ignore(prefName, listener); + resolve(); + }; + + Preferences.observe(prefName, listener); + }); +} + +add_task(async function test_disable() { + const OLD_ID = "old_id@tests.mozilla.org"; + const NEW_ID = "new_id@tests.mozilla.org"; + + const PREF_TO_WATCH = "network.http.speculative-parallel-limit"; + + // Create an object to hold the values to which we will initialize the prefs. + const PREFS = { + "network.predictor.enabled": true, + "network.prefetch-next": true, + "network.http.speculative-parallel-limit": 10, + "network.dns.disablePrefetch": false, + }; + + // Set prefs to our initial values. + for (let pref in PREFS) { + Preferences.set(pref, PREFS[pref]); + } + + do_register_cleanup(() => { + // Reset the prefs. + for (let pref in PREFS) { + Preferences.reset(pref); + } + }); + + function checkPrefs(expected) { + for (let pref in PREFS) { + let msg = `${pref} set correctly.`; + let expectedValue = expected ? PREFS[pref] : !PREFS[pref]; + if (pref === "network.http.speculative-parallel-limit") { + expectedValue = expected ? ExtensionPreferencesManager.getDefaultValue(pref) : 0; + } + equal(Preferences.get(pref), expectedValue, msg); + } + } + + async function background() { + browser.test.onMessage.addListener(async (msg, data) => { + await browser.privacy.network.networkPredictionEnabled.set(data); + let settingData = await browser.privacy.network.networkPredictionEnabled.get({}); + browser.test.sendMessage("privacyData", settingData); + }); + } + + // Create an array of extensions to install. + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { + gecko: { + id: OLD_ID, + }, + }, + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + + ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { + gecko: { + id: NEW_ID, + }, + }, + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + ]; + + await promiseStartupManager(); + + for (let extension of testExtensions) { + await extension.startup(); + } + + // Set the value to true for the older extension. + testExtensions[0].sendMessage("set", {value: true}); + let data = await testExtensions[0].awaitMessage("privacyData"); + ok(data.value, "Value set to true for the older extension."); + + // Set the value to false for the newest extension. + testExtensions[1].sendMessage("set", {value: false}); + data = await testExtensions[1].awaitMessage("privacyData"); + ok(!data.value, "Value set to false for the newest extension."); + + // Verify the prefs have been set to match the "false" setting. + checkPrefs(false); + + // Disable the newest extension. + let disabledPromise = awaitPrefChange(PREF_TO_WATCH); + let newAddon = await AddonManager.getAddonByID(NEW_ID); + newAddon.userDisabled = true; + await disabledPromise; + + // Verify the prefs have been set to match the "true" setting. + checkPrefs(true); + + // Disable the older extension. + disabledPromise = awaitPrefChange(PREF_TO_WATCH); + let oldAddon = await AddonManager.getAddonByID(OLD_ID); + oldAddon.userDisabled = true; + await disabledPromise; + + // Verify the prefs have reverted back to their initial values. + for (let pref in PREFS) { + equal(Preferences.get(pref), PREFS[pref], `${pref} reset correctly.`); + } + + // Re-enable the newest extension. + let enabledPromise = awaitEvent("ready"); + newAddon.userDisabled = false; + await enabledPromise; + + // Verify the prefs have been set to match the "false" setting. + checkPrefs(false); + + // Re-enable the older extension. + enabledPromise = awaitEvent("ready"); + oldAddon.userDisabled = false; + await enabledPromise; + + // Verify the prefs have remained set to match the "false" setting. + checkPrefs(false); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js new file mode 100644 index 0000000000..54af81d951 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js @@ -0,0 +1,158 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +XPCOMUtils.defineLazyGetter(this, "Management", () => { + const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {}); + return Management; +}); + +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Preferences", + "resource://gre/modules/Preferences.jsm"); + +const { + createAppInfo, + createTempWebExtensionFile, + promiseCompleteAllInstalls, + promiseFindAddonUpdates, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +// Allow for unsigned addons. +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +add_task(async function test_privacy_update() { + // Create a object to hold the values to which we will initialize the prefs. + const PREFS = { + "network.predictor.enabled": true, + "network.prefetch-next": true, + "network.http.speculative-parallel-limit": 10, + "network.dns.disablePrefetch": false, + }; + + const EXTENSION_ID = "test_privacy_addon_update@tests.mozilla.org"; + const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity"; + + // Set prefs to our initial values. + for (let pref in PREFS) { + Preferences.set(pref, PREFS[pref]); + } + + do_register_cleanup(() => { + // Reset the prefs. + for (let pref in PREFS) { + Preferences.reset(pref); + } + }); + + async function background() { + browser.test.onMessage.addListener(async (msg, data) => { + let settingData; + switch (msg) { + case "get": + settingData = await browser.privacy.network.networkPredictionEnabled.get({}); + browser.test.sendMessage("privacyData", settingData); + break; + + case "set": + await browser.privacy.network.networkPredictionEnabled.set(data); + settingData = await browser.privacy.network.networkPredictionEnabled.get({}); + browser.test.sendMessage("privacyData", settingData); + break; + } + }); + } + + const testServer = createHttpServer(); + const port = testServer.identity.primaryPort; + + // The test extension uses an insecure update url. + Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false); + + testServer.registerPathHandler("/test_update.json", (request, response) => { + response.write(`{ + "addons": { + "${EXTENSION_ID}": { + "updates": [ + { + "version": "2.0", + "update_link": "http://localhost:${port}/addons/test_privacy-2.0.xpi" + } + ] + } + } + }`); + }); + + let webExtensionFile = createTempWebExtensionFile({ + manifest: { + version: "2.0", + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + permissions: ["privacy"], + }, + background, + }); + + testServer.registerFile("/addons/test_privacy-2.0.xpi", webExtensionFile); + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + "version": "1.0", + "applications": { + "gecko": { + "id": EXTENSION_ID, + "update_url": `http://localhost:${port}/test_update.json`, + }, + }, + permissions: ["privacy"], + }, + background, + }); + + await extension.startup(); + + // Change the value to false. + extension.sendMessage("set", {value: false}); + let data = await extension.awaitMessage("privacyData"); + ok(!data.value, "get returns expected value after setting."); + + equal(extension.version, "1.0", "The installed addon has the expected version."); + + let update = await promiseFindAddonUpdates(extension.addon); + let install = update.updateAvailable; + + await promiseCompleteAllInstalls([install]); + + await extension.awaitStartup(); + + equal(extension.version, "2.0", "The updated addon has the expected version."); + + extension.sendMessage("get"); + data = await extension.awaitMessage("privacyData"); + ok(!data.value, "get returns expected value after updating."); + + // Verify the prefs are still set to match the "false" setting. + for (let pref in PREFS) { + let msg = `${pref} set correctly.`; + let expectedValue = pref === "network.http.speculative-parallel-limit" ? 0 : !PREFS[pref]; + equal(Preferences.get(pref), expectedValue, msg); + } + + await extension.unload(); + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js new file mode 100644 index 0000000000..b120eefb68 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js @@ -0,0 +1,23 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_connect_without_listener() { + function background() { + let port = browser.runtime.connect(); + port.onDisconnect.addListener(() => { + browser.test.assertEq("Could not establish connection. Receiving end does not exist.", port.error && port.error.message); + browser.test.notifyPass("port.onDisconnect was called"); + }); + } + let extensionData = { + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("port.onDisconnect was called"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js new file mode 100644 index 0000000000..ed0dd8261f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js @@ -0,0 +1,26 @@ +/* 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/. */ +"use strict"; + +add_task(async function setup() { + ExtensionTestUtils.mockAppInfo(); +}); + +add_task(async function test_getBrowserInfo() { + async function background() { + let info = await browser.runtime.getBrowserInfo(); + + browser.test.assertEq(info.name, "XPCShell", "name is valid"); + browser.test.assertEq(info.vendor, "Mozilla", "vendor is Mozilla"); + browser.test.assertEq(info.version, "48", "version is correct"); + browser.test.assertEq(info.buildID, "20160315", "buildID is correct"); + + browser.test.notifyPass("runtime.getBrowserInfo"); + } + + const extension = ExtensionTestUtils.loadExtension({background}); + await extension.startup(); + await extension.awaitFinish("runtime.getBrowserInfo"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js new file mode 100644 index 0000000000..bec400694b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js @@ -0,0 +1,25 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + browser.runtime.getPlatformInfo(info => { + let validOSs = ["mac", "win", "android", "cros", "linux", "openbsd"]; + let validArchs = ["arm", "x86-32", "x86-64"]; + + browser.test.assertTrue(validOSs.indexOf(info.os) != -1, "OS is valid"); + browser.test.assertTrue(validArchs.indexOf(info.arch) != -1, "Architecture is valid"); + browser.test.notifyPass("runtime.getPlatformInfo"); + }); +} + +let extensionData = { + background: backgroundScript, +}; + +add_task(async function() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("runtime.getPlatformInfo"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js new file mode 100644 index 0000000000..5c8afd9a19 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js @@ -0,0 +1,357 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +XPCOMUtils.defineLazyGetter(this, "Management", () => { + const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {}); + return Management; +}); + +Cu.import("resource://gre/modules/AddonManager.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); + +const { + createAppInfo, + createTempWebExtensionFile, + promiseAddonEvent, + promiseCompleteAllInstalls, + promiseFindAddonUpdates, + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +// Allow for unsigned addons. +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +function background() { + let onInstalledDetails = null; + let onStartupFired = false; + + browser.runtime.onInstalled.addListener(details => { + onInstalledDetails = details; + }); + + browser.runtime.onStartup.addListener(() => { + onStartupFired = true; + }); + + browser.test.onMessage.addListener(message => { + if (message === "get-on-installed-details") { + onInstalledDetails = onInstalledDetails || {fired: false}; + browser.test.sendMessage("on-installed-details", onInstalledDetails); + } else if (message === "did-on-startup-fire") { + browser.test.sendMessage("on-startup-fired", onStartupFired); + } else if (message === "reload-extension") { + browser.runtime.reload(); + } + }); + + browser.runtime.onUpdateAvailable.addListener(details => { + browser.test.sendMessage("reloading"); + browser.runtime.reload(); + }); +} + +async function expectEvents(extension, {onStartupFired, onInstalledFired, onInstalledReason, onInstalledTemporary, onInstalledPrevious}) { + extension.sendMessage("get-on-installed-details"); + let details = await extension.awaitMessage("on-installed-details"); + if (onInstalledFired) { + equal(details.reason, onInstalledReason, "runtime.onInstalled fired with the correct reason"); + equal(details.temporary, onInstalledTemporary, "runtime.onInstalled fired with the correct temporary flag"); + if (onInstalledPrevious) { + equal(details.previousVersion, onInstalledPrevious, "runtime.onInstalled after update with correct previousVersion"); + } + } else { + equal(details.fired, onInstalledFired, "runtime.onInstalled should not have fired"); + } + + extension.sendMessage("did-on-startup-fire"); + let fired = await extension.awaitMessage("on-startup-fired"); + equal(fired, onStartupFired, `Expected runtime.onStartup to ${onStartupFired ? "" : "not "} fire`); +} + +add_task(async function test_should_fire_on_addon_update() { + Preferences.set("extensions.logging.enabled", false); + + await promiseStartupManager(); + + const EXTENSION_ID = "test_runtime_on_installed_addon_update@tests.mozilla.org"; + + const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity"; + + // The test extension uses an insecure update url. + Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false); + + const testServer = createHttpServer(); + const port = testServer.identity.primaryPort; + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + "version": "1.0", + "applications": { + "gecko": { + "id": EXTENSION_ID, + "update_url": `http://localhost:${port}/test_update.json`, + }, + }, + }, + background, + }); + + testServer.registerPathHandler("/test_update.json", (request, response) => { + response.write(`{ + "addons": { + "${EXTENSION_ID}": { + "updates": [ + { + "version": "2.0", + "update_link": "http://localhost:${port}/addons/test_runtime_on_installed-2.0.xpi" + } + ] + } + } + }`); + }); + + let webExtensionFile = createTempWebExtensionFile({ + manifest: { + version: "2.0", + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + testServer.registerFile("/addons/test_runtime_on_installed-2.0.xpi", webExtensionFile); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "install", + }); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + equal(addon.version, "1.0", "The installed addon has the correct version"); + + let update = await promiseFindAddonUpdates(addon); + let install = update.updateAvailable; + + let promiseInstalled = promiseAddonEvent("onInstalled"); + await promiseCompleteAllInstalls([install]); + + await extension.awaitMessage("reloading"); + + let [updated_addon] = await promiseInstalled; + equal(updated_addon.version, "2.0", "The updated addon has the correct version"); + + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "update", + onInstalledPrevious: "1.0", + }); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +add_task(async function test_should_fire_on_browser_update() { + const EXTENSION_ID = "test_runtime_on_installed_browser_update@tests.mozilla.org"; + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + "version": "1.0", + "applications": { + "gecko": { + "id": EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "install", + }); + + await promiseRestartManager("1"); + + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: false, + }); + + // Update the browser. + await promiseRestartManager("2"); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "browser_update", + }); + + // Restart the browser. + await promiseRestartManager("2"); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: false, + }); + + // Update the browser again. + await promiseRestartManager("3"); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "browser_update", + }); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +add_task(async function test_should_not_fire_on_reload() { + const EXTENSION_ID = "test_runtime_on_installed_reload@tests.mozilla.org"; + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + "version": "1.0", + "applications": { + "gecko": { + "id": EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "install", + }); + + extension.sendMessage("reload-extension"); + extension.setRestarting(); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: false, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); + +add_task(async function test_should_not_fire_on_restart() { + const EXTENSION_ID = "test_runtime_on_installed_restart@tests.mozilla.org"; + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + "version": "1.0", + "applications": { + "gecko": { + "id": EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "install", + }); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + addon.userDisabled = true; + + addon.userDisabled = false; + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: false, + }); + + await extension.markUnloaded(); + await promiseShutdownManager(); +}); + +add_task(async function test_temporary_installation() { + const EXTENSION_ID = "test_runtime_on_installed_addon_temporary@tests.mozilla.org"; + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + "version": "1.0", + "applications": { + "gecko": { + "id": EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledReason: "install", + onInstalledTemporary: true, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js new file mode 100644 index 0000000000..a4e06f16d0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js @@ -0,0 +1,111 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function tabsSendMessageReply() { + function background() { + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond(msg); + } else if (msg == "respond-soon") { + setTimeout(() => { respond(msg); }, 0); + return true; + } else if (msg == "respond-promise") { + return Promise.resolve(msg); + } else if (msg == "respond-never") { + return undefined; + } else if (msg == "respond-error") { + return Promise.reject(new Error(msg)); + } else if (msg == "throw-error") { + throw new Error(msg); + } + }); + + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond("hello"); + } else if (msg == "respond-now-2") { + respond(msg); + } + }); + + let childFrame = document.createElement("iframe"); + childFrame.src = "extensionpage.html"; + document.body.appendChild(childFrame); + } + + function senderScript() { + Promise.all([ + browser.runtime.sendMessage("respond-now"), + browser.runtime.sendMessage("respond-now-2"), + new Promise(resolve => browser.runtime.sendMessage("respond-soon", resolve)), + browser.runtime.sendMessage("respond-promise"), + browser.runtime.sendMessage("respond-never"), + new Promise(resolve => { + browser.runtime.sendMessage("respond-never", response => { resolve(response); }); + }), + + browser.runtime.sendMessage("respond-error").catch(error => Promise.resolve({error})), + browser.runtime.sendMessage("throw-error").catch(error => Promise.resolve({error})), + ]).then(([respondNow, respondNow2, respondSoon, respondPromise, respondNever, respondNever2, respondError, throwError]) => { + browser.test.assertEq("respond-now", respondNow, "Got the expected immediate response"); + browser.test.assertEq("respond-now-2", respondNow2, "Got the expected immediate response from the second listener"); + browser.test.assertEq("respond-soon", respondSoon, "Got the expected delayed response"); + browser.test.assertEq("respond-promise", respondPromise, "Got the expected promise response"); + browser.test.assertEq(undefined, respondNever, "Got the expected no-response resolution"); + browser.test.assertEq(undefined, respondNever2, "Got the expected no-response resolution"); + + browser.test.assertEq("respond-error", respondError.error.message, "Got the expected error response"); + browser.test.assertEq("throw-error", throwError.error.message, "Got the expected thrown error response"); + + browser.test.notifyPass("sendMessage"); + }).catch(e => { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("sendMessage"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + files: { + "senderScript.js": senderScript, + "extensionpage.html": ``, + }, + }); + + await extension.startup(); + await extension.awaitFinish("sendMessage"); + await extension.unload(); +}); + +add_task(async function tabsSendMessageBlob() { + function background() { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertTrue(msg.blob instanceof Blob, "Message is a blob"); + return Promise.resolve(msg); + }); + + let childFrame = document.createElement("iframe"); + childFrame.src = "extensionpage.html"; + document.body.appendChild(childFrame); + } + + function senderScript() { + browser.runtime.sendMessage({blob: new Blob(["hello"])}).then(response => { + browser.test.assertTrue(response.blob instanceof Blob, "Response is a blob"); + browser.test.notifyPass("sendBlob"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + files: { + "senderScript.js": senderScript, + "extensionpage.html": ``, + }, + }); + + await extension.startup(); + await extension.awaitFinish("sendBlob"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js new file mode 100644 index 0000000000..5134f71117 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js @@ -0,0 +1,98 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function() { + const ID1 = "sendMessage1@tests.mozilla.org"; + const ID2 = "sendMessage2@tests.mozilla.org"; + + let extension1 = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener((...args) => { + browser.runtime.sendMessage(...args); + }); + + let frame = document.createElement("iframe"); + frame.src = "page.html"; + document.body.appendChild(frame); + }, + manifest: {applications: {gecko: {id: ID1}}}, + files: { + "page.js": function() { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.sendMessage("received-page", {msg, sender}); + }); + }, + "page.html": ``, + }, + }); + + let extension2 = ExtensionTestUtils.loadExtension({ + background() { + browser.runtime.onMessageExternal.addListener((msg, sender) => { + browser.test.sendMessage("received-external", {msg, sender}); + }); + }, + manifest: {applications: {gecko: {id: ID2}}}, + }); + + await Promise.all([extension1.startup(), extension2.startup()]); + + // Check that a message was sent within extension1. + async function checkLocalMessage(msg) { + let result = await extension1.awaitMessage("received-page"); + deepEqual(result.msg, msg, "Received internal message"); + equal(result.sender.id, ID1, "Received correct sender id"); + } + + // Check that a message was sent from extension1 to extension2. + async function checkRemoteMessage(msg) { + let result = await extension2.awaitMessage("received-external"); + deepEqual(result.msg, msg, "Received cross-extension message"); + equal(result.sender.id, ID1, "Received correct sender id"); + } + + // sendMessage() takes 3 arguments: + // optional extensionID + // mandatory message + // optional options + // Due to this insane design we parse its arguments manually. This + // test is meant to cover all the combinations. + + // A single null or undefined argument is allowed, and represents the message + extension1.sendMessage(null); + await checkLocalMessage(null); + + // With one argument, it must be just the message + extension1.sendMessage("message"); + await checkLocalMessage("message"); + + // With two arguments, these cases should be treated as (extensionID, message) + extension1.sendMessage(ID2, "message"); + await checkRemoteMessage("message"); + + extension1.sendMessage(ID2, {msg: "message"}); + await checkRemoteMessage({msg: "message"}); + + // And these should be (message, options) + extension1.sendMessage("message", {}); + await checkLocalMessage("message"); + + // or (message, non-callback), pick your poison + extension1.sendMessage("message", undefined); + await checkLocalMessage("message"); + + // With three arguments, we send a cross-extension message + extension1.sendMessage(ID2, "message", {}); + await checkRemoteMessage("message"); + + // Even when the last one is null or undefined + extension1.sendMessage(ID2, "message", undefined); + await checkRemoteMessage("message"); + + // The four params case is unambigous, so we allow null as a (non-) callback + extension1.sendMessage(ID2, "message", {}, null); + await checkRemoteMessage("message"); + + await Promise.all([extension1.unload(), extension2.unload()]); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js new file mode 100644 index 0000000000..cc60ff6622 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js @@ -0,0 +1,53 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_sendMessage_error() { + async function background() { + let circ = {}; + circ.circ = circ; + let testCases = [ + // [arguments, expected error string], + [[], "runtime.sendMessage's message argument is missing"], + [[null, null, null, 42], "runtime.sendMessage's last argument is not a function"], + [[null, null, 1], "runtime.sendMessage's options argument is invalid"], + [[1, null, null], "runtime.sendMessage's extensionId argument is invalid"], + [[null, null, null, null, null], "runtime.sendMessage received too many arguments"], + + // Even when the parameters are accepted, we still expect an error + // because there is no onMessage listener. + [[null, null, null], "Could not establish connection. Receiving end does not exist."], + + // Structured cloning doesn't work with DOM objects + [[null, location, null], "The object could not be cloned."], + [[null, [circ, location], null], "The object could not be cloned."], + ]; + + // Repeat all tests with the undefined value instead of null. + for (let [args, expectedError] of testCases.slice()) { + args = args.map(arg => arg === null ? undefined : arg); + testCases.push([args, expectedError]); + } + + for (let [args, expectedError] of testCases) { + let description = `runtime.sendMessage(${args.map(String).join(", ")})`; + + await browser.test.assertRejects( + browser.runtime.sendMessage(...args), + expectedError, + `expected error message for ${description}`); + } + + browser.test.notifyPass("sendMessage parameter validation"); + } + let extensionData = { + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("sendMessage parameter validation"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js new file mode 100644 index 0000000000..0477beacf8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js @@ -0,0 +1,54 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_sendMessage_without_listener() { + async function background() { + await browser.test.assertRejects( + browser.runtime.sendMessage("msg"), + "Could not establish connection. Receiving end does not exist.", + "sendMessage callback was invoked"); + + browser.test.notifyPass("sendMessage callback was invoked"); + } + let extensionData = { + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("sendMessage callback was invoked"); + + await extension.unload(); +}); + +add_task(async function test_chrome_sendMessage_without_listener() { + function background() { + /* globals chrome */ + browser.test.assertEq(null, chrome.runtime.lastError, "no lastError before call"); + let retval = chrome.runtime.sendMessage("msg"); + browser.test.assertEq(null, chrome.runtime.lastError, "no lastError after call"); + browser.test.assertEq(undefined, retval, "return value of chrome.runtime.sendMessage without callback"); + + let isAsyncCall = false; + retval = chrome.runtime.sendMessage("msg", reply => { + browser.test.assertEq(undefined, reply, "no reply"); + browser.test.assertTrue(isAsyncCall, "chrome.runtime.sendMessage's callback must be called asynchronously"); + browser.test.assertEq(undefined, retval, "return value of chrome.runtime.sendMessage with callback"); + browser.test.assertEq("Could not establish connection. Receiving end does not exist.", chrome.runtime.lastError.message); + browser.test.notifyPass("finished chrome.runtime.sendMessage"); + }); + isAsyncCall = true; + } + let extensionData = { + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("finished chrome.runtime.sendMessage"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_self.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_self.js new file mode 100644 index 0000000000..f4b67f6621 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_self.js @@ -0,0 +1,51 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +add_task(async function test_sendMessage_to_self_should_not_trigger_onMessage() { + async function background() { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq("msg from child", msg); + browser.test.notifyPass("sendMessage did not call same-frame onMessage"); + }); + + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("sendMessage with a listener in another frame", msg); + browser.runtime.sendMessage("should only reach another frame"); + }); + + await browser.test.assertRejects( + browser.runtime.sendMessage("should not trigger same-frame onMessage"), + "Could not establish connection. Receiving end does not exist."); + + let anotherFrame = document.createElement("iframe"); + anotherFrame.src = browser.extension.getURL("extensionpage.html"); + document.body.appendChild(anotherFrame); + } + + function lastScript() { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq("should only reach another frame", msg); + browser.runtime.sendMessage("msg from child"); + }); + browser.test.sendMessage("sendMessage callback called"); + } + + let extensionData = { + background, + files: { + "lastScript.js": lastScript, + "extensionpage.html": ``, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("sendMessage callback called"); + extension.sendMessage("sendMessage with a listener in another frame"); + await extension.awaitFinish("sendMessage did not call same-frame onMessage"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js new file mode 100644 index 0000000000..0b0712dc85 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js @@ -0,0 +1,1511 @@ +"use strict"; + +Components.utils.import("resource://gre/modules/Schemas.jsm"); +Components.utils.import("resource://gre/modules/BrowserUtils.jsm"); +Components.utils.import("resource://gre/modules/ExtensionCommon.jsm"); + +let {LocalAPIImplementation, SchemaAPIInterface} = ExtensionCommon; + +const global = this; + +let json = [ + {namespace: "testing", + + properties: { + PROP1: {value: 20}, + prop2: {type: "string"}, + prop3: { + $ref: "submodule", + }, + prop4: { + $ref: "submodule", + unsupported: true, + }, + }, + + types: [ + { + id: "type1", + type: "string", + "enum": ["value1", "value2", "value3"], + }, + + { + id: "type2", + type: "object", + properties: { + prop1: {type: "integer"}, + prop2: {type: "array", items: {"$ref": "type1"}}, + }, + }, + + { + id: "basetype1", + type: "object", + properties: { + prop1: {type: "string"}, + }, + }, + + { + id: "basetype2", + choices: [ + {type: "integer"}, + ], + }, + + { + $extend: "basetype1", + properties: { + prop2: {type: "string"}, + }, + }, + + { + $extend: "basetype2", + choices: [ + {type: "string"}, + ], + }, + + { + id: "submodule", + type: "object", + functions: [ + { + name: "sub_foo", + type: "function", + parameters: [], + returns: "integer", + }, + ], + }, + ], + + functions: [ + { + name: "foo", + type: "function", + parameters: [ + {name: "arg1", type: "integer", optional: true, default: 99}, + {name: "arg2", type: "boolean", optional: true}, + ], + }, + + { + name: "bar", + type: "function", + parameters: [ + {name: "arg1", type: "integer", optional: true}, + {name: "arg2", type: "boolean"}, + ], + }, + + { + name: "baz", + type: "function", + parameters: [ + {name: "arg1", type: "object", properties: { + prop1: {type: "string"}, + prop2: {type: "integer", optional: true}, + prop3: {type: "integer", unsupported: true}, + }}, + ], + }, + + { + name: "qux", + type: "function", + parameters: [ + {name: "arg1", "$ref": "type1"}, + ], + }, + + { + name: "quack", + type: "function", + parameters: [ + {name: "arg1", "$ref": "type2"}, + ], + }, + + { + name: "quora", + type: "function", + parameters: [ + {name: "arg1", type: "function"}, + ], + }, + + { + name: "quileute", + type: "function", + parameters: [ + {name: "arg1", type: "integer", optional: true}, + {name: "arg2", type: "integer"}, + ], + }, + + { + name: "queets", + type: "function", + unsupported: true, + parameters: [], + }, + + { + name: "quintuplets", + type: "function", + parameters: [ + {name: "obj", type: "object", properties: [], additionalProperties: {type: "integer"}}, + ], + }, + + { + name: "quasar", + type: "function", + parameters: [ + {name: "abc", type: "object", properties: { + func: {type: "function", parameters: [ + {name: "x", type: "integer"}, + ]}, + }}, + ], + }, + + { + name: "quosimodo", + type: "function", + parameters: [ + {name: "xyz", type: "object", additionalProperties: {type: "any"}}, + ], + }, + + { + name: "patternprop", + type: "function", + parameters: [ + { + name: "obj", + type: "object", + properties: {"prop1": {type: "string", pattern: "^\\d+$"}}, + patternProperties: { + "(?i)^prop\\d+$": {type: "string"}, + "^foo\\d+$": {type: "string"}, + }, + }, + ], + }, + + { + name: "pattern", + type: "function", + parameters: [ + {name: "arg", type: "string", pattern: "(?i)^[0-9a-f]+$"}, + ], + }, + + { + name: "format", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + url: {type: "string", "format": "url", "optional": true}, + relativeUrl: {type: "string", "format": "relativeUrl", "optional": true}, + strictRelativeUrl: {type: "string", "format": "strictRelativeUrl", "optional": true}, + }, + }, + ], + }, + + { + name: "formatDate", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + date: {type: "string", format: "date", optional: true}, + }, + }, + ], + }, + + { + name: "deep", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + foo: { + type: "object", + properties: { + bar: { + type: "array", + items: { + type: "object", + properties: { + baz: { + type: "object", + properties: { + required: {type: "integer"}, + optional: {type: "string", optional: true}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + + { + name: "errors", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + warn: { + type: "string", + pattern: "^\\d+$", + optional: true, + onError: "warn", + }, + ignore: { + type: "string", + pattern: "^\\d+$", + optional: true, + onError: "ignore", + }, + default: { + type: "string", + pattern: "^\\d+$", + optional: true, + }, + }, + }, + ], + }, + + { + name: "localize", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + foo: {type: "string", "preprocess": "localize", "optional": true}, + bar: {type: "string", "optional": true}, + url: {type: "string", "preprocess": "localize", "format": "url", "optional": true}, + }, + }, + ], + }, + + { + name: "extended1", + type: "function", + parameters: [ + {name: "val", $ref: "basetype1"}, + ], + }, + + { + name: "extended2", + type: "function", + parameters: [ + {name: "val", $ref: "basetype2"}, + ], + }, + ], + + events: [ + { + name: "onFoo", + type: "function", + }, + + { + name: "onBar", + type: "function", + extraParameters: [{ + name: "filter", + type: "integer", + optional: true, + default: 1, + }], + }, + ], + }, + { + namespace: "foreign", + properties: { + foreignRef: {$ref: "testing.submodule"}, + }, + }, + { + namespace: "inject", + properties: { + PROP1: {value: "should inject"}, + }, + }, + { + namespace: "do-not-inject", + properties: { + PROP1: {value: "should not inject"}, + }, + }, +]; + +let tallied = null; + +function tally(kind, ns, name, args) { + tallied = [kind, ns, name, args]; +} + +function verify(...args) { + do_check_eq(JSON.stringify(tallied), JSON.stringify(args)); + tallied = null; +} + +let talliedErrors = []; + +function checkErrors(errors) { + do_check_eq(talliedErrors.length, errors.length, "Got expected number of errors"); + for (let [i, error] of errors.entries()) { + do_check_true(i in talliedErrors && String(talliedErrors[i]).includes(error), + `${JSON.stringify(error)} is a substring of error ${JSON.stringify(talliedErrors[i])}`); + } + + talliedErrors.length = 0; +} + +let permissions = new Set(); + +class TallyingAPIImplementation extends SchemaAPIInterface { + constructor(namespace, name) { + super(); + this.namespace = namespace; + this.name = name; + } + + callFunction(args) { + tally("call", this.namespace, this.name, args); + } + + callFunctionNoReturn(args) { + tally("call", this.namespace, this.name, args); + } + + getProperty() { + tally("get", this.namespace, this.name); + } + + setProperty(value) { + tally("set", this.namespace, this.name, value); + } + + addListener(listener, args) { + tally("addListener", this.namespace, this.name, [listener, args]); + } + + removeListener(listener) { + tally("removeListener", this.namespace, this.name, [listener]); + } + + hasListener(listener) { + tally("hasListener", this.namespace, this.name, [listener]); + } +} + +let wrapper = { + url: "moz-extension://b66e3509-cdb3-44f6-8eb8-c8b39b3a1d27/", + + cloneScope: global, + + checkLoadURL(url) { + return !url.startsWith("chrome:"); + }, + + preprocessors: { + localize(value, context) { + return value.replace(/__MSG_(.*?)__/g, (m0, m1) => `${m1.toUpperCase()}`); + }, + }, + + logError(message) { + talliedErrors.push(message); + }, + + hasPermission(permission) { + return permissions.has(permission); + }, + + shouldInject(ns, name) { + return name != "do-not-inject"; + }, + + getImplementation(namespace, name) { + return new TallyingAPIImplementation(namespace, name); + }, +}; + +add_task(async function() { + let url = "data:," + JSON.stringify(json); + await Schemas.load(url); + + let root = {}; + tallied = null; + Schemas.inject(root, wrapper); + do_check_eq(tallied, null); + + do_check_eq(root.testing.PROP1, 20, "simple value property"); + do_check_eq(root.testing.type1.VALUE1, "value1", "enum type"); + do_check_eq(root.testing.type1.VALUE2, "value2", "enum type"); + + do_check_eq("inject" in root, true, "namespace 'inject' should be injected"); + do_check_eq(root["do-not-inject"], undefined, "namespace 'do-not-inject' should not be injected"); + + root.testing.foo(11, true); + verify("call", "testing", "foo", [11, true]); + + root.testing.foo(true); + verify("call", "testing", "foo", [99, true]); + + root.testing.foo(null, true); + verify("call", "testing", "foo", [99, true]); + + root.testing.foo(undefined, true); + verify("call", "testing", "foo", [99, true]); + + root.testing.foo(11); + verify("call", "testing", "foo", [11, null]); + + Assert.throws(() => root.testing.bar(11), + /Incorrect argument types/, + "should throw without required arg"); + + Assert.throws(() => root.testing.bar(11, true, 10), + /Incorrect argument types/, + "should throw with too many arguments"); + + root.testing.bar(true); + verify("call", "testing", "bar", [null, true]); + + root.testing.baz({prop1: "hello", prop2: 22}); + verify("call", "testing", "baz", [{prop1: "hello", prop2: 22}]); + + root.testing.baz({prop1: "hello"}); + verify("call", "testing", "baz", [{prop1: "hello", prop2: null}]); + + root.testing.baz({prop1: "hello", prop2: null}); + verify("call", "testing", "baz", [{prop1: "hello", prop2: null}]); + + Assert.throws(() => root.testing.baz({prop2: 12}), + /Property "prop1" is required/, + "should throw without required property"); + + Assert.throws(() => root.testing.baz({prop1: "hi", prop3: 12}), + /Property "prop3" is unsupported by Firefox/, + "should throw with unsupported property"); + + Assert.throws(() => root.testing.baz({prop1: "hi", prop4: 12}), + /Unexpected property "prop4"/, + "should throw with unexpected property"); + + Assert.throws(() => root.testing.baz({prop1: 12}), + /Expected string instead of 12/, + "should throw with wrong type"); + + root.testing.qux("value2"); + verify("call", "testing", "qux", ["value2"]); + + Assert.throws(() => root.testing.qux("value4"), + /Invalid enumeration value "value4"/, + "should throw for invalid enum value"); + + root.testing.quack({prop1: 12, prop2: ["value1", "value3"]}); + verify("call", "testing", "quack", [{prop1: 12, prop2: ["value1", "value3"]}]); + + Assert.throws(() => root.testing.quack({prop1: 12, prop2: ["value1", "value3", "value4"]}), + /Invalid enumeration value "value4"/, + "should throw for invalid array type"); + + function f() {} + root.testing.quora(f); + do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["call", "testing", "quora"])); + do_check_eq(tallied[3][0], f); + tallied = null; + + let g = () => 0; + root.testing.quora(g); + do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["call", "testing", "quora"])); + do_check_eq(tallied[3][0], g); + tallied = null; + + root.testing.quileute(10); + verify("call", "testing", "quileute", [null, 10]); + + Assert.throws(() => root.testing.queets(), + /queets is not a function/, + "should throw for unsupported functions"); + + root.testing.quintuplets({a: 10, b: 20, c: 30}); + verify("call", "testing", "quintuplets", [{a: 10, b: 20, c: 30}]); + + Assert.throws(() => root.testing.quintuplets({a: 10, b: 20, c: 30, d: "hi"}), + /Expected integer instead of "hi"/, + "should throw for wrong additionalProperties type"); + + root.testing.quasar({func: f}); + do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["call", "testing", "quasar"])); + do_check_eq(tallied[3][0].func, f); + tallied = null; + + root.testing.quosimodo({a: 10, b: 20, c: 30}); + verify("call", "testing", "quosimodo", [{a: 10, b: 20, c: 30}]); + tallied = null; + + Assert.throws(() => root.testing.quosimodo(10), + /Incorrect argument types/, + "should throw for wrong type"); + + root.testing.patternprop({prop1: "12", prop2: "42", Prop3: "43", foo1: "x"}); + verify("call", "testing", "patternprop", [{prop1: "12", prop2: "42", Prop3: "43", foo1: "x"}]); + tallied = null; + + root.testing.patternprop({prop1: "12"}); + verify("call", "testing", "patternprop", [{prop1: "12"}]); + tallied = null; + + Assert.throws(() => root.testing.patternprop({prop1: "12", foo1: null}), + /Expected string instead of null/, + "should throw for wrong property type"); + + Assert.throws(() => root.testing.patternprop({prop1: "xx", prop2: "yy"}), + /String "xx" must match \/\^\\d\+\$\//, + "should throw for wrong property type"); + + Assert.throws(() => root.testing.patternprop({prop1: "12", prop2: 42}), + /Expected string instead of 42/, + "should throw for wrong property type"); + + Assert.throws(() => root.testing.patternprop({prop1: "12", prop2: null}), + /Expected string instead of null/, + "should throw for wrong property type"); + + Assert.throws(() => root.testing.patternprop({prop1: "12", propx: "42"}), + /Unexpected property "propx"/, + "should throw for unexpected property"); + + Assert.throws(() => root.testing.patternprop({prop1: "12", Foo1: "x"}), + /Unexpected property "Foo1"/, + "should throw for unexpected property"); + + root.testing.pattern("DEADbeef"); + verify("call", "testing", "pattern", ["DEADbeef"]); + tallied = null; + + Assert.throws(() => root.testing.pattern("DEADcow"), + /String "DEADcow" must match \/\^\[0-9a-f\]\+\$\/i/, + "should throw for non-match"); + + root.testing.format({url: "http://foo/bar", + relativeUrl: "http://foo/bar"}); + verify("call", "testing", "format", [{url: "http://foo/bar", + relativeUrl: "http://foo/bar", + strictRelativeUrl: null}]); + tallied = null; + + root.testing.format({relativeUrl: "foo.html", strictRelativeUrl: "foo.html"}); + verify("call", "testing", "format", [{url: null, + relativeUrl: `${wrapper.url}foo.html`, + strictRelativeUrl: `${wrapper.url}foo.html`}]); + tallied = null; + + for (let format of ["url", "relativeUrl"]) { + Assert.throws(() => root.testing.format({[format]: "chrome://foo/content/"}), + /Access denied/, + "should throw for access denied"); + } + + for (let urlString of ["//foo.html", "http://foo/bar.html"]) { + Assert.throws(() => root.testing.format({strictRelativeUrl: urlString}), + /must be a relative URL/, + "should throw for non-relative URL"); + } + + const dates = [ + "2016-03-04", + "2016-03-04T08:00:00Z", + "2016-03-04T08:00:00.000Z", + "2016-03-04T08:00:00-08:00", + "2016-03-04T08:00:00.000-08:00", + "2016-03-04T08:00:00+08:00", + "2016-03-04T08:00:00.000+08:00", + "2016-03-04T08:00:00+0800", + "2016-03-04T08:00:00-0800", + ]; + dates.forEach(str => { + root.testing.formatDate({date: str}); + verify("call", "testing", "formatDate", [{date: str}]); + }); + + // Make sure that a trivial change to a valid date invalidates it. + dates.forEach(str => { + Assert.throws(() => root.testing.formatDate({date: "0" + str}), + /Invalid date string/, + "should throw for invalid iso date string"); + Assert.throws(() => root.testing.formatDate({date: str + "0"}), + /Invalid date string/, + "should throw for invalid iso date string"); + }); + + const badDates = [ + "I do not look anything like a date string", + "2016-99-99", + "2016-03-04T25:00:00Z", + ]; + badDates.forEach(str => { + Assert.throws(() => root.testing.formatDate({date: str}), + /Invalid date string/, + "should throw for invalid iso date string"); + }); + + root.testing.deep({foo: {bar: [{baz: {required: 12, optional: "42"}}]}}); + verify("call", "testing", "deep", [{foo: {bar: [{baz: {required: 12, optional: "42"}}]}}]); + tallied = null; + + Assert.throws(() => root.testing.deep({foo: {bar: [{baz: {optional: "42"}}]}}), + /Type error for parameter arg \(Error processing foo\.bar\.0\.baz: Property "required" is required\) for testing\.deep/, + "should throw with the correct object path"); + + Assert.throws(() => root.testing.deep({foo: {bar: [{baz: {required: 12, optional: 42}}]}}), + /Type error for parameter arg \(Error processing foo\.bar\.0\.baz\.optional: Expected string instead of 42\) for testing\.deep/, + "should throw with the correct object path"); + + + talliedErrors.length = 0; + + root.testing.errors({warn: "0123", ignore: "0123", default: "0123"}); + verify("call", "testing", "errors", [{warn: "0123", ignore: "0123", default: "0123"}]); + checkErrors([]); + + root.testing.errors({warn: "0123", ignore: "x123", default: "0123"}); + verify("call", "testing", "errors", [{warn: "0123", ignore: null, default: "0123"}]); + checkErrors([]); + + root.testing.errors({warn: "x123", ignore: "0123", default: "0123"}); + verify("call", "testing", "errors", [{warn: null, ignore: "0123", default: "0123"}]); + checkErrors([ + 'String "x123" must match /^\\d+$/', + ]); + + + root.testing.onFoo.addListener(f); + do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["addListener", "testing", "onFoo"])); + do_check_eq(tallied[3][0], f); + do_check_eq(JSON.stringify(tallied[3][1]), JSON.stringify([])); + tallied = null; + + root.testing.onFoo.removeListener(f); + do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["removeListener", "testing", "onFoo"])); + do_check_eq(tallied[3][0], f); + tallied = null; + + root.testing.onFoo.hasListener(f); + do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["hasListener", "testing", "onFoo"])); + do_check_eq(tallied[3][0], f); + tallied = null; + + Assert.throws(() => root.testing.onFoo.addListener(10), + /Invalid listener/, + "addListener with non-function should throw"); + + root.testing.onBar.addListener(f, 10); + do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["addListener", "testing", "onBar"])); + do_check_eq(tallied[3][0], f); + do_check_eq(JSON.stringify(tallied[3][1]), JSON.stringify([10])); + tallied = null; + + root.testing.onBar.addListener(f); + do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["addListener", "testing", "onBar"])); + do_check_eq(tallied[3][0], f); + do_check_eq(JSON.stringify(tallied[3][1]), JSON.stringify([1])); + tallied = null; + + Assert.throws(() => root.testing.onBar.addListener(f, "hi"), + /Incorrect argument types/, + "addListener with wrong extra parameter should throw"); + + let target = {prop1: 12, prop2: ["value1", "value3"]}; + let proxy = new Proxy(target, {}); + Assert.throws(() => root.testing.quack(proxy), + /Expected a plain JavaScript object, got a Proxy/, + "should throw when passing a Proxy"); + + if (Symbol.toStringTag) { + let stringTarget = {prop1: 12, prop2: ["value1", "value3"]}; + stringTarget[Symbol.toStringTag] = () => "[object Object]"; + let stringProxy = new Proxy(stringTarget, {}); + Assert.throws(() => root.testing.quack(stringProxy), + /Expected a plain JavaScript object, got a Proxy/, + "should throw when passing a Proxy"); + } + + + root.testing.localize({foo: "__MSG_foo__", bar: "__MSG_foo__", url: "__MSG_http://example.com/__"}); + verify("call", "testing", "localize", [{foo: "FOO", bar: "__MSG_foo__", url: "http://example.com/"}]); + tallied = null; + + + Assert.throws(() => root.testing.localize({url: "__MSG_/foo/bar__"}), + /\/FOO\/BAR is not a valid URL\./, + "should throw for invalid URL"); + + + root.testing.extended1({prop1: "foo", prop2: "bar"}); + verify("call", "testing", "extended1", [{prop1: "foo", prop2: "bar"}]); + tallied = null; + + Assert.throws(() => root.testing.extended1({prop1: "foo", prop2: 12}), + /Expected string instead of 12/, + "should throw for wrong property type"); + + Assert.throws(() => root.testing.extended1({prop1: "foo"}), + /Property "prop2" is required/, + "should throw for missing property"); + + Assert.throws(() => root.testing.extended1({prop1: "foo", prop2: "bar", prop3: "xxx"}), + /Unexpected property "prop3"/, + "should throw for extra property"); + + + root.testing.extended2("foo"); + verify("call", "testing", "extended2", ["foo"]); + tallied = null; + + root.testing.extended2(12); + verify("call", "testing", "extended2", [12]); + tallied = null; + + Assert.throws(() => root.testing.extended2(true), + /Incorrect argument types/, + "should throw for wrong argument type"); + + root.testing.prop3.sub_foo(); + verify("call", "testing.prop3", "sub_foo", []); + tallied = null; + + Assert.throws(() => root.testing.prop4.sub_foo(), + /root.testing.prop4 is undefined/, + "should throw for unsupported submodule"); + + root.foreign.foreignRef.sub_foo(); + verify("call", "foreign.foreignRef", "sub_foo", []); + tallied = null; +}); + +let deprecatedJson = [ + {namespace: "deprecated", + + properties: { + accessor: { + type: "string", + writable: true, + deprecated: "This is not the property you are looking for", + }, + }, + + types: [ + { + "id": "Type", + "type": "string", + }, + ], + + functions: [ + { + name: "property", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + foo: { + type: "string", + }, + }, + additionalProperties: { + type: "any", + deprecated: "Unknown property", + }, + }, + ], + }, + + { + name: "value", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + type: "integer", + }, + { + type: "string", + deprecated: "Please use an integer, not ${value}", + }, + ], + }, + ], + }, + + { + name: "choices", + type: "function", + parameters: [ + { + name: "arg", + deprecated: "You have no choices", + choices: [ + { + type: "integer", + }, + ], + }, + ], + }, + + { + name: "ref", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + $ref: "Type", + deprecated: "Deprecated alias", + }, + ], + }, + ], + }, + + { + name: "method", + type: "function", + deprecated: "Do not call this method", + parameters: [ + ], + }, + ], + + events: [ + { + name: "onDeprecated", + type: "function", + deprecated: "This event does not work", + }, + ], + }, +]; + +add_task(async function testDeprecation() { + let url = "data:," + JSON.stringify(deprecatedJson); + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + talliedErrors.length = 0; + + + root.deprecated.property({foo: "bar", xxx: "any", yyy: "property"}); + verify("call", "deprecated", "property", [{foo: "bar", xxx: "any", yyy: "property"}]); + checkErrors([ + "Error processing xxx: Unknown property", + "Error processing yyy: Unknown property", + ]); + + root.deprecated.value(12); + verify("call", "deprecated", "value", [12]); + checkErrors([]); + + root.deprecated.value("12"); + verify("call", "deprecated", "value", ["12"]); + checkErrors(["Please use an integer, not \"12\""]); + + root.deprecated.choices(12); + verify("call", "deprecated", "choices", [12]); + checkErrors(["You have no choices"]); + + root.deprecated.ref("12"); + verify("call", "deprecated", "ref", ["12"]); + checkErrors(["Deprecated alias"]); + + root.deprecated.method(); + verify("call", "deprecated", "method", []); + checkErrors(["Do not call this method"]); + + + void root.deprecated.accessor; + verify("get", "deprecated", "accessor", null); + checkErrors(["This is not the property you are looking for"]); + + root.deprecated.accessor = "x"; + verify("set", "deprecated", "accessor", "x"); + checkErrors(["This is not the property you are looking for"]); + + + root.deprecated.onDeprecated.addListener(() => {}); + checkErrors(["This event does not work"]); + + root.deprecated.onDeprecated.removeListener(() => {}); + checkErrors(["This event does not work"]); + + root.deprecated.onDeprecated.hasListener(() => {}); + checkErrors(["This event does not work"]); +}); + + +let choicesJson = [ + {namespace: "choices", + + types: [ + ], + + functions: [ + { + name: "meh", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + type: "string", + enum: ["foo", "bar", "baz"], + }, + { + type: "string", + pattern: "florg.*meh", + }, + { + type: "integer", + minimum: 12, + maximum: 42, + }, + ], + }, + ], + }, + + { + name: "foo", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + type: "object", + properties: { + blurg: { + type: "string", + unsupported: true, + optional: true, + }, + }, + additionalProperties: { + type: "string", + }, + }, + { + type: "string", + }, + { + type: "array", + minItems: 2, + maxItems: 3, + items: { + type: "integer", + }, + }, + ], + }, + ], + }, + + { + name: "bar", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + type: "object", + properties: { + baz: { + type: "string", + }, + }, + }, + { + type: "array", + items: { + type: "integer", + }, + }, + ], + }, + ], + }, + ]}, +]; + +add_task(async function testChoices() { + let url = "data:," + JSON.stringify(choicesJson); + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + talliedErrors.length = 0; + + Assert.throws(() => root.choices.meh("frog"), + /Value "frog" must either: be one of \["foo", "bar", "baz"\], match the pattern \/florg\.\*meh\/, or be an integer value/); + + Assert.throws(() => root.choices.meh(4), + /be a string value, or be at least 12/); + + Assert.throws(() => root.choices.meh(43), + /be a string value, or be no greater than 42/); + + + Assert.throws(() => root.choices.foo([]), + /be an object value, be a string value, or have at least 2 items/); + + Assert.throws(() => root.choices.foo([1, 2, 3, 4]), + /be an object value, be a string value, or have at most 3 items/); + + Assert.throws(() => root.choices.foo({foo: 12}), + /.foo must be a string value, be a string value, or be an array value/); + + Assert.throws(() => root.choices.foo({blurg: "foo"}), + /not contain an unsupported "blurg" property, be a string value, or be an array value/); + + + Assert.throws(() => root.choices.bar({}), + /contain the required "baz" property, or be an array value/); + + Assert.throws(() => root.choices.bar({baz: "x", quux: "y"}), + /not contain an unexpected "quux" property, or be an array value/); + + Assert.throws(() => root.choices.bar({baz: "x", quux: "y", foo: "z"}), + /not contain the unexpected properties \[foo, quux\], or be an array value/); +}); + + +let permissionsJson = [ + {namespace: "noPerms", + + types: [], + + functions: [ + { + name: "noPerms", + type: "function", + parameters: [], + }, + + { + name: "fooPerm", + type: "function", + permissions: ["foo"], + parameters: [], + }, + ]}, + + {namespace: "fooPerm", + + permissions: ["foo"], + + types: [], + + functions: [ + { + name: "noPerms", + type: "function", + parameters: [], + }, + + { + name: "fooBarPerm", + type: "function", + permissions: ["foo.bar"], + parameters: [], + }, + ]}, +]; + +add_task(async function testPermissions() { + let url = "data:," + JSON.stringify(permissionsJson); + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + equal(typeof root.noPerms, "object", "noPerms namespace should exist"); + equal(typeof root.noPerms.noPerms, "function", "noPerms.noPerms method should exist"); + + equal(root.noPerms.fooPerm, undefined, "noPerms.fooPerm should not method exist"); + + equal(root.fooPerm, undefined, "fooPerm namespace should not exist"); + + + do_print('Add "foo" permission'); + permissions.add("foo"); + + root = {}; + Schemas.inject(root, wrapper); + + equal(typeof root.noPerms, "object", "noPerms namespace should exist"); + equal(typeof root.noPerms.noPerms, "function", "noPerms.noPerms method should exist"); + equal(typeof root.noPerms.fooPerm, "function", "noPerms.fooPerm method should exist"); + + equal(typeof root.fooPerm, "object", "fooPerm namespace should exist"); + equal(typeof root.fooPerm.noPerms, "function", "noPerms.noPerms method should exist"); + + equal(root.fooPerm.fooBarPerm, undefined, "fooPerm.fooBarPerm method should not exist"); + + + do_print('Add "foo.bar" permission'); + permissions.add("foo.bar"); + + root = {}; + Schemas.inject(root, wrapper); + + equal(typeof root.noPerms, "object", "noPerms namespace should exist"); + equal(typeof root.noPerms.noPerms, "function", "noPerms.noPerms method should exist"); + equal(typeof root.noPerms.fooPerm, "function", "noPerms.fooPerm method should exist"); + + equal(typeof root.fooPerm, "object", "fooPerm namespace should exist"); + equal(typeof root.fooPerm.noPerms, "function", "noPerms.noPerms method should exist"); + equal(typeof root.fooPerm.fooBarPerm, "function", "noPerms.fooBarPerm method should exist"); +}); + +let nestedNamespaceJson = [ + { + "namespace": "nested.namespace", + "types": [ + { + "id": "CustomType", + "type": "object", + "events": [ + { + "name": "onEvent", + }, + ], + "properties": { + "url": { + "type": "string", + }, + }, + "functions": [ + { + "name": "functionOnCustomType", + "type": "function", + "parameters": [ + { + "name": "title", + "type": "string", + }, + ], + }, + ], + }, + ], + "properties": { + "instanceOfCustomType": { + "$ref": "CustomType", + }, + }, + "functions": [ + { + "name": "create", + "type": "function", + "parameters": [ + { + "name": "title", + "type": "string", + }, + ], + }, + ], + }, +]; + +add_task(async function testNestedNamespace() { + let url = "data:," + JSON.stringify(nestedNamespaceJson); + + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + talliedErrors.length = 0; + + ok(root.nested, "The root object contains the first namespace level"); + ok(root.nested.namespace, "The first level object contains the second namespace level"); + + ok(root.nested.namespace.create, "Got the expected function in the nested namespace"); + do_check_eq(typeof root.nested.namespace.create, "function", + "The property is a function as expected"); + + let {instanceOfCustomType} = root.nested.namespace; + + ok(instanceOfCustomType, + "Got the expected instance of the CustomType defined in the schema"); + ok(instanceOfCustomType.functionOnCustomType, + "Got the expected method in the CustomType instance"); + + // TODO: test support events and properties in a SubModuleType defined in the schema, + // once implemented, e.g.: + // + // ok(instanceOfCustomType.url, + // "Got the expected property defined in the CustomType instance) + // + // ok(instanceOfCustomType.onEvent && + // instanceOfCustomType.onEvent.addListener && + // typeof instanceOfCustomType.onEvent.addListener == "function", + // "Got the expected event defined in the CustomType instance"); +}); + +let $importJson = [ + { + namespace: "from_the", + $import: "future", + }, + { + namespace: "future", + properties: { + PROP1: {value: "original value"}, + PROP2: {value: "second original"}, + }, + types: [ + { + id: "Colour", + type: "string", + enum: ["red", "white", "blue"], + }, + ], + functions: [ + { + name: "dye", + type: "function", + parameters: [ + {name: "arg", $ref: "Colour"}, + ], + }, + ], + }, + { + namespace: "embrace", + $import: "future", + properties: { + PROP2: {value: "overridden value"}, + }, + types: [ + { + id: "Colour", + type: "string", + enum: ["blue", "orange"], + }, + ], + }, +]; + +add_task(async function test_$import() { + let url = "data:," + JSON.stringify($importJson); + await Schemas.load(url); + + let root = {}; + tallied = null; + Schemas.inject(root, wrapper); + equal(tallied, null); + + equal(root.from_the.PROP1, "original value", "imported property"); + equal(root.from_the.PROP2, "second original", "second imported property"); + equal(root.from_the.Colour.RED, "red", "imported enum type"); + equal(typeof root.from_the.dye, "function", "imported function"); + + root.from_the.dye("white"); + verify("call", "from_the", "dye", ["white"]); + + Assert.throws(() => root.from_the.dye("orange"), + /Invalid enumeration value/, + "original imported argument type Colour doesn't include 'orange'"); + + equal(root.embrace.PROP1, "original value", "imported property"); + equal(root.embrace.PROP2, "overridden value", "overridden property"); + equal(root.embrace.Colour.ORANGE, "orange", "overridden enum type"); + equal(typeof root.embrace.dye, "function", "imported function"); + + root.embrace.dye("orange"); + verify("call", "embrace", "dye", ["orange"]); + + Assert.throws(() => root.embrace.dye("white"), + /Invalid enumeration value/, + "overridden argument type Colour doesn't include 'white'"); +}); + +add_task(async function testLocalAPIImplementation() { + let countGet2 = 0; + let countProp3 = 0; + let countProp3SubFoo = 0; + + let testingApiObj = { + get PROP1() { + // PROP1 is a schema-defined constant. + throw new Error("Unexpected get PROP1"); + }, + get prop2() { + ++countGet2; + return "prop2 val"; + }, + get prop3() { + throw new Error("Unexpected get prop3"); + }, + set prop3(v) { + // prop3 is a submodule, defined as a function, so the API should not pass + // through assignment to prop3. + throw new Error("Unexpected set prop3"); + }, + }; + let submoduleApiObj = { + get sub_foo() { + ++countProp3; + return () => { + return ++countProp3SubFoo; + }; + }, + }; + + let localWrapper = { + cloneScope: global, + shouldInject(ns, name) { + return name == "testing" || ns == "testing" || ns == "testing.prop3"; + }, + getImplementation(ns, name) { + do_check_true(ns == "testing" || ns == "testing.prop3"); + if (ns == "testing.prop3" && name == "sub_foo") { + // It is fine to use `null` here because we don't call async functions. + return new LocalAPIImplementation(submoduleApiObj, name, null); + } + // It is fine to use `null` here because we don't call async functions. + return new LocalAPIImplementation(testingApiObj, name, null); + }, + }; + + let root = {}; + Schemas.inject(root, localWrapper); + do_check_eq(countGet2, 0); + do_check_eq(countProp3, 0); + do_check_eq(countProp3SubFoo, 0); + + do_check_eq(root.testing.PROP1, 20); + + do_check_eq(root.testing.prop2, "prop2 val"); + do_check_eq(countGet2, 1); + + do_check_eq(root.testing.prop2, "prop2 val"); + do_check_eq(countGet2, 2); + + do_print(JSON.stringify(root.testing)); + do_check_eq(root.testing.prop3.sub_foo(), 1); + do_check_eq(countProp3, 1); + do_check_eq(countProp3SubFoo, 1); + + do_check_eq(root.testing.prop3.sub_foo(), 2); + do_check_eq(countProp3, 2); + do_check_eq(countProp3SubFoo, 2); + + root.testing.prop3.sub_foo = () => { return "overwritten"; }; + do_check_eq(root.testing.prop3.sub_foo(), "overwritten"); + + root.testing.prop3 = {sub_foo() { return "overwritten again"; }}; + do_check_eq(root.testing.prop3.sub_foo(), "overwritten again"); + do_check_eq(countProp3SubFoo, 2); +}); + + +let defaultsJson = [ + {namespace: "defaultsJson", + + types: [], + + functions: [ + { + name: "defaultFoo", + type: "function", + parameters: [ + {name: "arg", type: "object", optional: true, properties: { + prop1: {type: "integer", optional: true}, + }, default: {prop1: 1}}, + ], + returns: { + type: "object", + }, + }, + ]}, +]; + +add_task(async function testDefaults() { + let url = "data:," + JSON.stringify(defaultsJson); + await Schemas.load(url); + + let testingApiObj = { + defaultFoo: function(arg) { + if (Object.keys(arg) != "prop1") { + throw new Error(`Received the expected default object, default: ${JSON.stringify(arg)}`); + } + arg.newProp = 1; + return arg; + }, + }; + + let localWrapper = { + cloneScope: global, + shouldInject(ns) { + return true; + }, + getImplementation(ns, name) { + return new LocalAPIImplementation(testingApiObj, name, null); + }, + }; + + let root = {}; + Schemas.inject(root, localWrapper); + + deepEqual(root.defaultsJson.defaultFoo(), {prop1: 1, newProp: 1}); + deepEqual(root.defaultsJson.defaultFoo({prop1: 2}), {prop1: 2, newProp: 1}); + deepEqual(root.defaultsJson.defaultFoo(), {prop1: 1, newProp: 1}); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js new file mode 100644 index 0000000000..0df547588a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js @@ -0,0 +1,151 @@ +"use strict"; + +Components.utils.import("resource://gre/modules/Schemas.jsm"); + +const global = this; + +let schemaJson = [ + { + namespace: "noAllowedContexts", + properties: { + prop1: {type: "object"}, + prop2: {type: "object", allowedContexts: ["test_zero", "test_one"]}, + prop3: {type: "number", value: 1}, + prop4: {type: "number", value: 1, allowedContexts: ["numeric_one"]}, + }, + }, + { + namespace: "defaultContexts", + defaultContexts: ["test_two"], + properties: { + prop1: {type: "object"}, + prop2: {type: "object", allowedContexts: ["test_three"]}, + prop3: {type: "number", value: 1}, + prop4: {type: "number", value: 1, allowedContexts: ["numeric_two"]}, + }, + }, + { + namespace: "withAllowedContexts", + allowedContexts: ["test_four"], + properties: { + prop1: {type: "object"}, + prop2: {type: "object", allowedContexts: ["test_five"]}, + prop3: {type: "number", value: 1}, + prop4: {type: "number", value: 1, allowedContexts: ["numeric_three"]}, + }, + }, + { + namespace: "withAllowedContextsAndDefault", + allowedContexts: ["test_six"], + defaultContexts: ["test_seven"], + properties: { + prop1: {type: "object"}, + prop2: {type: "object", allowedContexts: ["test_eight"]}, + prop3: {type: "number", value: 1}, + prop4: {type: "number", value: 1, allowedContexts: ["numeric_four"]}, + }, + }, + { + namespace: "with_submodule", + defaultContexts: ["test_nine"], + types: [{ + id: "subtype", + type: "object", + functions: [{ + name: "noAllowedContexts", + type: "function", + parameters: [], + }, { + name: "allowedContexts", + allowedContexts: ["test_ten"], + type: "function", + parameters: [], + }], + }], + properties: { + prop1: {$ref: "subtype"}, + prop2: {$ref: "subtype", allowedContexts: ["test_eleven"]}, + }, + }, +]; + +add_task(async function testRestrictions() { + let url = "data:," + JSON.stringify(schemaJson); + await Schemas.load(url); + let results = {}; + let localWrapper = { + cloneScope: global, + shouldInject(ns, name, allowedContexts) { + name = ns ? ns + "." + name : name; + results[name] = allowedContexts.join(","); + return true; + }, + getImplementation() { + // The actual implementation is not significant for this test. + // Let's take this opportunity to see if schema generation is free of + // exceptions even when somehow getImplementation does not return an + // implementation. + }, + }; + + let root = {}; + Schemas.inject(root, localWrapper); + + function verify(path, expected) { + let obj = root; + for (let thing of path.split(".")) { + try { + obj = obj[thing]; + } catch (e) { + // Blech. + } + } + + let result = results[path]; + equal(result, expected, path); + } + + verify("noAllowedContexts", ""); + verify("noAllowedContexts.prop1", ""); + verify("noAllowedContexts.prop2", "test_zero,test_one"); + verify("noAllowedContexts.prop3", ""); + verify("noAllowedContexts.prop4", "numeric_one"); + + verify("defaultContexts", ""); + verify("defaultContexts.prop1", "test_two"); + verify("defaultContexts.prop2", "test_three"); + verify("defaultContexts.prop3", "test_two"); + verify("defaultContexts.prop4", "numeric_two"); + + verify("withAllowedContexts", "test_four"); + verify("withAllowedContexts.prop1", ""); + verify("withAllowedContexts.prop2", "test_five"); + verify("withAllowedContexts.prop3", ""); + verify("withAllowedContexts.prop4", "numeric_three"); + + verify("withAllowedContextsAndDefault", "test_six"); + verify("withAllowedContextsAndDefault.prop1", "test_seven"); + verify("withAllowedContextsAndDefault.prop2", "test_eight"); + verify("withAllowedContextsAndDefault.prop3", "test_seven"); + verify("withAllowedContextsAndDefault.prop4", "numeric_four"); + + verify("with_submodule", ""); + verify("with_submodule.prop1", "test_nine"); + verify("with_submodule.prop1.noAllowedContexts", "test_nine"); + verify("with_submodule.prop1.allowedContexts", "test_ten"); + verify("with_submodule.prop2", "test_eleven"); + // Note: test_nine inherits allowed contexts from the namespace, not from + // submodule. There is no "defaultContexts" for submodule types to not + // complicate things. + verify("with_submodule.prop1.noAllowedContexts", "test_nine"); + verify("with_submodule.prop1.allowedContexts", "test_ten"); + + // This is a constant, so it does not matter that getImplementation does not + // return an implementation since the API injector should take care of it. + equal(root.noAllowedContexts.prop3, 1); + + Assert.throws(() => root.noAllowedContexts.prop1, + /undefined/, + "Should throw when the implementation is absent."); +}); + diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js new file mode 100644 index 0000000000..ec1f4880c7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js @@ -0,0 +1,233 @@ +"use strict"; + +Components.utils.import("resource://gre/modules/ExtensionCommon.jsm"); +Components.utils.import("resource://gre/modules/Schemas.jsm"); + +let {BaseContext, LocalAPIImplementation} = ExtensionCommon; + +let schemaJson = [ + { + namespace: "testnamespace", + functions: [{ + name: "one_required", + type: "function", + parameters: [{ + name: "first", + type: "function", + parameters: [], + }], + }, { + name: "one_optional", + type: "function", + parameters: [{ + name: "first", + type: "function", + parameters: [], + optional: true, + }], + }, { + name: "async_required", + type: "function", + async: "first", + parameters: [{ + name: "first", + type: "function", + parameters: [], + }], + }, { + name: "async_optional", + type: "function", + async: "first", + parameters: [{ + name: "first", + type: "function", + parameters: [], + optional: true, + }], + }], + }, +]; + +const global = this; +class StubContext extends BaseContext { + constructor() { + let fakeExtension = {id: "test@web.extension"}; + super("testEnv", fakeExtension); + this.sandbox = Cu.Sandbox(global); + } + + get cloneScope() { + return this.sandbox; + } + + get principal() { + return Cu.getObjectPrincipal(this.sandbox); + } +} + +let context; + +function generateAPIs(extraWrapper, apiObj) { + context = new StubContext(); + let localWrapper = { + cloneScope: global, + shouldInject() { + return true; + }, + getImplementation(namespace, name) { + return new LocalAPIImplementation(apiObj, name, context); + }, + }; + Object.assign(localWrapper, extraWrapper); + + let root = {}; + Schemas.inject(root, localWrapper); + return root.testnamespace; +} + +add_task(async function testParameterValidation() { + await Schemas.load("data:," + JSON.stringify(schemaJson)); + + let testnamespace; + function assertThrows(name, ...args) { + Assert.throws(() => testnamespace[name](...args), + /Incorrect argument types/, + `Expected testnamespace.${name}(${args.map(String).join(", ")}) to throw.`); + } + function assertNoThrows(name, ...args) { + try { + testnamespace[name](...args); + } catch (e) { + do_print(`testnamespace.${name}(${args.map(String).join(", ")}) unexpectedly threw.`); + throw new Error(e); + } + } + let cb = () => {}; + + for (let isChromeCompat of [true, false]) { + do_print(`Testing API validation with isChromeCompat=${isChromeCompat}`); + testnamespace = generateAPIs({ + isChromeCompat, + }, { + one_required() {}, + one_optional() {}, + async_required() {}, + async_optional() {}, + }); + + assertThrows("one_required"); + assertThrows("one_required", null); + assertNoThrows("one_required", cb); + assertThrows("one_required", cb, null); + assertThrows("one_required", cb, cb); + + assertNoThrows("one_optional"); + assertNoThrows("one_optional", null); + assertNoThrows("one_optional", cb); + assertThrows("one_optional", cb, null); + assertThrows("one_optional", cb, cb); + + // Schema-based validation happens before an async method is called, so + // errors should be thrown synchronously. + + // The parameter was declared as required, but there was also an "async" + // attribute with the same value as the parameter name, so the callback + // parameter is actually optional. + assertNoThrows("async_required"); + assertNoThrows("async_required", null); + assertNoThrows("async_required", cb); + assertThrows("async_required", cb, null); + assertThrows("async_required", cb, cb); + + assertNoThrows("async_optional"); + assertNoThrows("async_optional", null); + assertNoThrows("async_optional", cb); + assertThrows("async_optional", cb, null); + assertThrows("async_optional", cb, cb); + } +}); + +add_task(async function testAsyncResults() { + await Schemas.load("data:," + JSON.stringify(schemaJson)); + async function runWithCallback(func) { + do_print(`Calling testnamespace.${func.name}, expecting callback with result`); + return await new Promise(resolve => { + let result = "uninitialized value"; + let returnValue = func(reply => { + result = reply; + resolve(result); + }); + // When a callback is given, the return value must be missing. + do_check_eq(returnValue, undefined); + // Callback must be called asynchronously. + do_check_eq(result, "uninitialized value"); + }); + } + + async function runFailCallback(func) { + do_print(`Calling testnamespace.${func.name}, expecting callback with error`); + return await new Promise(resolve => { + func(reply => { + do_check_eq(reply, undefined); + resolve(context.lastError.message); // eslint-disable-line no-undef + }); + }); + } + + for (let isChromeCompat of [true, false]) { + do_print(`Testing API invocation with isChromeCompat=${isChromeCompat}`); + let testnamespace = generateAPIs({ + isChromeCompat, + }, { + async_required(cb) { + do_check_eq(cb, undefined); + return Promise.resolve(1); + }, + async_optional(cb) { + do_check_eq(cb, undefined); + return Promise.resolve(2); + }, + }); + if (!isChromeCompat) { // No promises for chrome. + do_print("testnamespace.async_required should be a Promise"); + let promise = testnamespace.async_required(); + do_check_true(promise instanceof context.cloneScope.Promise); + do_check_eq(await promise, 1); + + do_print("testnamespace.async_optional should be a Promise"); + promise = testnamespace.async_optional(); + do_check_true(promise instanceof context.cloneScope.Promise); + do_check_eq(await promise, 2); + } + + do_check_eq(await runWithCallback(testnamespace.async_required), 1); + do_check_eq(await runWithCallback(testnamespace.async_optional), 2); + + let otherSandbox = Cu.Sandbox(null, {}); + let errorFactories = [ + msg => { throw new context.cloneScope.Error(msg); }, + msg => context.cloneScope.Promise.reject({message: msg}), + msg => Cu.evalInSandbox(`throw new Error("${msg}")`, otherSandbox), + msg => Cu.evalInSandbox(`Promise.reject({message: "${msg}"})`, otherSandbox), + ]; + for (let makeError of errorFactories) { + do_print(`Testing callback/promise with error caused by: ${makeError}`); + testnamespace = generateAPIs({ + isChromeCompat, + }, { + async_required() { return makeError("ONE"); }, + async_optional() { return makeError("TWO"); }, + }); + + if (!isChromeCompat) { // No promises for chrome. + await Assert.rejects(testnamespace.async_required(), /ONE/, + "should reject testnamespace.async_required()").catch(() => {}); + await Assert.rejects(testnamespace.async_optional(), /TWO/, + "should reject testnamespace.async_optional()").catch(() => {}); + } + + do_check_eq(await runFailCallback(testnamespace.async_required), "ONE"); + do_check_eq(await runFailCallback(testnamespace.async_optional), "TWO"); + } + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js new file mode 100644 index 0000000000..0606cf5e0d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js @@ -0,0 +1,468 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Components.utils.import("resource://gre/modules/ExtensionCommon.jsm"); +Components.utils.import("resource://gre/modules/Schemas.jsm"); + +let {SchemaAPIInterface} = ExtensionCommon; + +const global = this; + +let json = [ + { + namespace: "revokableNs", + + permissions: ["revokableNs"], + + properties: { + stringProp: { + type: "string", + writable: true, + }, + + revokableStringProp: { + type: "string", + permissions: ["revokableProp"], + writable: true, + }, + + submoduleProp: { + $ref: "submodule", + }, + + revokableSubmoduleProp: { + $ref: "submodule", + permissions: ["revokableProp"], + }, + }, + + types: [ + { + id: "submodule", + type: "object", + functions: [ + { + name: "sub_foo", + type: "function", + parameters: [], + returns: "integer", + }, + ], + }, + ], + + functions: [ + { + name: "func", + type: "function", + parameters: [], + }, + + { + name: "revokableFunc", + type: "function", + parameters: [], + permissions: ["revokableFunc"], + }, + ], + + events: [ + { + name: "onEvent", + type: "function", + }, + + { + name: "onRevokableEvent", + type: "function", + permissions: ["revokableEvent"], + }, + ], + }, +]; + +let recorded = []; + +function record(...args) { + recorded.push(args); +} + +function verify(expected) { + for (let [i, rec] of expected.entries()) { + Assert.deepEqual(recorded[i], rec, `Record ${i} matches`); + } + + equal(recorded.length, expected.length, "Got expected number of records"); + + recorded.length = 0; +} + +do_register_cleanup(() => { + equal(recorded.length, 0, "No unchecked recorded events at shutdown"); +}); + +let permissions = new Set(); + +class APIImplementation extends SchemaAPIInterface { + constructor(namespace, name) { + super(); + this.namespace = namespace; + this.name = name; + } + + record(method, args) { + record(method, this.namespace, this.name, args); + } + + revoke(...args) { + this.record("revoke", args); + } + + callFunction(...args) { + this.record("callFunction", args); + } + + callFunctionNoReturn(...args) { + this.record("callFunctionNoReturn", args); + } + + getProperty(...args) { + this.record("getProperty", args); + } + + setProperty(...args) { + this.record("setProperty", args); + } + + addListener(...args) { + this.record("addListener", args); + } + + removeListener(...args) { + this.record("removeListener", args); + } + + hasListener(...args) { + this.record("hasListener", args); + } +} + +let context = { + cloneScope: global, + + permissionsChanged: null, + + setPermissionsChangedCallback(callback) { + this.permissionsChanged = callback; + }, + + hasPermission(permission) { + return permissions.has(permission); + }, + + isPermissionRevokable(permission) { + return permission.startsWith("revokable"); + }, + + getImplementation(namespace, name) { + return new APIImplementation(namespace, name); + }, + + shouldInject() { + return true; + }, +}; + +function ignoreError(fn) { + try { + fn(); + } catch (e) { + // Meh. + } +} + +add_task(async function() { + let url = "data:," + JSON.stringify(json); + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, context); + equal(recorded.length, 0, "No recorded events"); + + let listener = () => {}; + let captured = {}; + + function checkRecorded() { + let possible = [ + ["revokableNs", ["getProperty", "revokableNs", "stringProp", []]], + ["revokableProp", ["getProperty", "revokableNs", "revokableStringProp", []]], + + ["revokableNs", ["setProperty", "revokableNs", "stringProp", ["stringProp"]]], + ["revokableProp", ["setProperty", "revokableNs", "revokableStringProp", ["revokableStringProp"]]], + + ["revokableNs", ["callFunctionNoReturn", "revokableNs", "func", [[]]]], + ["revokableFunc", ["callFunctionNoReturn", "revokableNs", "revokableFunc", [[]]]], + + ["revokableNs", ["callFunction", "revokableNs.submoduleProp", "sub_foo", [[]]]], + ["revokableProp", ["callFunction", "revokableNs.revokableSubmoduleProp", "sub_foo", [[]]]], + + ["revokableNs", ["addListener", "revokableNs", "onEvent", [listener, []]]], + ["revokableNs", ["removeListener", "revokableNs", "onEvent", [listener]]], + ["revokableNs", ["hasListener", "revokableNs", "onEvent", [listener]]], + + ["revokableEvent", ["addListener", "revokableNs", "onRevokableEvent", [listener, []]]], + ["revokableEvent", ["removeListener", "revokableNs", "onRevokableEvent", [listener]]], + ["revokableEvent", ["hasListener", "revokableNs", "onRevokableEvent", [listener]]], + ]; + + let expected = []; + if (permissions.has("revokableNs")) { + for (let [perm, recording] of possible) { + if (!perm || permissions.has(perm)) { + expected.push(recording); + } + } + } + + verify(expected); + } + + function check() { + do_print(`Check normal access (permissions: [${Array.from(permissions)}])`); + + let ns = root.revokableNs; + + void ns.stringProp; + void ns.revokableStringProp; + + ns.stringProp = "stringProp"; + ns.revokableStringProp = "revokableStringProp"; + + ns.func(); + + if (ns.revokableFunc) { + ns.revokableFunc(); + } + + ns.submoduleProp.sub_foo(); + if (ns.revokableSubmoduleProp) { + ns.revokableSubmoduleProp.sub_foo(); + } + + ns.onEvent.addListener(listener); + ns.onEvent.removeListener(listener); + ns.onEvent.hasListener(listener); + + if (ns.onRevokableEvent) { + ns.onRevokableEvent.addListener(listener); + ns.onRevokableEvent.removeListener(listener); + ns.onRevokableEvent.hasListener(listener); + } + + checkRecorded(); + } + + function capture() { + do_print("Capture values"); + + let ns = root.revokableNs; + + captured = {ns}; + captured.revokableStringProp = Object.getOwnPropertyDescriptor( + ns, "revokableStringProp"); + + captured.revokableSubmoduleProp = ns.revokableSubmoduleProp; + if (ns.revokableSubmoduleProp) { + captured.sub_foo = ns.revokableSubmoduleProp.sub_foo; + } + + captured.revokableFunc = ns.revokableFunc; + + captured.onRevokableEvent = ns.onRevokableEvent; + if (ns.onRevokableEvent) { + captured.addListener = ns.onRevokableEvent.addListener; + captured.removeListener = ns.onRevokableEvent.removeListener; + captured.hasListener = ns.onRevokableEvent.hasListener; + } + } + + function checkCaptured() { + do_print(`Check captured value access (permissions: [${Array.from(permissions)}])`); + + let {ns} = captured; + + void ns.stringProp; + ignoreError(() => captured.revokableStringProp.get()); + if (!permissions.has("revokableProp")) { + void ns.revokableStringProp; + } + + ns.stringProp = "stringProp"; + ignoreError(() => captured.revokableStringProp.set("revokableStringProp")); + if (!permissions.has("revokableProp")) { + ns.revokableStringProp = "revokableStringProp"; + } + + ignoreError(() => ns.func()); + ignoreError(() => captured.revokableFunc()); + if (!permissions.has("revokableFunc")) { + ignoreError(() => ns.revokableFunc()); + } + + ignoreError(() => ns.submoduleProp.sub_foo()); + + ignoreError(() => captured.sub_foo()); + if (!permissions.has("revokableProp")) { + ignoreError(() => captured.revokableSubmoduleProp.sub_foo()); + ignoreError(() => ns.revokableSubmoduleProp.sub_foo()); + } + + ignoreError(() => ns.onEvent.addListener(listener)); + ignoreError(() => ns.onEvent.removeListener(listener)); + ignoreError(() => ns.onEvent.hasListener(listener)); + + ignoreError(() => captured.addListener(listener)); + ignoreError(() => captured.removeListener(listener)); + ignoreError(() => captured.hasListener(listener)); + if (!permissions.has("revokableEvent")) { + ignoreError(() => captured.onRevokableEvent.addListener(listener)); + ignoreError(() => captured.onRevokableEvent.removeListener(listener)); + ignoreError(() => captured.onRevokableEvent.hasListener(listener)); + + ignoreError(() => ns.onRevokableEvent.addListener(listener)); + ignoreError(() => ns.onRevokableEvent.removeListener(listener)); + ignoreError(() => ns.onRevokableEvent.hasListener(listener)); + } + + checkRecorded(); + } + + permissions.add("revokableNs"); + permissions.add("revokableProp"); + permissions.add("revokableFunc"); + permissions.add("revokableEvent"); + + check(); + capture(); + checkCaptured(); + + permissions.delete("revokableProp"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "revokableStringProp", []], + ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []], + ]); + + check(); + checkCaptured(); + + permissions.delete("revokableFunc"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "revokableFunc", []], + ]); + + check(); + checkCaptured(); + + permissions.delete("revokableEvent"); + context.permissionsChanged(); + + verify([ + ["revoke", "revokableNs", "onRevokableEvent", []], + ]); + + check(); + checkCaptured(); + + permissions.delete("revokableNs"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "stringProp", []], + ["revoke", "revokableNs", "func", []], + ["revoke", "revokableNs.submoduleProp", "sub_foo", []], + ["revoke", "revokableNs", "onEvent", []], + ]); + + checkCaptured(); + + permissions.add("revokableNs"); + permissions.add("revokableProp"); + permissions.add("revokableFunc"); + permissions.add("revokableEvent"); + context.permissionsChanged(); + + check(); + capture(); + checkCaptured(); + + permissions.delete("revokableProp"); + permissions.delete("revokableFunc"); + permissions.delete("revokableEvent"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "revokableStringProp", []], + ["revoke", "revokableNs", "revokableFunc", []], + ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []], + ["revoke", "revokableNs", "onRevokableEvent", []], + ]); + + check(); + checkCaptured(); + + permissions.add("revokableProp"); + permissions.add("revokableFunc"); + permissions.add("revokableEvent"); + context.permissionsChanged(); + + check(); + capture(); + checkCaptured(); + + permissions.delete("revokableNs"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "stringProp", []], + ["revoke", "revokableNs", "revokableStringProp", []], + ["revoke", "revokableNs", "func", []], + ["revoke", "revokableNs", "revokableFunc", []], + ["revoke", "revokableNs.submoduleProp", "sub_foo", []], + ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []], + ["revoke", "revokableNs", "onEvent", []], + ["revoke", "revokableNs", "onRevokableEvent", []], + ]); + + equal(root.revokableNs, undefined, "Namespace is not defined"); + checkCaptured(); +}); + + +add_task(async function test_neuter() { + context.permissionsChanged = null; + + let root = {}; + Schemas.inject(root, context); + equal(recorded.length, 0, "No recorded events"); + + permissions.add("revokableNs"); + permissions.add("revokableProp"); + permissions.add("revokableFunc"); + permissions.add("revokableEvent"); + + let ns = root.revokableNs; + let {submoduleProp} = ns; + + let lazyGetter = Object.getOwnPropertyDescriptor(submoduleProp, "sub_foo"); + + permissions.delete("revokableNs"); + context.permissionsChanged(); + verify([]); + + equal(root.revokableNs, undefined, "Should have no revokableNs"); + equal(ns.submoduleProp, undefined, "Should have no ns.submoduleProp"); + + equal(submoduleProp.sub_foo, undefined, "No sub_foo"); + lazyGetter.get.call(submoduleProp); + equal(submoduleProp.sub_foo, undefined, "No sub_foo"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js b/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js new file mode 100644 index 0000000000..eeef01eb76 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js @@ -0,0 +1,30 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +const {GlobalManager} = Cu.import("resource://gre/modules/Extension.jsm", {}); + +add_task(async function test_global_manager_shutdown_cleanup() { + equal(GlobalManager.initialized, false, + "GlobalManager start as not initialized"); + + function background() { + browser.test.notifyPass("background page loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + + await extension.startup(); + await extension.awaitFinish("background page loaded"); + + equal(GlobalManager.initialized, true, + "GlobalManager has been initialized once an extension is started"); + + await extension.unload(); + + equal(GlobalManager.initialized, false, + "GlobalManager has been uninitialized once all the webextensions have been stopped"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_simple.js b/toolkit/components/extensions/test/xpcshell/test_ext_simple.js new file mode 100644 index 0000000000..66c5934023 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_simple.js @@ -0,0 +1,69 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_simple() { + let extensionData = { + manifest: { + "name": "Simple extension test", + "version": "1.0", + "manifest_version": 2, + "description": "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.unload(); +}); + +add_task(async function test_background() { + function background() { + browser.test.log("running background script"); + + browser.test.onMessage.addListener((x, y) => { + browser.test.assertEq(x, 10, "x is 10"); + browser.test.assertEq(y, 20, "y is 20"); + + browser.test.notifyPass("background test passed"); + }); + + browser.test.sendMessage("running", 1); + } + + let extensionData = { + background, + manifest: { + "name": "Simple extension test", + "version": "1.0", + "manifest_version": 2, + "description": "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let [, x] = await Promise.all([extension.startup(), extension.awaitMessage("running")]); + equal(x, 1, "got correct value from extension"); + + extension.sendMessage(10, 20); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_extensionTypes() { + let extensionData = { + background: function() { + browser.test.assertEq(typeof browser.extensionTypes, "object", "browser.extensionTypes exists"); + browser.test.assertEq(typeof browser.extensionTypes.RunAt, "object", "browser.extensionTypes.RunAt exists"); + browser.test.notifyPass("extentionTypes test passed"); + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js new file mode 100644 index 0000000000..76b81e4c6c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.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"; + +Cu.import("resource://gre/modules/Preferences.jsm"); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +const ADDON_ID = "test-startup-cache@xpcshell.mozilla.org"; + +function makeExtension(opts) { + return { + useAddonManager: "permanent", + + manifest: { + "version": opts.version, + "applications": {"gecko": {"id": ADDON_ID}}, + + "name": "__MSG_name__", + + "default_locale": "en_US", + }, + + files: { + "_locales/en_US/messages.json": { + name: { + message: `en-US ${opts.version}`, + description: "Name.", + }, + }, + "_locales/fr/messages.json": { + name: { + message: `fr ${opts.version}`, + description: "Name.", + }, + }, + }, + + background() { + browser.test.onMessage.addListener(msg => { + if (msg === "get-manifest") { + browser.test.sendMessage("manifest", browser.runtime.getManifest()); + } + }); + }, + }; +} + +add_task(async function() { + Preferences.set("extensions.logging.enabled", false); + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension( + makeExtension({version: "1.0"})); + + function getManifest() { + extension.sendMessage("get-manifest"); + return extension.awaitMessage("manifest"); + } + + + await extension.startup(); + + equal(extension.version, "1.0", "Expected extension version"); + let manifest = await getManifest(); + equal(manifest.name, "en-US 1.0", "Got expected manifest name"); + + + do_print("Restart and re-check"); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + equal(extension.version, "1.0", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "en-US 1.0", "Got expected manifest name"); + + + do_print("Change locale to 'fr' and restart"); + Preferences.set("general.useragent.locale", "fr"); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + equal(extension.version, "1.0", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "fr 1.0", "Got expected manifest name"); + + + do_print("Update to version 1.1"); + await extension.upgrade(makeExtension({version: "1.1"})); + + equal(extension.version, "1.1", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "fr 1.1", "Got expected manifest name"); + + + do_print("Change locale to 'en-US' and restart"); + Preferences.set("general.useragent.locale", "en-US"); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + equal(extension.version, "1.1", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "en-US 1.1", "Got expected manifest name"); + + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage.js new file mode 100644 index 0000000000..49f3afaa8a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage.js @@ -0,0 +1,371 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const STORAGE_SYNC_PREF = "webextensions.storage.sync.enabled"; +Cu.import("resource://gre/modules/Preferences.jsm"); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +/** + * Utility function to ensure that all supported APIs for getting are + * tested. + * + * @param {string} areaName + * either "local" or "sync" according to what we want to test + * @param {string} prop + * "key" to look up using the storage API + * @param {Object} value + * "value" to compare against + */ +async function checkGetImpl(areaName, prop, value) { + let storage = browser.storage[areaName]; + + let data = await storage.get(null); + browser.test.assertEq(value, data[prop], `null getter worked for ${prop} in ${areaName}`); + + data = await storage.get(prop); + browser.test.assertEq(value, data[prop], `string getter worked for ${prop} in ${areaName}`); + + data = await storage.get([prop]); + browser.test.assertEq(value, data[prop], `array getter worked for ${prop} in ${areaName}`); + + data = await storage.get({[prop]: undefined}); + browser.test.assertEq(value, data[prop], `object getter worked for ${prop} in ${areaName}`); +} + +add_task(async function test_local_cache_invalidation() { + function background(checkGet) { + browser.test.onMessage.addListener(async msg => { + if (msg === "set-initial") { + await browser.storage.local.set({"test-prop1": "value1", "test-prop2": "value2"}); + browser.test.sendMessage("set-initial-done"); + } else if (msg === "check") { + await checkGet("local", "test-prop1", "value1"); + await checkGet("local", "test-prop2", "value2"); + browser.test.sendMessage("check-done"); + } + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + background: `(${background})(${checkGetImpl})`, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage("set-initial"); + await extension.awaitMessage("set-initial-done"); + + Services.obs.notifyObservers(null, "extension-invalidate-storage-cache"); + + extension.sendMessage("check"); + await extension.awaitMessage("check-done"); + + await extension.unload(); +}); + +add_task(async function test_config_flag_needed() { + function background() { + let promises = []; + let apiTests = [ + {method: "get", args: ["foo"]}, + {method: "set", args: [{foo: "bar"}]}, + {method: "remove", args: ["foo"]}, + {method: "clear", args: []}, + ]; + apiTests.forEach(testDef => { + promises.push(browser.test.assertRejects( + browser.storage.sync[testDef.method](...testDef.args), + "Please set webextensions.storage.sync.enabled to true in about:config", + `storage.sync.${testDef.method} is behind a flag`)); + }); + + Promise.all(promises).then(() => browser.test.notifyPass("flag needed")); + } + + Preferences.set(STORAGE_SYNC_PREF, false); + ok(!Preferences.get(STORAGE_SYNC_PREF)); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + background: `(${background})(${checkGetImpl})`, + }); + + await extension.startup(); + await extension.awaitFinish("flag needed"); + await extension.unload(); + Preferences.reset(STORAGE_SYNC_PREF); +}); + +add_task(async function test_reloading_extensions_works() { + // Just some random extension ID that we can re-use + const extensionId = "my-extension-id@1"; + + function loadExtension() { + function background() { + browser.storage.sync.set({"a": "b"}).then(() => { + browser.test.notifyPass("set-works"); + }); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + background: `(${background})()`, + }, extensionId); + } + + Preferences.set(STORAGE_SYNC_PREF, true); + + let extension1 = loadExtension(); + + await extension1.startup(); + await extension1.awaitFinish("set-works"); + await extension1.unload(); + + let extension2 = loadExtension(); + + await extension2.startup(); + await extension2.awaitFinish("set-works"); + await extension2.unload(); + + Preferences.reset(STORAGE_SYNC_PREF); +}); + +do_register_cleanup(() => { + Preferences.reset(STORAGE_SYNC_PREF); +}); + +add_task(async function test_backgroundScript() { + async function backgroundScript(checkGet) { + let globalChanges, gResolve; + function clearGlobalChanges() { + globalChanges = new Promise(resolve => { gResolve = resolve; }); + } + clearGlobalChanges(); + let expectedAreaName; + + browser.storage.onChanged.addListener((changes, areaName) => { + browser.test.assertEq(expectedAreaName, areaName, + "Expected area name received by listener"); + gResolve(changes); + }); + + async function checkChanges(areaName, changes, message) { + function checkSub(obj1, obj2) { + for (let prop in obj1) { + browser.test.assertTrue(obj1[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})`); + browser.test.assertTrue(obj2[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})`); + browser.test.assertEq(obj1[prop].oldValue, obj2[prop].oldValue, + `checkChanges ${areaName} ${prop} old (${message})`); + browser.test.assertEq(obj1[prop].newValue, obj2[prop].newValue, + `checkChanges ${areaName} ${prop} new (${message})`); + } + } + + const recentChanges = await globalChanges; + checkSub(changes, recentChanges); + checkSub(recentChanges, changes); + clearGlobalChanges(); + } + + /* eslint-disable dot-notation */ + async function runTests(areaName) { + expectedAreaName = areaName; + let storage = browser.storage[areaName]; + // Set some data and then test getters. + try { + await storage.set({"test-prop1": "value1", "test-prop2": "value2"}); + await checkChanges(areaName, + {"test-prop1": {newValue: "value1"}, "test-prop2": {newValue: "value2"}}, + "set (a)"); + + await checkGet(areaName, "test-prop1", "value1"); + await checkGet(areaName, "test-prop2", "value2"); + + let data = await storage.get({"test-prop1": undefined, "test-prop2": undefined, "other": "default"}); + browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (a)"); + browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (a)"); + browser.test.assertEq("default", data["other"], "other correct"); + + data = await storage.get(["test-prop1", "test-prop2", "other"]); + browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (b)"); + browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (b)"); + browser.test.assertFalse("other" in data, "other correct"); + + // Remove data in various ways. + await storage.remove("test-prop1"); + await checkChanges(areaName, {"test-prop1": {oldValue: "value1"}}, "remove string"); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse("test-prop1" in data, "prop1 absent (remove string)"); + browser.test.assertTrue("test-prop2" in data, "prop2 present (remove string)"); + + await storage.set({"test-prop1": "value1"}); + await checkChanges(areaName, {"test-prop1": {newValue: "value1"}}, "set (c)"); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertEq(data["test-prop1"], "value1", "prop1 correct (c)"); + browser.test.assertEq(data["test-prop2"], "value2", "prop2 correct (c)"); + + await storage.remove(["test-prop1", "test-prop2"]); + await checkChanges(areaName, + {"test-prop1": {oldValue: "value1"}, "test-prop2": {oldValue: "value2"}}, + "remove array"); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse("test-prop1" in data, "prop1 absent (remove array)"); + browser.test.assertFalse("test-prop2" in data, "prop2 absent (remove array)"); + + // test storage.clear + await storage.set({"test-prop1": "value1", "test-prop2": "value2"}); + // Make sure that set() handler happened before we clear the + // promise again. + await globalChanges; + + clearGlobalChanges(); + await storage.clear(); + + await checkChanges(areaName, + {"test-prop1": {oldValue: "value1"}, "test-prop2": {oldValue: "value2"}}, + "clear"); + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse("test-prop1" in data, "prop1 absent (clear)"); + browser.test.assertFalse("test-prop2" in data, "prop2 absent (clear)"); + + // Make sure we can store complex JSON data. + // known previous values + await storage.set({"test-prop1": "value1", "test-prop2": "value2"}); + + // Make sure the set() handler landed. + await globalChanges; + + clearGlobalChanges(); + await storage.set({ + "test-prop1": { + str: "hello", + bool: true, + null: null, + undef: undefined, + obj: {}, + arr: [1, 2], + date: new Date(0), + regexp: /regexp/, + func: function func() {}, + window, + }, + }); + + await storage.set({"test-prop2": function func() {}}); + const recentChanges = await globalChanges; + + browser.test.assertEq("value1", recentChanges["test-prop1"].oldValue, "oldValue correct"); + browser.test.assertEq("object", typeof(recentChanges["test-prop1"].newValue), "newValue is obj"); + clearGlobalChanges(); + + data = await storage.get({"test-prop1": undefined, "test-prop2": undefined}); + let obj = data["test-prop1"]; + + browser.test.assertEq("hello", obj.str, "string part correct"); + browser.test.assertEq(true, obj.bool, "bool part correct"); + browser.test.assertEq(null, obj.null, "null part correct"); + browser.test.assertEq(undefined, obj.undef, "undefined part correct"); + browser.test.assertEq(undefined, obj.func, "function part correct"); + browser.test.assertEq(undefined, obj.window, "window part correct"); + browser.test.assertEq("1970-01-01T00:00:00.000Z", obj.date, "date part correct"); + browser.test.assertEq("/regexp/", obj.regexp, "regexp part correct"); + browser.test.assertEq("object", typeof(obj.obj), "object part correct"); + browser.test.assertTrue(Array.isArray(obj.arr), "array part present"); + browser.test.assertEq(1, obj.arr[0], "arr[0] part correct"); + browser.test.assertEq(2, obj.arr[1], "arr[1] part correct"); + browser.test.assertEq(2, obj.arr.length, "arr.length part correct"); + + obj = data["test-prop2"]; + + browser.test.assertEq("[object Object]", {}.toString.call(obj), "function serialized as a plain object"); + browser.test.assertEq(0, Object.keys(obj).length, "function serialized as an empty object"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("storage"); + } + } + + browser.test.onMessage.addListener(msg => { + let promise; + if (msg === "test-local") { + promise = runTests("local"); + } else if (msg === "test-sync") { + promise = runTests("sync"); + } + promise.then(() => browser.test.sendMessage("test-finished")); + }); + + browser.test.sendMessage("ready"); + } + + let extensionData = { + background: `(${backgroundScript})(${checkGetImpl})`, + manifest: { + permissions: ["storage"], + }, + }; + + Preferences.set(STORAGE_SYNC_PREF, true); + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage("test-local"); + await extension.awaitMessage("test-finished"); + + extension.sendMessage("test-sync"); + await extension.awaitMessage("test-finished"); + + Preferences.reset(STORAGE_SYNC_PREF); + await extension.unload(); +}); + +add_task(async function test_storage_requires_real_id() { + async function backgroundScript() { + const EXCEPTION_MESSAGE = + "The storage API is not available with a temporary addon ID. " + + "Please add an explicit addon ID to your manifest. " + + "For more information see https://bugzil.la/1323228."; + + await browser.test.assertRejects(browser.storage.sync.set({"foo": "bar"}), + EXCEPTION_MESSAGE); + + browser.test.notifyPass("exception correct"); + } + + let extensionData = { + background: `(${backgroundScript})(${checkGetImpl})`, + manifest: { + permissions: ["storage"], + }, + useAddonManager: "temporary", + }; + + Preferences.set(STORAGE_SYNC_PREF, true); + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("exception correct"); + + Preferences.reset(STORAGE_SYNC_PREF); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js new file mode 100644 index 0000000000..7eae559a62 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js @@ -0,0 +1,1480 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +do_get_profile(); // so we can use FxAccounts + +Cu.import("resource://testing-common/httpd.js"); +Cu.import("resource://services-common/utils.js"); +Cu.import("resource://services-crypto/utils.js"); +const { + CollectionKeyEncryptionRemoteTransformer, + CryptoCollection, + ExtensionStorageSync, + idToKey, + KeyRingEncryptionRemoteTransformer, + keyToId, +} = Cu.import("resource://gre/modules/ExtensionStorageSync.jsm", {}); +Cu.import("resource://services-sync/engines/extension-storage.js"); +Cu.import("resource://services-sync/keys.js"); +Cu.import("resource://services-sync/util.js"); + +/* globals BulkKeyBundle, CommonUtils, EncryptionRemoteTransformer */ +/* globals Utils */ + +function handleCannedResponse(cannedResponse, request, response) { + response.setStatusLine(null, cannedResponse.status.status, + cannedResponse.status.statusText); + // send the headers + for (let headerLine of cannedResponse.sampleHeaders) { + let headerElements = headerLine.split(":"); + response.setHeader(headerElements[0], headerElements[1].trimLeft()); + } + response.setHeader("Date", (new Date()).toUTCString()); + + response.write(cannedResponse.responseBody); +} + +function collectionRecordsPath(collectionId) { + return `/buckets/default/collections/${collectionId}/records`; +} + +class KintoServer { + constructor() { + // Set up an HTTP Server + this.httpServer = new HttpServer(); + this.httpServer.start(-1); + + // Set corresponding to records that might be served. + // The format of these objects is defined in the documentation for #addRecord. + this.records = []; + + // Collections that we have set up access to (see `installCollection`). + this.collections = new Set(); + + // ETag to serve with responses + this.etag = 1; + + this.port = this.httpServer.identity.primaryPort; + + // POST requests we receive from the client go here + this.posts = []; + // DELETEd buckets will go here. + this.deletedBuckets = []; + // Anything in here will force the next POST to generate a conflict + this.conflicts = []; + // If this is true, reject the next request with a 401 + this.rejectNextAuthResponse = false; + this.failedAuths = []; + + this.installConfigPath(); + this.installBatchPath(); + this.installCatchAll(); + } + + clearPosts() { + this.posts = []; + } + + getPosts() { + return this.posts; + } + + getDeletedBuckets() { + return this.deletedBuckets; + } + + rejectNextAuthWith(response) { + this.rejectNextAuthResponse = response; + } + + checkAuth(request, response) { + // FIXME: assert auth is "Bearer ...token..." + if (this.rejectNextAuthResponse) { + response.setStatusLine(null, 401, "Unauthorized"); + response.write(this.rejectNextAuthResponse); + this.rejectNextAuthResponse = false; + this.failedAuths.push(request); + return true; + } + return false; + } + + installConfigPath() { + const configPath = "/v1/"; + const responseBody = JSON.stringify({ + "settings": {"batch_max_requests": 25}, + "url": `http://localhost:${this.port}/v1/`, + "documentation": "https://kinto.readthedocs.org/", + "version": "1.5.1", + "commit": "cbc6f58", + "hello": "kinto", + }); + const configResponse = { + "sampleHeaders": [ + "Access-Control-Allow-Origin: *", + "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + "Content-Type: application/json; charset=UTF-8", + "Server: waitress", + ], + "status": {status: 200, statusText: "OK"}, + "responseBody": responseBody, + }; + + function handleGetConfig(request, response) { + if (request.method != "GET") { + dump(`ARGH, got ${request.method}\n`); + } + return handleCannedResponse(configResponse, request, response); + } + + this.httpServer.registerPathHandler(configPath, handleGetConfig); + } + + installBatchPath() { + const batchPath = "/v1/batch"; + + function handlePost(request, response) { + if (this.checkAuth(request, response)) { + return; + } + + let bodyStr = CommonUtils.readBytesFromInputStream(request.bodyInputStream); + let body = JSON.parse(bodyStr); + let defaults = body.defaults; + for (let req of body.requests) { + let headers = Object.assign({}, (defaults && defaults.headers) || {}, req.headers); + this.posts.push(Object.assign({}, req, {headers})); + } + + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", (new Date()).toUTCString()); + + let postResponse = { + responses: body.requests.map(req => { + let oneBody; + if (req.method == "DELETE") { + let id = req.path.match(/^\/buckets\/default\/collections\/.+\/records\/(.+)$/)[1]; + oneBody = { + "data": { + "deleted": true, + "id": id, + "last_modified": this.etag, + }, + }; + } else { + oneBody = {"data": Object.assign({}, req.body.data, {last_modified: this.etag}), + "permissions": []}; + } + + return { + path: req.path, + status: 201, // FIXME -- only for new posts?? + headers: {"ETag": 3000}, // FIXME??? + body: oneBody, + }; + }), + }; + + if (this.conflicts.length > 0) { + const nextConflict = this.conflicts.shift(); + if (!nextConflict.transient) { + this.records.push(nextConflict); + } + const {data} = nextConflict; + postResponse = { + responses: body.requests.map(req => { + return { + path: req.path, + status: 412, + headers: {"ETag": this.etag}, // is this correct?? + body: { + details: { + existing: data, + }, + }, + }; + }), + }; + } + + response.write(JSON.stringify(postResponse)); + + // "sampleHeaders": [ + // "Access-Control-Allow-Origin: *", + // "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + // "Server: waitress", + // "Etag: \"4000\"" + // ], + } + + this.httpServer.registerPathHandler(batchPath, handlePost.bind(this)); + } + + installCatchAll() { + this.httpServer.registerPathHandler("/", (request, response) => { + dump(`got request: ${request.method}:${request.path}?${request.queryString}\n`); + dump(`${CommonUtils.readBytesFromInputStream(request.bodyInputStream)}\n`); + }); + } + + /** + * Add a record to those that can be served by this server. + * + * @param {Object} properties An object describing the record that + * should be served. The properties of this object are: + * - collectionId {string} This record should only be served if a + * request is for this collection. + * - predicate {Function} If present, this record should only be served if the + * predicate returns true. The predicate will be called with + * {request: Request, response: Response, since: number, server: KintoServer}. + * - data {string} The record to serve. + * - conflict {boolean} If present and true, this record is added to + * "conflicts" and won't be served, but will cause a conflict on + * the next push. + */ + addRecord(properties) { + if (!properties.conflict) { + this.records.push(properties); + } else { + this.conflicts.push(properties); + } + + this.installCollection(properties.collectionId); + } + + /** + * Tell the server to set up a route for this collection. + * + * This will automatically be called for any collection to which you `addRecord`. + * + * @param {string} collectionId the collection whose route we + * should set up. + */ + installCollection(collectionId) { + if (this.collections.has(collectionId)) { + return; + } + this.collections.add(collectionId); + const remoteRecordsPath = "/v1" + collectionRecordsPath(encodeURIComponent(collectionId)); + this.httpServer.registerPathHandler(remoteRecordsPath, this.handleGetRecords.bind(this, collectionId)); + } + + handleGetRecords(collectionId, request, response) { + if (this.checkAuth(request, response)) { + return; + } + + if (request.method != "GET") { + do_throw(`only GET is supported on ${request.path}`); + } + + let sinceMatch = request.queryString.match(/(^|&)_since=(\d+)/); + let since = sinceMatch && parseInt(sinceMatch[2], 10); + + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", (new Date()).toUTCString()); + response.setHeader("ETag", this.etag.toString()); + + const records = this.records.filter(properties => { + if (properties.collectionId != collectionId) { + return false; + } + + if (properties.predicate) { + const predAllowed = properties.predicate({ + request: request, + response: response, + since: since, + server: this, + }); + if (!predAllowed) { + return false; + } + } + + return true; + }).map(properties => properties.data); + + const body = JSON.stringify({ + "data": records, + }); + response.write(body); + } + + installDeleteBucket() { + this.httpServer.registerPrefixHandler("/v1/buckets/", (request, response) => { + if (request.method != "DELETE") { + dump(`got a non-delete action on bucket: ${request.method} ${request.path}\n`); + return; + } + + const noPrefix = request.path.slice("/v1/buckets/".length); + const [bucket, afterBucket] = noPrefix.split("/", 1); + if (afterBucket && afterBucket != "") { + dump(`got a delete for a non-bucket: ${request.method} ${request.path}\n`); + } + + this.deletedBuckets.push(bucket); + // Fake like this actually deletes the records. + this.records = []; + + response.write(JSON.stringify({ + data: { + deleted: true, + last_modified: 1475161309026, + id: "b09f1618-d789-302d-696e-74ec53ee18a8", // FIXME + }, + })); + }); + } + + // Utility function to install a keyring at the start of a test. + installKeyRing(fxaService, keysData, salts, etag, properties) { + const keysRecord = { + "id": "keys", + "keys": keysData, + "salts": salts, + "last_modified": etag, + }; + this.etag = etag; + const transformer = new KeyRingEncryptionRemoteTransformer(fxaService); + this.encryptAndAddRecord(transformer, Object.assign({}, properties, { + collectionId: "storage-sync-crypto", + data: keysRecord, + })); + } + + encryptAndAddRecord(transformer, properties) { + return transformer.encode(properties.data).then(encrypted => { + this.addRecord(Object.assign({}, properties, {data: encrypted})); + }); + } + + stop() { + this.httpServer.stop(() => { }); + } +} + +/** + * Predicate that represents a record appearing at some time. + * Requests with "_since" before this time should see this record, + * unless the server itself isn't at this time yet (etag is before + * this time). + * + * Requests with _since after this time shouldn't see this record any + * more, since it hasn't changed after this time. + * + * @param {int} startTime the etag at which time this record should + * start being available (and thus, the predicate should start + * returning true) + * @returns {Function} + */ +function appearsAt(startTime) { + return function({since, server}) { + return since < startTime && startTime < server.etag; + }; +} + +// Run a block of code with access to a KintoServer. +async function withServer(f) { + let server = new KintoServer(); + // Point the sync.storage client to use the test server we've just started. + Services.prefs.setCharPref("webextensions.storage.sync.serverURL", + `http://localhost:${server.port}/v1`); + try { + await f(server); + } finally { + server.stop(); + } +} + +// Run a block of code with access to both a sync context and a +// KintoServer. This is meant as a workaround for eslint's refusal to +// let me have 5 nested callbacks. +async function withContextAndServer(f) { + await withSyncContext(async function(context) { + await withServer(async function(server) { + await f(context, server); + }); + }); +} + +// Run a block of code with fxa mocked out to return a specific user. +// Calls the given function with an ExtensionStorageSync instance that +// was constructed using a mocked FxAccounts instance. +async function withSignedInUser(user, f) { + let fxaServiceMock = { + getSignedInUser() { + return Promise.resolve(user); + }, + getOAuthToken() { + return Promise.resolve("some-access-token"); + }, + sessionStatus() { + return Promise.resolve(true); + }, + removeCachedOAuthToken() { + return Promise.resolve(); + }, + }; + + let telemetryMock = { + _calls: [], + _histograms: {}, + scalarSet(name, value) { + this._calls.push({method: "scalarSet", name, value}); + }, + keyedScalarSet(name, key, value) { + this._calls.push({method: "keyedScalarSet", name, key, value}); + }, + getKeyedHistogramById(name) { + let self = this; + return { + add(key, value) { + if (!self._histograms[name]) { + self._histograms[name] = []; + } + self._histograms[name].push(value); + }, + }; + }, + }; + let extensionStorageSync = new ExtensionStorageSync(fxaServiceMock, telemetryMock); + await f(extensionStorageSync, fxaServiceMock); +} + +// Some assertions that make it easier to write tests about what was +// posted and when. + +// Assert that the request was made with the correct access token. +// This should be true of all requests, so this is usually called from +// another assertion. +function assertAuthenticatedRequest(post) { + equal(post.headers.Authorization, "Bearer some-access-token"); +} + +// Assert that this post was made with the correct request headers to +// create a new resource while protecting against someone else +// creating it at the same time (in other words, "If-None-Match: *"). +// Also calls assertAuthenticatedRequest(post). +function assertPostedNewRecord(post) { + assertAuthenticatedRequest(post); + equal(post.headers["If-None-Match"], "*"); +} + +// Assert that this post was made with the correct request headers to +// update an existing resource while protecting against concurrent +// modification (in other words, `If-Match: "${etag}"`). +// Also calls assertAuthenticatedRequest(post). +function assertPostedUpdatedRecord(post, since) { + assertAuthenticatedRequest(post); + equal(post.headers["If-Match"], `"${since}"`); +} + +// Assert that this post was an encrypted keyring, and produce the +// decrypted body. Sanity check the body while we're here. +const assertPostedEncryptedKeys = async function(fxaService, post) { + equal(post.path, collectionRecordsPath("storage-sync-crypto") + "/keys"); + + let body = await new KeyRingEncryptionRemoteTransformer(fxaService).decode(post.body.data); + ok(body.keys, `keys object should be present in decoded body`); + ok(body.keys.default, `keys object should have a default key`); + ok(body.salts, `salts object should be present in decoded body`); + return body; +}; + +// assertEqual, but for keyring[extensionId] == key. +function assertKeyRingKey(keyRing, extensionId, expectedKey, message) { + if (!message) { + message = `expected keyring's key for ${extensionId} to match ${expectedKey.keyPairB64}`; + } + ok(keyRing.hasKeysFor([extensionId]), + `expected keyring to have a key for ${extensionId}\n`); + deepEqual(keyRing.keyForCollection(extensionId).keyPairB64, expectedKey.keyPairB64, + message); +} + +// Assert that this post was posted for a given extension. +const assertExtensionRecord = async function(fxaService, post, extension, key) { + const extensionId = extension.id; + const cryptoCollection = new CryptoCollection(fxaService); + const hashedId = "id-" + (await cryptoCollection.hashWithExtensionSalt(keyToId(key), extensionId)); + const collectionId = await cryptoCollection.extensionIdToCollectionId(extensionId); + const transformer = new CollectionKeyEncryptionRemoteTransformer(cryptoCollection, extensionId); + equal(post.path, `${collectionRecordsPath(collectionId)}/${hashedId}`, + "decrypted data should be posted to path corresponding to its key"); + let decoded = await transformer.decode(post.body.data); + equal(decoded.key, key, + "decrypted data should have a key attribute corresponding to the extension data key"); + return decoded; +}; + +// Tests using this ID will share keys in local storage, so be careful. +const defaultExtensionId = "{13bdde76-4dc7-11e6-9bdc-54ee758d6342}"; +const defaultExtension = {id: defaultExtensionId}; + +const BORING_KB = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const loggedInUser = { + uid: "0123456789abcdef0123456789abcdef", + kB: BORING_KB, + oauthTokens: { + "sync:addon-storage": { + token: "some-access-token", + }, + }, +}; + +function uuid() { + const uuidgen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator); + return uuidgen.generateUUID().toString(); +} + +add_task(async function test_key_to_id() { + equal(keyToId("foo"), "key-foo"); + equal(keyToId("my-new-key"), "key-my_2D_new_2D_key"); + equal(keyToId(""), "key-"); + equal(keyToId("™"), "key-_2122_"); + equal(keyToId("\b"), "key-_8_"); + equal(keyToId("abc\ndef"), "key-abc_A_def"); + equal(keyToId("Kinto's fancy_string"), "key-Kinto_27_s_20_fancy_5F_string"); + + const KEYS = ["foo", "my-new-key", "", "Kinto's fancy_string", "™", "\b"]; + for (let key of KEYS) { + equal(idToKey(keyToId(key)), key); + } + + equal(idToKey("hi"), null); + equal(idToKey("-key-hi"), null); + equal(idToKey("key--abcd"), null); + equal(idToKey("key-%"), null); + equal(idToKey("key-_HI"), null); + equal(idToKey("key-_HI_"), null); + equal(idToKey("key-"), ""); + equal(idToKey("key-1"), "1"); + equal(idToKey("key-_2D_"), "-"); +}); + +add_task(async function test_extension_id_to_collection_id() { + const extensionId = "{9419cce6-5435-11e6-84bf-54ee758d6342}"; + // FIXME: this doesn't actually require the signed in user, but the + // extensionIdToCollectionId method exists on CryptoCollection, + // which needs an fxaService to be instantiated. + await withSignedInUser(loggedInUser, async function(extensionStorageSync, fxaService) { + // Fake a static keyring since the server doesn't exist. + const salt = "Scgx8RJ8Y0rxMGFYArUiKeawlW+0zJyFmtTDvro9qPo="; + const cryptoCollection = new CryptoCollection(fxaService); + await cryptoCollection._setSalt(extensionId, salt); + + equal(await cryptoCollection.extensionIdToCollectionId(extensionId), + "ext-0_QHA1P93_yJoj7ONisrR0lW6uN4PZ3Ii-rT-QOjtvo"); + }); +}); + +add_task(async function ensureCanSync_posts_new_keys() { + const extensionId = uuid(); + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function(extensionStorageSync, fxaService) { + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + let newKeys = await extensionStorageSync.ensureCanSync([extensionId]); + ok(newKeys.hasKeysFor([extensionId]), `key isn't present for ${extensionId}`); + + let posts = server.getPosts(); + equal(posts.length, 1); + const post = posts[0]; + assertPostedNewRecord(post); + const body = await assertPostedEncryptedKeys(fxaService, post); + const oldSalt = body.salts[extensionId]; + ok(body.keys.collections[extensionId], `keys object should have a key for ${extensionId}`); + ok(oldSalt, `salts object should have a salt for ${extensionId}`); + + // Try adding another key to make sure that the first post was + // OK, even on a new profile. + await extensionStorageSync.cryptoCollection._clear(); + server.clearPosts(); + // Restore the first posted keyring, but add a last_modified date + const firstPostedKeyring = Object.assign({}, post.body.data, {last_modified: server.etag}); + server.addRecord({ + data: firstPostedKeyring, + collectionId: "storage-sync-crypto", + predicate: appearsAt(250), + }); + const extensionId2 = uuid(); + newKeys = await extensionStorageSync.ensureCanSync([extensionId2]); + ok(newKeys.hasKeysFor([extensionId]), `didn't forget key for ${extensionId}`); + ok(newKeys.hasKeysFor([extensionId2]), `new key generated for ${extensionId2}`); + + posts = server.getPosts(); + equal(posts.length, 1); + const newPost = posts[posts.length - 1]; + const newBody = await assertPostedEncryptedKeys(fxaService, newPost); + ok(newBody.keys.collections[extensionId], `keys object should have a key for ${extensionId}`); + ok(newBody.keys.collections[extensionId2], `keys object should have a key for ${extensionId2}`); + ok(newBody.salts[extensionId], `salts object should have a key for ${extensionId}`); + ok(newBody.salts[extensionId2], `salts object should have a key for ${extensionId2}`); + equal(oldSalt, newBody.salts[extensionId], `old salt should be preserved in post`); + }); + }); +}); + +add_task(async function ensureCanSync_pulls_key() { + // ensureCanSync is implemented by adding a key to our local record + // and doing a sync. This means that if the same key exists + // remotely, we get a "conflict". Ensure that we handle this + // correctly -- we keep the server key (since presumably it's + // already been used to encrypt records) and we don't wipe out other + // collections' keys. + const extensionId = uuid(); + const extensionId2 = uuid(); + const extensionOnlyKey = uuid(); + const extensionOnlySalt = uuid(); + const DEFAULT_KEY = new BulkKeyBundle("[default]"); + DEFAULT_KEY.generateRandom(); + const RANDOM_KEY = new BulkKeyBundle(extensionId); + RANDOM_KEY.generateRandom(); + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function(extensionStorageSync, fxaService) { + // FIXME: generating a random salt probably shouldn't require a CryptoCollection? + const cryptoCollection = new CryptoCollection(fxaService); + const RANDOM_SALT = cryptoCollection.getNewSalt(); + await extensionStorageSync.cryptoCollection._clear(); + const keysData = { + "default": DEFAULT_KEY.keyPairB64, + "collections": { + [extensionId]: RANDOM_KEY.keyPairB64, + }, + }; + const saltData = { + [extensionId]: RANDOM_SALT, + }; + server.installKeyRing(fxaService, keysData, saltData, 950, { + predicate: appearsAt(900), + }); + + let collectionKeys = await extensionStorageSync.ensureCanSync([extensionId]); + assertKeyRingKey(collectionKeys, extensionId, RANDOM_KEY); + + let posts = server.getPosts(); + equal(posts.length, 0, + "ensureCanSync shouldn't push when the server keyring has the right key"); + + // Another client generates a key for extensionId2 + const newKey = new BulkKeyBundle(extensionId2); + newKey.generateRandom(); + keysData.collections[extensionId2] = newKey.keyPairB64; + saltData[extensionId2] = cryptoCollection.getNewSalt(); + server.installKeyRing(fxaService, keysData, saltData, 1050, { + predicate: appearsAt(1000), + }); + + let newCollectionKeys = await extensionStorageSync.ensureCanSync([extensionId, extensionId2]); + assertKeyRingKey(newCollectionKeys, extensionId2, newKey); + assertKeyRingKey(newCollectionKeys, extensionId, RANDOM_KEY, + `ensureCanSync shouldn't lose the old key for ${extensionId}`); + + posts = server.getPosts(); + equal(posts.length, 0, "ensureCanSync shouldn't push when updating keys"); + + // Another client generates a key, but not a salt, for extensionOnlyKey + const onlyKey = new BulkKeyBundle(extensionOnlyKey); + onlyKey.generateRandom(); + keysData.collections[extensionOnlyKey] = onlyKey.keyPairB64; + server.installKeyRing(fxaService, keysData, saltData, 1150, { + predicate: appearsAt(1100), + }); + + let withNewKey = await extensionStorageSync.ensureCanSync([extensionId, extensionOnlyKey]); + dump(`got ${JSON.stringify(withNewKey.asWBO().cleartext)}\n`); + assertKeyRingKey(withNewKey, extensionOnlyKey, onlyKey); + assertKeyRingKey(withNewKey, extensionId, RANDOM_KEY, + `ensureCanSync shouldn't lose the old key for ${extensionId}`); + + posts = server.getPosts(); + equal(posts.length, 1, "ensureCanSync should push when generating a new salt"); + const withNewKeyRecord = await assertPostedEncryptedKeys(fxaService, posts[0]); + // We don't a priori know what the new salt is + dump(`${JSON.stringify(withNewKeyRecord)}\n`); + ok(withNewKeyRecord.salts[extensionOnlyKey], + `ensureCanSync should generate a salt for an extension that only had a key`); + + // Another client generates a key, but not a salt, for extensionOnlyKey + const newSalt = cryptoCollection.getNewSalt(); + saltData[extensionOnlySalt] = newSalt; + server.installKeyRing(fxaService, keysData, saltData, 1250, { + predicate: appearsAt(1200), + }); + + let withOnlySaltKey = await extensionStorageSync.ensureCanSync([extensionId, extensionOnlySalt]); + assertKeyRingKey(withOnlySaltKey, extensionId, RANDOM_KEY, + `ensureCanSync shouldn't lose the old key for ${extensionId}`); + // We don't a priori know what the new key is + ok(withOnlySaltKey.hasKeysFor([extensionOnlySalt]), + `ensureCanSync generated a key for an extension that only had a salt`); + + posts = server.getPosts(); + equal(posts.length, 2, "ensureCanSync should push when generating a new key"); + const withNewSaltRecord = await assertPostedEncryptedKeys(fxaService, posts[1]); + equal(withNewSaltRecord.salts[extensionOnlySalt], newSalt, + "ensureCanSync should keep the existing salt when generating only a key"); + }); + }); +}); + +add_task(async function ensureCanSync_handles_conflicts() { + // Syncing is done through a pull followed by a push of any merged + // changes. Accordingly, the only way to have a "true" conflict -- + // i.e. with the server rejecting a change -- is if + // someone pushes changes between our pull and our push. Ensure that + // if this happens, we still behave sensibly (keep the remote key). + const extensionId = uuid(); + const DEFAULT_KEY = new BulkKeyBundle("[default]"); + DEFAULT_KEY.generateRandom(); + const RANDOM_KEY = new BulkKeyBundle(extensionId); + RANDOM_KEY.generateRandom(); + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function(extensionStorageSync, fxaService) { + // FIXME: generating salts probably shouldn't rely on a CryptoCollection + const cryptoCollection = new CryptoCollection(fxaService); + const RANDOM_SALT = cryptoCollection.getNewSalt(); + const keysData = { + "default": DEFAULT_KEY.keyPairB64, + "collections": { + [extensionId]: RANDOM_KEY.keyPairB64, + }, + }; + const saltData = { + [extensionId]: RANDOM_SALT, + }; + server.installKeyRing(fxaService, keysData, saltData, 765, {conflict: true}); + + await extensionStorageSync.cryptoCollection._clear(); + + let collectionKeys = await extensionStorageSync.ensureCanSync([extensionId]); + assertKeyRingKey(collectionKeys, extensionId, RANDOM_KEY, + `syncing keyring should keep the server key for ${extensionId}`); + + let posts = server.getPosts(); + equal(posts.length, 1, + "syncing keyring should have tried to post a keyring"); + const failedPost = posts[0]; + assertPostedNewRecord(failedPost); + let body = await assertPostedEncryptedKeys(fxaService, failedPost); + // This key will be the one the client generated locally, so + // we don't know what its value will be + ok(body.keys.collections[extensionId], + `decrypted failed post should have a key for ${extensionId}`); + notEqual(body.keys.collections[extensionId], RANDOM_KEY.keyPairB64, + `decrypted failed post should have a randomly-generated key for ${extensionId}`); + }); + }); +}); + +add_task(async function ensureCanSync_handles_deleted_conflicts() { + // A keyring can be deleted, and this changes the format of the 412 + // Conflict response from the Kinto server. Make sure we handle it correctly. + const extensionId = uuid(); + const extensionId2 = uuid(); + await withContextAndServer(async function(context, server) { + server.installCollection("storage-sync-crypto"); + server.installDeleteBucket(); + await withSignedInUser(loggedInUser, async function(extensionStorageSync, fxaService) { + server.etag = 700; + await extensionStorageSync.cryptoCollection._clear(); + + // Generate keys that we can check for later. + let collectionKeys = await extensionStorageSync.ensureCanSync([extensionId]); + const extensionKey = collectionKeys.keyForCollection(extensionId); + server.clearPosts(); + + // This is the response that the Kinto server return when the + // keyring has been deleted. + server.addRecord({collectionId: "storage-sync-crypto", conflict: true, transient: true, data: null, etag: 765}); + + // Try to add a new extension to trigger a sync of the keyring. + let collectionKeys2 = await extensionStorageSync.ensureCanSync([extensionId2]); + + assertKeyRingKey(collectionKeys2, extensionId, extensionKey, + `syncing keyring should keep our local key for ${extensionId}`); + + deepEqual(server.getDeletedBuckets(), ["default"], + "Kinto server should have been wiped when keyring was thrown away"); + + let posts = server.getPosts(); + equal(posts.length, 2, + "syncing keyring should have tried to post a keyring twice"); + // The first post got a conflict. + const failedPost = posts[0]; + assertPostedUpdatedRecord(failedPost, 700); + let body = await assertPostedEncryptedKeys(fxaService, failedPost); + + deepEqual(body.keys.collections[extensionId], extensionKey.keyPairB64, + `decrypted failed post should have the key for ${extensionId}`); + + // The second post was after the wipe, and succeeded. + const afterWipePost = posts[1]; + assertPostedNewRecord(afterWipePost); + let afterWipeBody = await assertPostedEncryptedKeys(fxaService, afterWipePost); + + deepEqual(afterWipeBody.keys.collections[extensionId], extensionKey.keyPairB64, + `decrypted new post should have preserved the key for ${extensionId}`); + }); + }); +}); + +add_task(async function ensureCanSync_handles_flushes() { + // One of the ways that bug 1359879 presents is as bug 1350088. This + // seems to be the symptom that results when the user had two + // devices, one of which was not syncing at the time the keyring was + // lost. Ensure we can recover for these users as well. + const extensionId = uuid(); + const extensionId2 = uuid(); + await withContextAndServer(async function(context, server) { + server.installCollection("storage-sync-crypto"); + server.installDeleteBucket(); + await withSignedInUser(loggedInUser, async function(extensionStorageSync, fxaService) { + server.etag = 700; + // Generate keys that we can check for later. + let collectionKeys = await extensionStorageSync.ensureCanSync([extensionId]); + const extensionKey = collectionKeys.keyForCollection(extensionId); + server.clearPosts(); + + // last_modified is new, but there is no data. + server.etag = 800; + + // Try to add a new extension to trigger a sync of the keyring. + let collectionKeys2 = await extensionStorageSync.ensureCanSync([extensionId2]); + + assertKeyRingKey(collectionKeys2, extensionId, extensionKey, + `syncing keyring should keep our local key for ${extensionId}`); + + deepEqual(server.getDeletedBuckets(), ["default"], + "Kinto server should have been wiped when keyring was thrown away"); + + let posts = server.getPosts(); + equal(posts.length, 1, + "syncing keyring should have tried to post a keyring once"); + + const post = posts[0]; + assertPostedNewRecord(post); + let postBody = await assertPostedEncryptedKeys(fxaService, post); + + deepEqual(postBody.keys.collections[extensionId], extensionKey.keyPairB64, + `decrypted new post should have preserved the key for ${extensionId}`); + }); + }); +}); + +add_task(async function checkSyncKeyRing_reuploads_keys() { + // Verify that when keys are present, they are reuploaded with the + // new kB when we call touchKeys(). + const extensionId = uuid(); + let extensionKey, extensionSalt; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function(extensionStorageSync, fxaService) { + server.installCollection("storage-sync-crypto"); + server.etag = 765; + + await extensionStorageSync.cryptoCollection._clear(); + + // Do an `ensureCanSync` to generate some keys. + let collectionKeys = await extensionStorageSync.ensureCanSync([extensionId]); + ok(collectionKeys.hasKeysFor([extensionId]), + `ensureCanSync should return a keyring that has a key for ${extensionId}`); + extensionKey = collectionKeys.keyForCollection(extensionId).keyPairB64; + equal(server.getPosts().length, 1, + "generating a key that doesn't exist on the server should post it"); + const body = await assertPostedEncryptedKeys(fxaService, server.getPosts()[0]); + extensionSalt = body.salts[extensionId]; + }); + + // The user changes their password. This is their new kB, with + // the last f changed to an e. + const NOVEL_KB = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdee"; + const newUser = Object.assign({}, loggedInUser, {kB: NOVEL_KB}); + let postedKeys; + await withSignedInUser(newUser, async function(extensionStorageSync, fxaService) { + await extensionStorageSync.checkSyncKeyRing(); + + let posts = server.getPosts(); + equal(posts.length, 2, + "when kB changes, checkSyncKeyRing should post the keyring reencrypted with the new kB"); + postedKeys = posts[1]; + assertPostedUpdatedRecord(postedKeys, 765); + + let body = await assertPostedEncryptedKeys(fxaService, postedKeys); + deepEqual(body.keys.collections[extensionId], extensionKey, + `the posted keyring should have the same key for ${extensionId} as the old one`); + deepEqual(body.salts[extensionId], extensionSalt, + `the posted keyring should have the same salt for ${extensionId} as the old one`); + }); + + // Verify that with the old kB, we can't decrypt the record. + await withSignedInUser(loggedInUser, async function(extensionStorageSync, fxaService) { + let error; + try { + await new KeyRingEncryptionRemoteTransformer(fxaService).decode(postedKeys.body.data); + } catch (e) { + error = e; + } + ok(error, "decrypting the keyring with the old kB should fail"); + ok(Utils.isHMACMismatch(error) || KeyRingEncryptionRemoteTransformer.isOutdatedKB(error), + "decrypting the keyring with the old kB should throw an HMAC mismatch"); + }); + }); +}); + +add_task(async function checkSyncKeyRing_overwrites_on_conflict() { + // If there is already a record on the server that was encrypted + // with a different kB, we wipe the server, clear sync state, and + // overwrite it with our keys. + const extensionId = uuid(); + let extensionKey; + await withSyncContext(async function(context) { + await withServer(async function(server) { + // The old device has this kB, which is very similar to the + // current kB but with the last f changed to an e. + const NOVEL_KB = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdee"; + const oldUser = Object.assign({}, loggedInUser, {kB: NOVEL_KB}); + server.installDeleteBucket(); + await withSignedInUser(oldUser, async function(extensionStorageSync, fxaService) { + await server.installKeyRing(fxaService, {}, {}, 765); + }); + + // Now we have this new user with a different kB. + await withSignedInUser(loggedInUser, async function(extensionStorageSync, fxaService) { + await extensionStorageSync.cryptoCollection._clear(); + + // Do an `ensureCanSync` to generate some keys. + // This will try to sync, notice that the record is + // undecryptable, and clear the server. + let collectionKeys = await extensionStorageSync.ensureCanSync([extensionId]); + ok(collectionKeys.hasKeysFor([extensionId]), + `ensureCanSync should always return a keyring with a key for ${extensionId}`); + extensionKey = collectionKeys.keyForCollection(extensionId).keyPairB64; + + deepEqual(server.getDeletedBuckets(), ["default"], + "Kinto server should have been wiped when keyring was thrown away"); + + let posts = server.getPosts(); + equal(posts.length, 1, + "new keyring should have been uploaded"); + const postedKeys = posts[0]; + // The POST was to an empty server, so etag shouldn't be respected + equal(postedKeys.headers.Authorization, "Bearer some-access-token", + "keyring upload should be authorized"); + equal(postedKeys.headers["If-None-Match"], "*", + "keyring upload should be to empty Kinto server"); + equal(postedKeys.path, collectionRecordsPath("storage-sync-crypto") + "/keys", + "keyring upload should be to keyring path"); + + let body = await new KeyRingEncryptionRemoteTransformer(fxaService).decode(postedKeys.body.data); + ok(body.uuid, "new keyring should have a UUID"); + equal(typeof body.uuid, "string", "keyring UUIDs should be strings"); + notEqual(body.uuid, "abcd", + "new keyring should not have the same UUID as previous keyring"); + ok(body.keys, + "new keyring should have a keys attribute"); + ok(body.keys.default, "new keyring should have a default key"); + // We should keep the extension key that was in our uploaded version. + deepEqual(extensionKey, body.keys.collections[extensionId], + "ensureCanSync should have returned keyring with the same key that was uploaded"); + + // This should be a no-op; the keys were uploaded as part of ensurekeysfor + await extensionStorageSync.checkSyncKeyRing(); + equal(server.getPosts().length, 1, + "checkSyncKeyRing should not need to post keys after they were reuploaded"); + }); + }); + }); +}); + +add_task(async function checkSyncKeyRing_flushes_on_uuid_change() { + // If we can decrypt the record, but the UUID has changed, that + // means another client has wiped the server and reuploaded a + // keyring, so reset sync state and reupload everything. + const extensionId = uuid(); + const extension = {id: extensionId}; + await withSyncContext(async function(context) { + await withServer(async function(server) { + server.installCollection("storage-sync-crypto"); + server.installDeleteBucket(); + await withSignedInUser(loggedInUser, async function(extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + const transformer = new KeyRingEncryptionRemoteTransformer(fxaService); + await extensionStorageSync.cryptoCollection._clear(); + + // Do an `ensureCanSync` to get access to keys and salt. + let collectionKeys = await extensionStorageSync.ensureCanSync([extensionId]); + const collectionId = await cryptoCollection.extensionIdToCollectionId(extensionId); + server.installCollection(collectionId); + + ok(collectionKeys.hasKeysFor([extensionId]), + `ensureCanSync should always return a keyring that has a key for ${extensionId}`); + const extensionKey = collectionKeys.keyForCollection(extensionId).keyPairB64; + + // Set something to make sure that it gets re-uploaded when + // uuid changes. + await extensionStorageSync.set(extension, {"my-key": 5}, context); + await extensionStorageSync.syncAll(); + + let posts = server.getPosts(); + equal(posts.length, 2, + "should have posted a new keyring and an extension datum"); + const postedKeys = posts[0]; + equal(postedKeys.path, collectionRecordsPath("storage-sync-crypto") + "/keys", + "should have posted keyring to /keys"); + + let body = await transformer.decode(postedKeys.body.data); + ok(body.uuid, + "keyring should have a UUID"); + ok(body.keys, + "keyring should have a keys attribute"); + ok(body.keys.default, + "keyring should have a default key"); + ok(body.salts[extensionId], + `keyring should have a salt for ${extensionId}`); + const extensionSalt = body.salts[extensionId]; + deepEqual(extensionKey, body.keys.collections[extensionId], + "new keyring should have the same key that we uploaded"); + + // Another client comes along and replaces the UUID. + // In real life, this would mean changing the keys too, but + // this test verifies that just changing the UUID is enough. + const newKeyRingData = Object.assign({}, body, { + uuid: "abcd", + // Technically, last_modified should be served outside the + // object, but the transformer will pass it through in + // either direction, so this is OK. + last_modified: 765, + }); + server.etag = 1000; + await server.encryptAndAddRecord(transformer, { + collectionId: "storage-sync-crypto", + data: newKeyRingData, + predicate: appearsAt(800), + }); + + // Fake adding another extension just so that the keyring will + // really get synced. + const newExtension = uuid(); + const newKeyRing = await extensionStorageSync.ensureCanSync([newExtension]); + + // This should have detected the UUID change and flushed everything. + // The keyring should, however, be the same, since we just + // changed the UUID of the previously POSTed one. + deepEqual(newKeyRing.keyForCollection(extensionId).keyPairB64, extensionKey, + "ensureCanSync should have pulled down a new keyring with the same keys"); + + // Syncing should reupload the data for the extension. + await extensionStorageSync.syncAll(); + posts = server.getPosts(); + equal(posts.length, 4, + "should have posted keyring for new extension and reuploaded extension data"); + + const finalKeyRingPost = posts[2]; + const reuploadedPost = posts[3]; + + equal(finalKeyRingPost.path, collectionRecordsPath("storage-sync-crypto") + "/keys", + "keyring for new extension should have been posted to /keys"); + let finalKeyRing = await transformer.decode(finalKeyRingPost.body.data); + equal(finalKeyRing.uuid, "abcd", + "newly uploaded keyring should preserve UUID from replacement keyring"); + deepEqual(finalKeyRing.salts[extensionId], extensionSalt, + "newly uploaded keyring should preserve salts from existing salts"); + + // Confirm that the data got reuploaded + let reuploadedData = await assertExtensionRecord(fxaService, reuploadedPost, extension, "my-key"); + equal(reuploadedData.data, 5, + "extension data should have a data attribute corresponding to the extension data value"); + }); + }); + }); +}); + +add_task(async function test_storage_sync_pulls_changes() { + const extensionId = defaultExtensionId; + const extension = defaultExtension; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function(extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + let transformer = new CollectionKeyEncryptionRemoteTransformer(cryptoCollection, extensionId); + server.installCollection("storage-sync-crypto"); + + let calls = []; + await extensionStorageSync.addOnChangedListener(extension, function() { + calls.push(arguments); + }, context); + + await extensionStorageSync.ensureCanSync([extensionId]); + const collectionId = await cryptoCollection.extensionIdToCollectionId(extensionId); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + "id": "key-remote_2D_key", + "key": "remote-key", + "data": 6, + }, + predicate: appearsAt(850), + }); + server.etag = 900; + + await extensionStorageSync.syncAll(); + const remoteValue = (await extensionStorageSync.get(extension, "remote-key", context))["remote-key"]; + equal(remoteValue, 6, + "ExtensionStorageSync.get() returns value retrieved from sync"); + + equal(calls.length, 1, + "syncing calls on-changed listener"); + deepEqual(calls[0][0], {"remote-key": {newValue: 6}}); + calls = []; + + // Syncing again doesn't do anything + await extensionStorageSync.syncAll(); + + equal(calls.length, 0, + "syncing again shouldn't call on-changed listener"); + + // Updating the server causes us to pull down the new value + server.etag = 1000; + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + "id": "key-remote_2D_key", + "key": "remote-key", + "data": 7, + }, + predicate: appearsAt(950), + }); + + await extensionStorageSync.syncAll(); + const remoteValue2 = (await extensionStorageSync.get(extension, "remote-key", context))["remote-key"]; + equal(remoteValue2, 7, + "ExtensionStorageSync.get() returns value updated from sync"); + + equal(calls.length, 1, + "syncing calls on-changed listener on update"); + deepEqual(calls[0][0], {"remote-key": {oldValue: 6, newValue: 7}}); + }); + }); +}); + +add_task(async function test_storage_sync_pushes_changes() { + // FIXME: This test relies on the fact that previous tests pushed + // keys and salts for the default extension ID + const extension = defaultExtension; + const extensionId = defaultExtensionId; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function(extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + const collectionId = await cryptoCollection.extensionIdToCollectionId(extensionId); + server.installCollection(collectionId); + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + await extensionStorageSync.set(extension, {"my-key": 5}, context); + + // install this AFTER we set the key to 5... + let calls = []; + extensionStorageSync.addOnChangedListener(extension, function() { + calls.push(arguments); + }, context); + + await extensionStorageSync.syncAll(); + const localValue = (await extensionStorageSync.get(extension, "my-key", context))["my-key"]; + equal(localValue, 5, + "pushing an ExtensionStorageSync value shouldn't change local value"); + const hashedId = "id-" + (await cryptoCollection.hashWithExtensionSalt("key-my_2D_key", extensionId)); + + let posts = server.getPosts(); + // FIXME: Keys were pushed in a previous test + equal(posts.length, 1, + "pushing a value should cause a post to the server"); + const post = posts[0]; + assertPostedNewRecord(post); + equal(post.path, `${collectionRecordsPath(collectionId)}/${hashedId}`, + "pushing a value should have a path corresponding to its id"); + + const encrypted = post.body.data; + ok(encrypted.ciphertext, + "pushing a value should post an encrypted record"); + ok(!encrypted.data, + "pushing a value should not have any plaintext data"); + equal(encrypted.id, hashedId, + "pushing a value should use a kinto-friendly record ID"); + + const record = await assertExtensionRecord(fxaService, post, extension, "my-key"); + equal(record.data, 5, + "when decrypted, a pushed value should have a data field corresponding to its storage.sync value"); + equal(record.id, "key-my_2D_key", + "when decrypted, a pushed value should have an id field corresponding to its record ID"); + + equal(calls.length, 0, + "pushing a value shouldn't call the on-changed listener"); + + await extensionStorageSync.set(extension, {"my-key": 6}, context); + await extensionStorageSync.syncAll(); + + // Doesn't push keys because keys were pushed by a previous test. + posts = server.getPosts(); + equal(posts.length, 2, + "updating a value should trigger another push"); + const updatePost = posts[1]; + assertPostedUpdatedRecord(updatePost, 1000); + equal(updatePost.path, `${collectionRecordsPath(collectionId)}/${hashedId}`, + "pushing an updated value should go to the same path"); + + const updateEncrypted = updatePost.body.data; + ok(updateEncrypted.ciphertext, + "pushing an updated value should still be encrypted"); + ok(!updateEncrypted.data, + "pushing an updated value should not have any plaintext visible"); + equal(updateEncrypted.id, hashedId, + "pushing an updated value should maintain the same ID"); + }); + }); +}); + +add_task(async function test_storage_sync_retries_failed_auth() { + const extensionId = uuid(); + const extension = {id: extensionId}; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function(extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + let transformer = new CollectionKeyEncryptionRemoteTransformer(cryptoCollection, extensionId); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.ensureCanSync([extensionId]); + await extensionStorageSync.set(extension, {"my-key": 5}, context); + const collectionId = await cryptoCollection.extensionIdToCollectionId(extensionId); + // Put a remote record just to verify that eventually we succeeded + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + "id": "key-remote_2D_key", + "key": "remote-key", + "data": 6, + }, + predicate: appearsAt(850), + }); + server.etag = 900; + + // This is a typical response from a production stack if your + // bearer token is bad. + server.rejectNextAuthWith("{\"code\": 401, \"errno\": 104, \"error\": \"Unauthorized\", \"message\": \"Please authenticate yourself to use this endpoint\"}"); + await extensionStorageSync.syncAll(); + + equal(server.failedAuths.length, 1, + "an auth was failed"); + + const remoteValue = (await extensionStorageSync.get(extension, "remote-key", context))["remote-key"]; + equal(remoteValue, 6, + "ExtensionStorageSync.get() returns value retrieved from sync"); + + + // Try again with an emptier JSON body to make sure this still + // works with a less-cooperative server. + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + "id": "key-remote_2D_key", + "key": "remote-key", + "data": 7, + }, + predicate: appearsAt(950), + }); + server.etag = 1000; + // Need to write a JSON response. + // kinto.js 9.0.2 doesn't throw unless there's json. + // See https://github.com/Kinto/kinto-http.js/issues/192. + server.rejectNextAuthWith("{}"); + + await extensionStorageSync.syncAll(); + + equal(server.failedAuths.length, 2, + "an auth was failed"); + + const newRemoteValue = (await extensionStorageSync.get(extension, "remote-key", context))["remote-key"]; + equal(newRemoteValue, 7, + "ExtensionStorageSync.get() returns value retrieved from sync"); + }); + }); +}); + +add_task(async function test_storage_sync_pulls_conflicts() { + const extensionId = uuid(); + const extension = {id: extensionId}; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function(extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + let transformer = new CollectionKeyEncryptionRemoteTransformer(cryptoCollection, extensionId); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.ensureCanSync([extensionId]); + const collectionId = await cryptoCollection.extensionIdToCollectionId(extensionId); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + "id": "key-remote_2D_key", + "key": "remote-key", + "data": 6, + }, + predicate: appearsAt(850), + }); + server.etag = 900; + + await extensionStorageSync.set(extension, {"remote-key": 8}, context); + + let calls = []; + await extensionStorageSync.addOnChangedListener(extension, function() { + calls.push(arguments); + }, context); + + await extensionStorageSync.syncAll(); + const remoteValue = (await extensionStorageSync.get(extension, "remote-key", context))["remote-key"]; + equal(remoteValue, 8, + "locally set value overrides remote value"); + + equal(calls.length, 1, + "conflicts manifest in on-changed listener"); + deepEqual(calls[0][0], {"remote-key": {newValue: 8}}); + calls = []; + + // Syncing again doesn't do anything + await extensionStorageSync.syncAll(); + + equal(calls.length, 0, + "syncing again shouldn't call on-changed listener"); + + // Updating the server causes us to pull down the new value + server.etag = 1000; + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + "id": "key-remote_2D_key", + "key": "remote-key", + "data": 7, + }, + predicate: appearsAt(950), + }); + + await extensionStorageSync.syncAll(); + const remoteValue2 = (await extensionStorageSync.get(extension, "remote-key", context))["remote-key"]; + equal(remoteValue2, 7, + "conflicts do not prevent retrieval of new values"); + + equal(calls.length, 1, + "syncing calls on-changed listener on update"); + deepEqual(calls[0][0], {"remote-key": {oldValue: 8, newValue: 7}}); + }); + }); +}); + +add_task(async function test_storage_sync_pulls_deletes() { + const extension = defaultExtension; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function(extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + const collectionId = await cryptoCollection.extensionIdToCollectionId(defaultExtensionId); + server.installCollection(collectionId); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.set(extension, {"my-key": 5}, context); + await extensionStorageSync.syncAll(); + server.clearPosts(); + + let calls = []; + await extensionStorageSync.addOnChangedListener(extension, function() { + calls.push(arguments); + }, context); + + const transformer = new CollectionKeyEncryptionRemoteTransformer(new CryptoCollection(fxaService), extension.id); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + "id": "key-my_2D_key", + "data": 6, + "_status": "deleted", + }, + }); + + await extensionStorageSync.syncAll(); + const remoteValues = (await extensionStorageSync.get(extension, "my-key", context)); + ok(!remoteValues["my-key"], + "ExtensionStorageSync.get() shows value was deleted by sync"); + + equal(server.getPosts().length, 0, + "pulling the delete shouldn't cause posts"); + + equal(calls.length, 1, + "syncing calls on-changed listener"); + deepEqual(calls[0][0], {"my-key": {oldValue: 5}}); + calls = []; + + // Syncing again doesn't do anything + await extensionStorageSync.syncAll(); + + equal(calls.length, 0, + "syncing again shouldn't call on-changed listener"); + }); + }); +}); + +add_task(async function test_storage_sync_pushes_deletes() { + const extensionId = uuid(); + const extension = {id: extensionId}; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function(extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + await cryptoCollection._clear(); + await cryptoCollection._setSalt(extensionId, cryptoCollection.getNewSalt()); + const collectionId = await cryptoCollection.extensionIdToCollectionId(extensionId); + + server.installCollection(collectionId); + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + await extensionStorageSync.set(extension, {"my-key": 5}, context); + + let calls = []; + extensionStorageSync.addOnChangedListener(extension, function() { + calls.push(arguments); + }, context); + + await extensionStorageSync.syncAll(); + let posts = server.getPosts(); + equal(posts.length, 2, + "pushing a non-deleted value should post keys and post the value to the server"); + + await extensionStorageSync.remove(extension, ["my-key"], context); + equal(calls.length, 1, + "deleting a value should call the on-changed listener"); + + await extensionStorageSync.syncAll(); + equal(calls.length, 1, + "pushing a deleted value shouldn't call the on-changed listener"); + + // Doesn't push keys because keys were pushed by a previous test. + const hashedId = "id-" + (await cryptoCollection.hashWithExtensionSalt("key-my_2D_key", extensionId)); + posts = server.getPosts(); + equal(posts.length, 3, + "deleting a value should trigger another push"); + const post = posts[2]; + assertPostedUpdatedRecord(post, 1000); + equal(post.path, `${collectionRecordsPath(collectionId)}/${hashedId}`, + "pushing a deleted value should go to the same path"); + ok(post.method, "PUT"); + ok(post.body.data.ciphertext, + "deleting a value should have an encrypted body"); + const decoded = await new CollectionKeyEncryptionRemoteTransformer(cryptoCollection, extensionId).decode(post.body.data); + equal(decoded._status, "deleted"); + // Ideally, we'd check that decoded.deleted is not true, because + // the encrypted record shouldn't have it, but the decoder will + // add it when it sees _status == deleted + }); + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_crypto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_crypto.js new file mode 100644 index 0000000000..527cad24fd --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_crypto.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + EncryptionRemoteTransformer, +} = Cu.import("resource://gre/modules/ExtensionStorageSync.jsm", {}); +Cu.import("resource://services-crypto/utils.js"); +Cu.import("resource://services-sync/util.js"); + +/** + * Like Assert.throws, but for generators. + * + * @param {string | Object | function} constraint + * What to use to check the exception. + * @param {function} f + * The function to call. + */ +async function throwsGen(constraint, f) { + let threw = false; + let exception; + try { + await f(); + } catch (e) { + threw = true; + exception = e; + } + + ok(threw, "did not throw an exception"); + + const debuggingMessage = `got ${exception}, expected ${constraint}`; + let message = exception; + if (typeof exception === "object") { + message = exception.message; + } + + if (typeof constraint === "function") { + ok(constraint(message), debuggingMessage); + } else { + ok(constraint === message, debuggingMessage); + } +} + +/** + * An EncryptionRemoteTransformer that uses a fixed key bundle, + * suitable for testing. + */ +class StaticKeyEncryptionRemoteTransformer extends EncryptionRemoteTransformer { + constructor(keyBundle) { + super(); + this.keyBundle = keyBundle; + } + + getKeys() { + return Promise.resolve(this.keyBundle); + } +} +const BORING_KB = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const STRETCHED_KEY = CryptoUtils.hkdf(BORING_KB, undefined, `testing storage.sync encryption`, 2 * 32); +const KEY_BUNDLE = { + sha256HMACHasher: Utils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256, Utils.makeHMACKey(STRETCHED_KEY.slice(0, 32))), + encryptionKeyB64: btoa(STRETCHED_KEY.slice(32, 64)), +}; +const transformer = new StaticKeyEncryptionRemoteTransformer(KEY_BUNDLE); + +add_task(async function test_encryption_transformer_roundtrip() { + const POSSIBLE_DATAS = [ + "string", + 2, // number + [1, 2, 3], // array + {key: "value"}, // object + ]; + + for (let data of POSSIBLE_DATAS) { + const record = {data, id: "key-some_2D_key", key: "some-key"}; + + deepEqual(record, await transformer.decode(await transformer.encode(record))); + } +}); + +add_task(async function test_refuses_to_decrypt_tampered() { + const encryptedRecord = await transformer.encode({data: [1, 2, 3], id: "key-some_2D_key", key: "some-key"}); + const tamperedHMAC = Object.assign({}, encryptedRecord, {hmac: "0000000000000000000000000000000000000000000000000000000000000001"}); + await throwsGen(Utils.isHMACMismatch, async function() { + await transformer.decode(tamperedHMAC); + }); + + const tamperedIV = Object.assign({}, encryptedRecord, {IV: "aaaaaaaaaaaaaaaaaaaaaa=="}); + await throwsGen(Utils.isHMACMismatch, async function() { + await transformer.decode(tamperedIV); + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_themes_supported_properties.js b/toolkit/components/extensions/test/xpcshell/test_ext_themes_supported_properties.js new file mode 100644 index 0000000000..903114714c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_themes_supported_properties.js @@ -0,0 +1,70 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Cu.import("resource://gre/modules/Schemas.jsm"); + +const { + validateThemeManifest, +} = Cu.import("resource://gre/modules/Extension.jsm", {}); + +const BASE_SCHEMA_URL = "chrome://extensions/content/schemas/manifest.json"; +const CATEGORY_EXTENSION_SCHEMAS = "webextension-schemas"; + +const baseManifestProperties = [ + "manifest_version", + "minimum_chrome_version", + "applications", + "browser_specific_settings", + "name", + "short_name", + "description", + "author", + "version", + "homepage_url", + "icons", + "incognito", + "background", + "options_ui", + "content_scripts", + "permissions", + "web_accessible_resources", + "developer", +]; + +async function getAdditionalInvalidManifestProperties() { + let invalidProperties = []; + await Schemas.load(BASE_SCHEMA_URL); + for (let [name, url] of XPCOMUtils.enumerateCategoryEntries(CATEGORY_EXTENSION_SCHEMAS)) { + if (name !== "theme") { + await Schemas.load(url); + let types = Schemas.schemaJSON.get(url)[0].types; + types.forEach(type => { + if (type.$extend == "WebExtensionManifest") { + let properties = Object.getOwnPropertyNames(type.properties); + invalidProperties.push(...properties); + } + }); + } + } + + // Also test an unrecognized property. + invalidProperties.push("unrecognized_property"); + + return invalidProperties; +} + +function checkProperties(actual, expected) { + Assert.equal(actual.length, expected.length, `Should have found ${expected.length} invalid properties`); + for (let i = 0; i < expected.length; i++) { + Assert.ok(actual.includes(expected[i]), `The invalid properties should contain "${expected[i]}"`); + } +} + +add_task(async function test_theme_supported_properties() { + let additionalInvalidProperties = await getAdditionalInvalidManifestProperties(); + let actual = validateThemeManifest([...baseManifestProperties, ...additionalInvalidProperties]); + let expected = ["background", "permissions", "content_scripts", ...additionalInvalidProperties]; + checkProperties(actual, expected); +}); + diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_topSites.js b/toolkit/components/extensions/test/xpcshell/test_ext_topSites.js new file mode 100644 index 0000000000..b31c22d363 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_topSites.js @@ -0,0 +1,121 @@ +"use strict"; + +Cu.import("resource://gre/modules/NewTabUtils.jsm"); + + +function TestProvider(getLinksFn) { + this.getLinks = getLinksFn; + this._observers = new Set(); +} + +TestProvider.prototype = { + addObserver: function(observer) { + this._observers.add(observer); + }, + notifyLinkChanged: function(link, index = -1, deleted = false) { + this._notifyObservers("onLinkChanged", link, index, deleted); + }, + notifyManyLinksChanged: function() { + this._notifyObservers("onManyLinksChanged"); + }, + _notifyObservers: function(observerMethodName, ...args) { + args.unshift(this); + for (let obs of this._observers) { + if (obs[observerMethodName]) { + obs[observerMethodName].apply(NewTabUtils.links, args); + } + } + }, +}; + +add_task(async function test_topSites() { + // Important: To avoid test failures due to clock jitter on Windows XP, call + // Date.now() once here, not each time through the loop. + let now = Date.now() * 1000; + let provider1 = new TestProvider(done => { + let data = [{url: "http://example.com/", title: "site#-1", frecency: 9, lastVisitDate: now}, + {url: "http://example0.com/", title: "site#0", frecency: 8, lastVisitDate: now}, + {url: "http://example3.com/", title: "site#3", frecency: 5, lastVisitDate: now}]; + done(data); + }); + let provider2 = new TestProvider(done => { + let data = [{url: "http://example1.com/", title: "site#1", frecency: 7, lastVisitDate: now}, + {url: "http://example2.com/", title: "site#2", frecency: 6, lastVisitDate: now}]; + done(data); + }); + + NewTabUtils.initWithoutProviders(); + NewTabUtils.links.addProvider(provider1); + NewTabUtils.links.addProvider(provider2); + NewTabUtils.test1Provider = provider1; + NewTabUtils.test2Provider = provider2; + + // Test that results from all providers are returned by default. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": [ + "topSites", + ], + }, + background() { + // Tests consistent behaviour when no providers are specified. + browser.topSites.get(result => { + browser.test.sendMessage("done1", result); + }); + browser.topSites.get({}, result => { + browser.test.sendMessage("done2", result); + }); + browser.topSites.get({providers: []}, result => { + browser.test.sendMessage("done3", result); + }); + // Tests that results are merged correctly. + browser.topSites.get({providers: ["test2", "test1"]}, result => { + browser.test.sendMessage("done4", result); + }); + // Tests that only the specified provider is used. + browser.topSites.get({providers: ["test2"]}, result => { + browser.test.sendMessage("done5", result); + }); + // Tests that specifying a non-existent provider returns an empty array. + browser.topSites.get({providers: ["fake"]}, result => { + browser.test.sendMessage("done6", result); + }); + }, + }); + + await extension.startup(); + + let expected1 = [{url: "http://example.com/", title: "site#-1"}, + {url: "http://example0.com/", title: "site#0"}, + {url: "http://example1.com/", title: "site#1"}, + {url: "http://example2.com/", title: "site#2"}, + {url: "http://example3.com/", title: "site#3"}]; + + let actual1 = await extension.awaitMessage("done1"); + Assert.deepEqual(expected1, actual1, "got topSites"); + + let actual2 = await extension.awaitMessage("done2"); + Assert.deepEqual(expected1, actual2, "got topSites"); + + let actual3 = await extension.awaitMessage("done3"); + Assert.deepEqual(expected1, actual3, "got topSites"); + + let actual4 = await extension.awaitMessage("done4"); + Assert.deepEqual(expected1, actual4, "got topSites"); + + let expected5 = [{url: "http://example1.com/", title: "site#1"}, + {url: "http://example2.com/", title: "site#2"}]; + + let actual5 = await extension.awaitMessage("done5"); + Assert.deepEqual(expected5, actual5, "got topSites"); + + let actual6 = await extension.awaitMessage("done6"); + Assert.deepEqual([], actual6, "got topSites"); + + await extension.unload(); + + NewTabUtils.links.removeProvider(provider1); + NewTabUtils.links.removeProvider(provider2); + delete NewTabUtils.test1Provider; + delete NewTabUtils.test2Provider; +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js new file mode 100644 index 0000000000..302899d111 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js @@ -0,0 +1,30 @@ +"use strict"; + +add_task(async function test_unknown_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "activeTab", + "fooUnknownPermission", + "http://*/", + "chrome://favicon/", + ], + }, + }); + + let {messages} = await promiseConsoleOutput( + () => extension.startup()); + + const {WebExtensionPolicy} = Cu.import("resource://gre/modules/Extension.jsm", {}); + + let policy = WebExtensionPolicy.getByID(extension.id); + Assert.deepEqual(Array.from(policy.permissions).sort(), ["activeTab", "http://*/*"]); + + ok(messages.some(message => /Error processing permissions\.1: Value "fooUnknownPermission" must/.test(message)), + 'Got expected error for "fooUnknownPermission"'); + + ok(messages.some(message => /Error processing permissions\.3: Value "chrome:\/\/favicon\/" must/.test(message)), + 'Got expected error for "chrome://favicon/"'); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_locale_converter.js b/toolkit/components/extensions/test/xpcshell/test_locale_converter.js new file mode 100644 index 0000000000..9d667ea317 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_locale_converter.js @@ -0,0 +1,133 @@ +"use strict"; + +const convService = Cc["@mozilla.org/streamConverters;1"] + .getService(Ci.nsIStreamConverterService); + +const UUID = "72b61ee3-aceb-476c-be1b-0822b036c9f1"; +const ADDON_ID = "test@web.extension"; +const URI = NetUtil.newURI(`moz-extension://${UUID}/file.css`); + +const FROM_TYPE = "application/vnd.mozilla.webext.unlocalized"; +const TO_TYPE = "text/css"; + + +function StringStream(string) { + let stream = Cc["@mozilla.org/io/string-input-stream;1"] + .createInstance(Ci.nsIStringInputStream); + + stream.data = string; + return stream; +} + + +// Initialize the policy service with a stub localizer for our +// add-on ID. +add_task(async function init() { + let policy = new WebExtensionPolicy({ + id: ADDON_ID, + mozExtensionHostname: UUID, + baseURL: "file:///", + + allowedOrigins: new MatchPatternSet([]), + + localizeCallback(string) { + return string.replace(/__MSG_(.*?)__/g, ""); + }, + }); + + policy.active = true; + + do_register_cleanup(() => { + policy.active = false; + }); +}); + + +// Test that the synchronous converter works as expected with a +// simple string. +add_task(async function testSynchronousConvert() { + let stream = StringStream("Foo __MSG_xxx__ bar __MSG_yyy__ baz"); + + let resultStream = convService.convert(stream, FROM_TYPE, TO_TYPE, URI); + + let result = NetUtil.readInputStreamToString(resultStream, resultStream.available()); + + equal(result, "Foo bar baz"); +}); + + +// Test that the asynchronous converter works as expected with input +// split into multiple chunks, and a boundary in the middle of a +// replacement token. +add_task(async function testAsyncConvert() { + let listener; + let awaitResult = new Promise((resolve, reject) => { + listener = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIStreamListener]), + + onDataAvailable(request, context, inputStream, offset, count) { + this.resultParts.push(NetUtil.readInputStreamToString(inputStream, count)); + }, + + onStartRequest() { + ok(!("resultParts" in this)); + this.resultParts = []; + }, + + onStopRequest(request, context, statusCode) { + if (!Components.isSuccessCode(statusCode)) { + reject(new Error(statusCode)); + } + + resolve(this.resultParts.join("\n")); + }, + }; + }); + + let parts = ["Foo __MSG_x", "xx__ bar __MSG_yyy__ baz"]; + + let converter = convService.asyncConvertData(FROM_TYPE, TO_TYPE, listener, URI); + converter.onStartRequest(null, null); + + for (let part of parts) { + converter.onDataAvailable(null, null, StringStream(part), 0, part.length); + } + + converter.onStopRequest(null, null, Cr.NS_OK); + + + let result = await awaitResult; + equal(result, "Foo bar baz"); +}); + + +// Test that attempting to initialize a converter with the URI of a +// nonexistent WebExtension fails. +add_task(async function testInvalidUUID() { + let uri = NetUtil.newURI("moz-extension://eb4f3be8-41c9-4970-aa6d-b84d1ecc02b2/file.css"); + let stream = StringStream("Foo __MSG_xxx__ bar __MSG_yyy__ baz"); + + // Assert.throws raise a TypeError exception when the expected param + // is an arrow function. (See Bug 1237961 for rationale) + let expectInvalidContextException = function(e) { + return e.result === Cr.NS_ERROR_INVALID_ARG && /Invalid context/.test(e); + }; + + Assert.throws(() => { + convService.convert(stream, FROM_TYPE, TO_TYPE, uri); + }, expectInvalidContextException); + + Assert.throws(() => { + let listener = {QueryInterface: XPCOMUtils.generateQI([Ci.nsIStreamListener])}; + + convService.asyncConvertData(FROM_TYPE, TO_TYPE, listener, uri); + }, expectInvalidContextException); +}); + + +// Test that an empty stream does not throw an NS_ERROR_ILLEGAL_VALUE. +add_task(async function testEmptyStream() { + let stream = StringStream(""); + let resultStream = convService.convert(stream, FROM_TYPE, TO_TYPE, URI); + equal(resultStream.data, ""); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_locale_data.js b/toolkit/components/extensions/test/xpcshell/test_locale_data.js new file mode 100644 index 0000000000..11e092b75a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_locale_data.js @@ -0,0 +1,130 @@ +"use strict"; + +Cu.import("resource://gre/modules/Extension.jsm"); + +/* globals ExtensionData */ + +const uuidGenerator = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator); + +async function generateAddon(data) { + let id = uuidGenerator.generateUUID().number; + + data = Object.assign({embedded: true}, data); + data.manifest = Object.assign({applications: {gecko: {id}}}, data.manifest); + + let xpi = Extension.generateXPI(data); + do_register_cleanup(() => { + Services.obs.notifyObservers(xpi, "flush-cache-entry"); + xpi.remove(false); + }); + + let fileURI = Services.io.newFileURI(xpi); + let jarURI = NetUtil.newURI(`jar:${fileURI.spec}!/webextension/`); + + let extension = new ExtensionData(jarURI); + await extension.loadManifest(); + + return extension; +} + +add_task(async function testMissingDefaultLocale() { + let extension = await generateAddon({ + "files": { + "_locales/en_US/messages.json": {}, + }, + }); + + equal(extension.errors.length, 0, "No errors reported"); + + await extension.initAllLocales(); + + equal(extension.errors.length, 1, "One error reported"); + + do_print(`Got error: ${extension.errors[0]}`); + + ok(extension.errors[0].includes('"default_locale" property is required'), + "Got missing default_locale error"); +}); + + +add_task(async function testInvalidDefaultLocale() { + let extension = await generateAddon({ + "manifest": { + "default_locale": "en", + }, + + "files": { + "_locales/en_US/messages.json": {}, + }, + }); + + equal(extension.errors.length, 1, "One error reported"); + + do_print(`Got error: ${extension.errors[0]}`); + + ok(extension.errors[0].includes("Loading locale file _locales/en/messages.json"), + "Got invalid default_locale error"); + + await extension.initAllLocales(); + + equal(extension.errors.length, 2, "Two errors reported"); + + do_print(`Got error: ${extension.errors[1]}`); + + ok(extension.errors[1].includes('"default_locale" property must correspond'), + "Got invalid default_locale error"); +}); + + +add_task(async function testUnexpectedDefaultLocale() { + let extension = await generateAddon({ + "manifest": { + "default_locale": "en_US", + }, + }); + + equal(extension.errors.length, 1, "One error reported"); + + do_print(`Got error: ${extension.errors[0]}`); + + ok(extension.errors[0].includes("Loading locale file _locales/en-US/messages.json"), + "Got invalid default_locale error"); + + await extension.initAllLocales(); + + equal(extension.errors.length, 2, "One error reported"); + + do_print(`Got error: ${extension.errors[1]}`); + + ok(extension.errors[1].includes('"default_locale" property must correspond'), + "Got unexpected default_locale error"); +}); + + +add_task(async function testInvalidSyntax() { + let extension = await generateAddon({ + "manifest": { + "default_locale": "en_US", + }, + + "files": { + "_locales/en_US/messages.json": '{foo: {message: "bar", description: "baz"}}', + }, + }); + + equal(extension.errors.length, 1, "No errors reported"); + + do_print(`Got error: ${extension.errors[0]}`); + + ok(extension.errors[0].includes("Loading locale file _locales\/en_US\/messages\.json: SyntaxError"), + "Got syntax error"); + + await extension.initAllLocales(); + + equal(extension.errors.length, 2, "One error reported"); + + do_print(`Got error: ${extension.errors[1]}`); + + ok(extension.errors[1].includes("Loading locale file _locales\/en_US\/messages\.json: SyntaxError"), + "Got syntax error"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_native_messaging.js b/toolkit/components/extensions/test/xpcshell/test_native_messaging.js new file mode 100644 index 0000000000..dc2aedcdb4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_native_messaging.js @@ -0,0 +1,302 @@ +"use strict"; + +/* global OS, HostManifestManager, NativeApp */ +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/AsyncShutdown.jsm"); +Cu.import("resource://gre/modules/ExtensionCommon.jsm"); +Cu.import("resource://gre/modules/FileUtils.jsm"); +Cu.import("resource://gre/modules/Schemas.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +const {Subprocess, SubprocessImpl} = Cu.import("resource://gre/modules/Subprocess.jsm", {}); +Cu.import("resource://gre/modules/NativeMessaging.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); + +let registry = null; +if (AppConstants.platform == "win") { + Cu.import("resource://testing-common/MockRegistry.jsm"); + registry = new MockRegistry(); + do_register_cleanup(() => { + registry.shutdown(); + }); +} + +const REGPATH = "Software\\Mozilla\\NativeMessagingHosts"; + +const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json"; + +let dir = FileUtils.getDir("TmpD", ["NativeMessaging"]); +dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +let userDir = dir.clone(); +userDir.append("user"); +userDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +let globalDir = dir.clone(); +globalDir.append("global"); +globalDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +let dirProvider = { + getFile(property) { + if (property == "XREUserNativeMessaging") { + return userDir.clone(); + } else if (property == "XRESysNativeMessaging") { + return globalDir.clone(); + } + return null; + }, +}; + +Services.dirsvc.registerProvider(dirProvider); + +do_register_cleanup(() => { + Services.dirsvc.unregisterProvider(dirProvider); + dir.remove(true); +}); + +function writeManifest(path, manifest) { + if (typeof manifest != "string") { + manifest = JSON.stringify(manifest); + } + return OS.File.writeAtomic(path, manifest); +} + +let PYTHON; +add_task(async function setup() { + await Schemas.load(BASE_SCHEMA); + + PYTHON = await Subprocess.pathSearch("python2.7"); + if (PYTHON == null) { + PYTHON = await Subprocess.pathSearch("python"); + } + notEqual(PYTHON, null, "Found a suitable python interpreter"); +}); + +let global = this; + +// Test of HostManifestManager.lookupApplication() begin here... +let context = { + url: null, + jsonStringify(...args) { return JSON.stringify(...args); }, + cloneScope: global, + logError() {}, + preprocessors: {}, + callOnClose: () => {}, + forgetOnClose: () => {}, +}; + +class MockContext extends ExtensionCommon.BaseContext { + constructor(extensionId) { + let fakeExtension = {id: extensionId}; + super("testEnv", fakeExtension); + this.sandbox = Cu.Sandbox(global); + } + + get cloneScope() { + return global; + } + + get principal() { + return Cu.getObjectPrincipal(this.sandbox); + } +} + +let templateManifest = { + name: "test", + description: "this is only a test", + path: "/bin/cat", + type: "stdio", + allowed_extensions: ["extension@tests.mozilla.org"], +}; + +add_task(async function test_nonexistent_manifest() { + let result = await HostManifestManager.lookupApplication("test", context); + equal(result, null, "lookupApplication returns null for non-existent application"); +}); + +const USER_TEST_JSON = OS.Path.join(userDir.path, "test.json"); + +add_task(async function test_good_manifest() { + await writeManifest(USER_TEST_JSON, templateManifest); + if (registry) { + registry.setValue(Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, "", USER_TEST_JSON); + } + + let result = await HostManifestManager.lookupApplication("test", context); + notEqual(result, null, "lookupApplication finds a good manifest"); + equal(result.path, USER_TEST_JSON, "lookupApplication returns the correct path"); + deepEqual(result.manifest, templateManifest, "lookupApplication returns the manifest contents"); +}); + +add_task(async function test_invalid_json() { + await writeManifest(USER_TEST_JSON, "this is not valid json"); + let result = await HostManifestManager.lookupApplication("test", context); + equal(result, null, "lookupApplication ignores bad json"); +}); + +add_task(async function test_invalid_name() { + let manifest = Object.assign({}, templateManifest); + manifest.name = "../test"; + await writeManifest(USER_TEST_JSON, manifest); + let result = await HostManifestManager.lookupApplication("test", context); + equal(result, null, "lookupApplication ignores an invalid name"); +}); + +add_task(async function test_name_mismatch() { + let manifest = Object.assign({}, templateManifest); + manifest.name = "not test"; + await writeManifest(USER_TEST_JSON, manifest); + let result = await HostManifestManager.lookupApplication("test", context); + let what = (AppConstants.platform == "win") ? "registry key" : "json filename"; + equal(result, null, `lookupApplication ignores mistmatch between ${what} and name property`); +}); + +add_task(async function test_missing_props() { + const PROPS = [ + "name", + "description", + "path", + "type", + "allowed_extensions", + ]; + for (let prop of PROPS) { + let manifest = Object.assign({}, templateManifest); + delete manifest[prop]; + + await writeManifest(USER_TEST_JSON, manifest); + let result = await HostManifestManager.lookupApplication("test", context); + equal(result, null, `lookupApplication ignores missing ${prop}`); + } +}); + +add_task(async function test_invalid_type() { + let manifest = Object.assign({}, templateManifest); + manifest.type = "bogus"; + await writeManifest(USER_TEST_JSON, manifest); + let result = await HostManifestManager.lookupApplication("test", context); + equal(result, null, "lookupApplication ignores invalid type"); +}); + +add_task(async function test_no_allowed_extensions() { + let manifest = Object.assign({}, templateManifest); + manifest.allowed_extensions = []; + await writeManifest(USER_TEST_JSON, manifest); + let result = await HostManifestManager.lookupApplication("test", context); + equal(result, null, "lookupApplication ignores manifest with no allowed_extensions"); +}); + +const GLOBAL_TEST_JSON = OS.Path.join(globalDir.path, "test.json"); +let globalManifest = Object.assign({}, templateManifest); +globalManifest.description = "This manifest is from the systemwide directory"; + +add_task(async function good_manifest_system_dir() { + await OS.File.remove(USER_TEST_JSON); + await writeManifest(GLOBAL_TEST_JSON, globalManifest); + if (registry) { + registry.setValue(Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, "", null); + registry.setValue(Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE, + `${REGPATH}\\test`, "", GLOBAL_TEST_JSON); + } + + let where = (AppConstants.platform == "win") ? "registry location" : "directory"; + let result = await HostManifestManager.lookupApplication("test", context); + notEqual(result, null, `lookupApplication finds a manifest in the system-wide ${where}`); + equal(result.path, GLOBAL_TEST_JSON, `lookupApplication returns path in the system-wide ${where}`); + deepEqual(result.manifest, globalManifest, `lookupApplication returns manifest contents from the system-wide ${where}`); +}); + +add_task(async function test_user_dir_precedence() { + await writeManifest(USER_TEST_JSON, templateManifest); + if (registry) { + registry.setValue(Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, "", USER_TEST_JSON); + } + // global test.json and LOCAL_MACHINE registry key on windows are + // still present from the previous test + + let result = await HostManifestManager.lookupApplication("test", context); + notEqual(result, null, "lookupApplication finds a manifest when entries exist in both user-specific and system-wide locations"); + equal(result.path, USER_TEST_JSON, "lookupApplication returns the user-specific path when user-specific and system-wide entries both exist"); + deepEqual(result.manifest, templateManifest, "lookupApplication returns user-specific manifest contents with user-specific and system-wide entries both exist"); +}); + +// Test shutdown handling in NativeApp +add_task(async function test_native_app_shutdown() { + const SCRIPT = String.raw` +import signal +import struct +import sys + +signal.signal(signal.SIGTERM, signal.SIG_IGN) + +while True: + rawlen = sys.stdin.read(4) + if len(rawlen) == 0: + signal.pause() + msglen = struct.unpack('@I', rawlen)[0] + msg = sys.stdin.read(msglen) + + sys.stdout.write(struct.pack('@I', msglen)) + sys.stdout.write(msg) + `; + + let scriptPath = OS.Path.join(userDir.path, "wontdie.py"); + let manifestPath = OS.Path.join(userDir.path, "wontdie.json"); + + const ID = "native@tests.mozilla.org"; + let manifest = { + name: "wontdie", + description: "test async shutdown of native apps", + type: "stdio", + allowed_extensions: [ID], + }; + + if (AppConstants.platform == "win") { + await OS.File.writeAtomic(scriptPath, SCRIPT); + + let batPath = OS.Path.join(userDir.path, "wontdie.bat"); + let batBody = `@ECHO OFF\n${PYTHON} -u "${scriptPath}" %*\n`; + await OS.File.writeAtomic(batPath, batBody); + await OS.File.setPermissions(batPath, {unixMode: 0o755}); + + manifest.path = batPath; + await writeManifest(manifestPath, manifest); + + registry.setValue(Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\wontdie`, "", manifestPath); + } else { + await OS.File.writeAtomic(scriptPath, `#!${PYTHON} -u\n${SCRIPT}`); + await OS.File.setPermissions(scriptPath, {unixMode: 0o755}); + manifest.path = scriptPath; + await writeManifest(manifestPath, manifest); + } + + let mockContext = new MockContext(ID); + let app = new NativeApp(mockContext, "wontdie"); + + // send a message and wait for the reply to make sure the app is running + let MSG = "test"; + let recvPromise = new Promise(resolve => { + let listener = (what, msg) => { + equal(msg, MSG, "Received test message"); + app.off("message", listener); + resolve(); + }; + app.on("message", listener); + }); + + let buffer = NativeApp.encodeMessage(mockContext, MSG); + app.send(new StructuredCloneHolder(buffer)); + await recvPromise; + + app._cleanup(); + + do_print("waiting for async shutdown"); + Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true); + AsyncShutdown.profileBeforeChange._trigger(); + Services.prefs.clearUserPref("toolkit.asyncshutdown.testing"); + + let procs = await SubprocessImpl.Process.getWorker().call("getProcesses", []); + equal(procs.size, 0, "native process exited"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_scripts.js b/toolkit/components/extensions/test/xpcshell/test_proxy_scripts.js new file mode 100644 index 0000000000..306a8fd9ed --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_scripts.js @@ -0,0 +1,355 @@ +"use strict"; + +/* eslint no-unused-vars: ["error", {"args": "none", "varsIgnorePattern": "^(FindProxyForURL)$"}] */ + +Cu.import("resource://gre/modules/Extension.jsm"); +Cu.import("resource://gre/modules/ProxyScriptContext.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "gProxyService", + "@mozilla.org/network/protocol-proxy-service;1", + "nsIProtocolProxyService"); + +async function testProxyScript(options, expected = {}) { + let scriptData = String(options.scriptData).replace(/^.*?\{([^]*)\}$/, "$1"); + let extensionData = { + background() { + browser.test.onMessage.addListener((message, data) => { + if (message === "runtime-message") { + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg === "finish-from-pac-script") { + browser.test.notifyPass("proxy"); + return Promise.resolve(msg); + } + }); + browser.runtime.sendMessage(data, {toProxyScript: true}).then(response => { + browser.test.sendMessage("runtime-message-sent"); + }); + } else if (message === "finish-from-xpcshell-test") { + browser.test.notifyPass("proxy"); + } + }); + }, + files: { + "proxy.js": scriptData, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + let extension_internal = extension.extension; + + await extension.startup(); + + let script = new ProxyScriptContext(extension_internal, extension_internal.getURL("proxy.js")); + + try { + await script.load(); + } catch (error) { + equal(error, expected.error, "Expected error received"); + script.unload(); + await extension.unload(); + return; + } + + if (options.runtimeMessage) { + extension.sendMessage("runtime-message", options.runtimeMessage); + await extension.awaitMessage("runtime-message-sent"); + } else { + extension.sendMessage("finish-from-xpcshell-test"); + } + + await extension.awaitFinish("proxy"); + + let proxyInfo = await new Promise((resolve, reject) => { + let channel = NetUtil.newChannel({ + uri: "http://www.mozilla.org/", + loadUsingSystemPrincipal: true, + }); + + gProxyService.asyncResolve(channel, 0, { + onProxyAvailable(req, uri, pi, status) { + resolve(pi); + }, + }); + }); + + if (!proxyInfo) { + equal(proxyInfo, expected.proxyInfo, "Expected proxyInfo to be null"); + } else { + let expectedProxyInfo = expected.proxyInfo; + for (let proxy = proxyInfo; proxy; proxy = proxy.failoverProxy) { + equal(proxy.host, expectedProxyInfo.host, `Expected proxy host to be ${expectedProxyInfo.host}`); + equal(proxy.port, expectedProxyInfo.port, `Expected proxy port to be ${expectedProxyInfo.port}`); + equal(proxy.type, expectedProxyInfo.type, `Expected proxy type to be ${expectedProxyInfo.type}`); + expectedProxyInfo = expectedProxyInfo.failoverProxy; + } + } + + await extension.unload(); + script.unload(); +} + +add_task(async function testUndefinedFindProxyForURL() { + await testProxyScript({ + scriptData() { }, + }, { + proxyInfo: null, + }); +}); + +add_task(async function testWrongTypeForFindProxyForURL() { + await testProxyScript({ + scriptData() { + let FindProxyForURL = "foo"; + }, + }, { + proxyInfo: null, + }); +}); + +add_task(async function testInvalidReturnTypeForFindProxyForURL() { + await testProxyScript({ + scriptData() { + function FindProxyForURL(url, host) { + return -1; + } + }, + }, { + proxyInfo: null, + }); +}); + +add_task(async function testSimpleProxyScript() { + await testProxyScript({ + scriptData() { + function FindProxyForURL(url, host) { + if (host === "www.mozilla.org") { + return "DIRECT"; + } + } + }, + }, { + proxyInfo: null, + }); +}); + +add_task(async function testRuntimeErrorInProxyScript() { + await testProxyScript({ + scriptData() { + function FindProxyForURL(url, host) { + return RUNTIME_ERROR; // eslint-disable-line no-undef + } + }, + }, { + proxyInfo: null, + }); +}); + +add_task(async function testProxyScriptWithUnexpectedReturnType() { + await testProxyScript({ + scriptData() { + function FindProxyForURL(url, host) { + return "UNEXPECTED 1.2.3.4:8080"; + } + }, + }, { + proxyInfo: null, + }); +}); + +add_task(async function testSocksReturnType() { + await testProxyScript({ + scriptData() { + function FindProxyForURL(url, host) { + return "SOCKS foo.bar:1080"; + } + }, + }, { + proxyInfo: { + host: "foo.bar", + port: "1080", + type: "socks", + failoverProxy: null, + }, + }); +}); + +add_task(async function testSocks4ReturnType() { + await testProxyScript({ + scriptData() { + function FindProxyForURL(url, host) { + return "SOCKS4 1.2.3.4:1080"; + } + }, + }, { + proxyInfo: { + host: "1.2.3.4", + port: "1080", + type: "socks4", + failoverProxy: null, + }, + }); +}); + +add_task(async function testSocksReturnTypeWithHostCheck() { + await testProxyScript({ + scriptData() { + function FindProxyForURL(url, host) { + if (host === "www.mozilla.org") { + return "SOCKS 4.4.4.4:9002"; + } + } + }, + }, { + proxyInfo: { + host: "4.4.4.4", + port: "9002", + type: "socks", + failoverProxy: null, + }, + }); +}); + +add_task(async function testProxyReturnType() { + await testProxyScript({ + scriptData() { + function FindProxyForURL(url, host) { + return "PROXY 1.2.3.4:8080"; + } + }, + }, { + proxyInfo: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: null, + }, + }); +}); + +add_task(async function testUnusualWhitespaceForFindProxyForURL() { + await testProxyScript({ + scriptData() { + function FindProxyForURL(url, host) { + return " PROXY 1.2.3.4:8080 "; + } + }, + }, { + proxyInfo: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: null, + }, + }); +}); + +add_task(async function testInvalidProxyScriptIgnoresFailover() { + await testProxyScript({ + scriptData() { + function FindProxyForURL(url, host) { + return "PROXY 1.2.3.4:8080; UNEXPECTED; SOCKS 1.2.3.4:8080"; + } + }, + }, { + proxyInfo: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: null, + }, + }); +}); + +add_task(async function testProxyScriptWithValidFailovers() { + await testProxyScript({ + scriptData() { + function FindProxyForURL(url, host) { + return "PROXY 1.2.3.4:8080; SOCKS 4.4.4.4:9000; DIRECT"; + } + }, + }, { + proxyInfo: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: { + host: "4.4.4.4", + port: "9000", + type: "socks", + failoverProxy: null, + }, + }, + }); +}); + +add_task(async function testProxyScriptWithAnInvalidFailover() { + await testProxyScript({ + scriptData() { + function FindProxyForURL(url, host) { + return "PROXY 1.2.3.4:8080; INVALID 1.2.3.4:9090; SOCKS 4.4.4.4:9000; DIRECT"; + } + }, + }, { + proxyInfo: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: null, + }, + }); +}); + +add_task(async function testProxyScriptWithEmptyFailovers() { + await testProxyScript({ + scriptData() { + function FindProxyForURL(url, host) { + return ";;;;;PROXY 1.2.3.4:8080"; + } + }, + }, { + proxyInfo: null, + }); +}); + +add_task(async function testProxyScriptWithInvalidReturn() { + await testProxyScript({ + scriptData() { + function FindProxyForURL(url, host) { + return "SOCKS :8080;"; + } + }, + }, { + proxyInfo: null, + }); +}); + +add_task(async function testProxyScriptWithRuntimeUpdate() { + await testProxyScript({ + scriptData() { + let settings = {}; + function FindProxyForURL(url, host) { + if (settings.host === "www.mozilla.org") { + return "PROXY 1.2.3.4:8080;"; + } + return "DIRECT"; + } + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg.host) { + settings.host = msg.host; + browser.runtime.sendMessage("finish-from-pac-script"); + return Promise.resolve(msg); + } + }); + }, + runtimeMessage: { + host: "www.mozilla.org", + }, + }, { + proxyInfo: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: null, + }, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini new file mode 100644 index 0000000000..f8f012ee00 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini @@ -0,0 +1,61 @@ +[test_ext_alarms.js] +[test_ext_alarms_does_not_fire.js] +[test_ext_alarms_periodic.js] +[test_ext_alarms_replaces.js] +[test_ext_api_permissions.js] +[test_ext_background_generated_load_events.js] +[test_ext_background_generated_reload.js] +[test_ext_background_global_history.js] +skip-if = os == "android" # Android does not use Places for history. +[test_ext_background_private_browsing.js] +[test_ext_background_runtime_connect_params.js] +[test_ext_background_sub_windows.js] +[test_ext_background_telemetry.js] +[test_ext_background_window_properties.js] +skip-if = os == "android" +[test_ext_contextual_identities.js] +skip-if = os == "android" # Containers are not exposed to android. +[test_ext_debugging_utils.js] +[test_ext_downloads.js] +[test_ext_downloads_download.js] +skip-if = os == "android" +[test_ext_downloads_misc.js] +skip-if = os == "android" || (os=='linux' && bits==32) # linux32: bug 1324870 +[test_ext_downloads_search.js] +skip-if = os == "android" +[test_ext_experiments.js] +[test_ext_extension.js] +[test_ext_extensionPreferencesManager.js] +[test_ext_extensionSettingsStore.js] +[test_ext_extension_startup_telemetry.js] +[test_ext_idle.js] +[test_ext_localStorage.js] +[test_ext_management.js] +[test_ext_management_uninstall_self.js] +[test_ext_onmessage_removelistener.js] +skip-if = true # This test no longer tests what it is meant to test. +[test_ext_privacy.js] +[test_ext_privacy_disable.js] +[test_ext_privacy_update.js] +[test_ext_runtime_connect_no_receiver.js] +[test_ext_runtime_getBrowserInfo.js] +[test_ext_runtime_getPlatformInfo.js] +[test_ext_runtime_onInstalled_and_onStartup.js] +[test_ext_runtime_sendMessage.js] +[test_ext_runtime_sendMessage_args.js] +[test_ext_runtime_sendMessage_errors.js] +[test_ext_runtime_sendMessage_no_receiver.js] +[test_ext_runtime_sendMessage_self.js] +[test_ext_shutdown_cleanup.js] +[test_ext_simple.js] +[test_ext_startup_cache.js] +[test_ext_storage.js] +[test_ext_storage_sync.js] +head = head.js head_sync.js +skip-if = os == "android" +[test_ext_storage_sync_crypto.js] +skip-if = os == "android" +[test_ext_topSites.js] +skip-if = os == "android" +[test_native_messaging.js] +skip-if = os == "android" diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini new file mode 100644 index 0000000000..71e29d208f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini @@ -0,0 +1,4 @@ +[test_ext_i18n.js] +[test_ext_i18n_css.js] +[test_ext_contentscript.js] +[test_ext_contentscript_xrays.js] diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini new file mode 100644 index 0000000000..7a04528100 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini @@ -0,0 +1,12 @@ +[DEFAULT] +head = head.js head_remote.js +tail = +firefox-appdir = browser +skip-if = appname == "thunderbird" || os == "android" +dupe-manifest = +support-files = + data/** + xpcshell-content.ini +tags = webextensions webextensions-e10s + +[include:xpcshell-content.ini] diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell.ini b/toolkit/components/extensions/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..fde5d05ba0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini @@ -0,0 +1,97 @@ +[DEFAULT] +head = head.js +firefox-appdir = browser +skip-if = appname == "thunderbird" +dupe-manifest = +support-files = + data/** + head_sync.js + xpcshell-content.ini +tags = webextensions + +[test_MatchPattern.js] +[test_WebExtensionContentScript.js] +[test_WebExtensionPolicy.js] + +[test_csp_custom_policies.js] +[test_csp_validator.js] +[test_ext_alarms.js] +[test_ext_alarms_does_not_fire.js] +[test_ext_alarms_periodic.js] +[test_ext_alarms_replaces.js] +[test_ext_api_permissions.js] +[test_ext_background_generated_load_events.js] +[test_ext_background_generated_reload.js] +[test_ext_background_global_history.js] +skip-if = os == "android" # Android does not use Places for history. +[test_ext_background_private_browsing.js] +[test_ext_background_runtime_connect_params.js] +[test_ext_background_sub_windows.js] +[test_ext_background_window_properties.js] +skip-if = os == "android" +[test_ext_contexts.js] +[test_ext_contextual_identities.js] +skip-if = os == "android" # Containers are not exposed to android. +[test_ext_debugging_utils.js] +[test_ext_downloads.js] +[test_ext_downloads_download.js] +skip-if = os == "android" +[test_ext_downloads_misc.js] +skip-if = os == "android" || (os=='linux' && bits==32) # linux32: bug 1324870 +[test_ext_downloads_search.js] +skip-if = os == "android" +[test_ext_experiments.js] +[test_ext_extension.js] +[test_ext_extensionPreferencesManager.js] +[test_ext_extensionSettingsStore.js] +[test_ext_extension_startup_telemetry.js] +[test_ext_idle.js] +[test_ext_json_parser.js] +[test_ext_localStorage.js] +[test_ext_management.js] +[test_ext_management_uninstall_self.js] +[test_ext_manifest_content_security_policy.js] +[test_ext_manifest_incognito.js] +[test_ext_manifest_minimum_chrome_version.js] +[test_ext_manifest_themes.js] +[test_ext_onmessage_removelistener.js] +skip-if = true # This test no longer tests what it is meant to test. +[test_ext_permissions.js] +skip-if = os == "android" # Bug 1350559 +[test_ext_privacy.js] +[test_ext_privacy_disable.js] +[test_ext_privacy_update.js] +[test_ext_runtime_connect_no_receiver.js] +[test_ext_runtime_getBrowserInfo.js] +[test_ext_runtime_getPlatformInfo.js] +[test_ext_runtime_onInstalled_and_onStartup.js] +[test_ext_runtime_sendMessage.js] +[test_ext_runtime_sendMessage_args.js] +[test_ext_runtime_sendMessage_errors.js] +[test_ext_runtime_sendMessage_no_receiver.js] +[test_ext_runtime_sendMessage_self.js] +[test_ext_schemas.js] +[test_ext_schemas_async.js] +[test_ext_schemas_allowed_contexts.js] +[test_ext_schemas_revoke.js] +[test_ext_shutdown_cleanup.js] +[test_ext_simple.js] +[test_ext_startup_cache.js] +[test_ext_storage.js] +[test_ext_storage_sync.js] +head = head.js head_sync.js +skip-if = os == "android" +[test_ext_storage_sync_crypto.js] +skip-if = os == "android" +[test_ext_themes_supported_properties.js] +[test_ext_topSites.js] +skip-if = os == "android" +[test_ext_unknown_permissions.js] +[test_ext_legacy_extension_context.js] +[test_ext_legacy_extension_embedding.js] +[test_locale_converter.js] +[test_locale_data.js] +[test_native_messaging.js] +skip-if = os == "android" +[test_proxy_scripts.js] +[include:xpcshell-content.ini] diff --git a/toolkit/components/extensions/webrequest/moz.build b/toolkit/components/extensions/webrequest/moz.build new file mode 100644 index 0000000000..49bc4fe568 --- /dev/null +++ b/toolkit/components/extensions/webrequest/moz.build @@ -0,0 +1,21 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +XPIDL_SOURCES += [ + 'nsIWebRequestListener.idl', +] + +XPIDL_MODULE = 'webextensions' + +EXPORTS += [ + 'nsWebRequestListener.h', +] + +UNIFIED_SOURCES += [ + 'nsWebRequestListener.cpp', +] + +FINAL_LIBRARY = 'xul' \ No newline at end of file diff --git a/toolkit/components/extensions/webrequest/nsIWebRequestListener.idl b/toolkit/components/extensions/webrequest/nsIWebRequestListener.idl new file mode 100644 index 0000000000..94d4819f3e --- /dev/null +++ b/toolkit/components/extensions/webrequest/nsIWebRequestListener.idl @@ -0,0 +1,28 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* 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/. */ + +#include "nsISupports.idl" +#include "nsIStreamListener.idl" +#include "nsITraceableChannel.idl" + +/* nsIWebRequestListener is a nsIThreadRetargetableStreamListener that handles + * forwarding of nsIRequestObserver for JS consumers. nsIWebRequestListener + * is not cycle collected, JS consumers should not keep a reference to this. + */ + +[scriptable, uuid(699a50bb-1f18-2844-b9ea-9f216f62cb18)] +interface nsIWebRequestListener : nsISupports +{ + void init(in nsIStreamListener aStreamListener, + in nsITraceableChannel aTraceableChannel); +}; + +%{C++ +/* ebea9901-e135-b546-82e2-052666992dbb */ +#define NS_WEBREQUESTLISTENER_CID \ + {0xebea9901, 0xe135, 0xb546, \ + {0x82, 0xe2, 0x05, 0x26, 0x66, 0x99, 0x2d, 0xbb} } +#define NS_WEBREQUESTLISTENER_CONTRACTID "@mozilla.org/webextensions/webRequestListener;1" +%} \ No newline at end of file diff --git a/toolkit/components/extensions/webrequest/nsWebRequestListener.cpp b/toolkit/components/extensions/webrequest/nsWebRequestListener.cpp new file mode 100644 index 0000000000..2e46d44444 --- /dev/null +++ b/toolkit/components/extensions/webrequest/nsWebRequestListener.cpp @@ -0,0 +1,72 @@ +/* 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/. */ + +#include "mozilla/ModuleUtils.h" +#include "nsWebRequestListener.h" + +#ifdef DEBUG +#include "MainThreadUtils.h" +#endif + +using namespace mozilla; + +NS_IMPL_ISUPPORTS(nsWebRequestListener, + nsIWebRequestListener, + nsIStreamListener, + nsIRequestObserver, + nsIThreadRetargetableStreamListener) + +NS_IMETHODIMP +nsWebRequestListener::Init(nsIStreamListener *aStreamListener, nsITraceableChannel *aTraceableChannel) +{ + MOZ_ASSERT(aStreamListener, "Should have aStreamListener"); + MOZ_ASSERT(aTraceableChannel, "Should have aTraceableChannel"); + mTargetStreamListener = aStreamListener; + return aTraceableChannel->SetNewListener(this, getter_AddRefs(mOrigStreamListener)); +} + +NS_IMETHODIMP +nsWebRequestListener::OnStartRequest(nsIRequest *request, nsISupports * aCtxt) +{ + MOZ_ASSERT(mTargetStreamListener, "Should have mTargetStreamListener"); + MOZ_ASSERT(mOrigStreamListener, "Should have mOrigStreamListener"); + + mTargetStreamListener->OnStartRequest(request, aCtxt); + + return mOrigStreamListener->OnStartRequest(request, aCtxt); +} + +NS_IMETHODIMP +nsWebRequestListener::OnStopRequest(nsIRequest *request, nsISupports *aCtxt, + nsresult aStatus) +{ + MOZ_ASSERT(mOrigStreamListener, "Should have mOrigStreamListener"); + MOZ_ASSERT(mTargetStreamListener, "Should have mTargetStreamListener"); + + mOrigStreamListener->OnStopRequest(request, aCtxt, aStatus); + + return mTargetStreamListener->OnStopRequest(request, aCtxt, aStatus); +} + +NS_IMETHODIMP +nsWebRequestListener::OnDataAvailable(nsIRequest *request, nsISupports * aCtxt, + nsIInputStream * inStr, + uint64_t sourceOffset, uint32_t count) +{ + MOZ_ASSERT(mOrigStreamListener, "Should have mOrigStreamListener"); + return mOrigStreamListener->OnDataAvailable(request, aCtxt, inStr, sourceOffset, count); +} + +NS_IMETHODIMP +nsWebRequestListener::CheckListenerChain() +{ + MOZ_ASSERT(NS_IsMainThread(), "Should be on main thread!"); + nsresult rv; + nsCOMPtr retargetableListener = + do_QueryInterface(mOrigStreamListener, &rv); + if (retargetableListener) { + return retargetableListener->CheckListenerChain(); + } + return rv; +} diff --git a/toolkit/components/extensions/webrequest/nsWebRequestListener.h b/toolkit/components/extensions/webrequest/nsWebRequestListener.h new file mode 100644 index 0000000000..b1f5b695c3 --- /dev/null +++ b/toolkit/components/extensions/webrequest/nsWebRequestListener.h @@ -0,0 +1,39 @@ +/* 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/. */ + +#ifndef nsWebRequestListener_h__ +#define nsWebRequestListener_h__ + +#include "nsCOMPtr.h" +#include "nsIWebRequestListener.h" +#include "nsIRequestObserver.h" +#include "nsIStreamListener.h" +#include "nsITraceableChannel.h" +#include "nsIThreadRetargetableStreamListener.h" +#include "nsProxyRelease.h" +#include "mozilla/Attributes.h" + +class nsWebRequestListener final : public nsIWebRequestListener + , public nsIStreamListener + , public nsIThreadRetargetableStreamListener +{ +public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSIWEBREQUESTLISTENER + NS_DECL_NSIREQUESTOBSERVER + NS_DECL_NSISTREAMLISTENER + NS_DECL_NSITHREADRETARGETABLESTREAMLISTENER + + nsWebRequestListener() {} + +private: + ~nsWebRequestListener() { + NS_ReleaseOnMainThread(mTargetStreamListener.forget()); + } + nsCOMPtr mOrigStreamListener; + nsCOMPtr mTargetStreamListener; +}; + +#endif // nsWebRequestListener_h__ + diff --git a/toolkit/components/moz.build b/toolkit/components/moz.build index b545510d55..96fb04c163 100644 --- a/toolkit/components/moz.build +++ b/toolkit/components/moz.build @@ -23,6 +23,7 @@ DIRS += [ 'cookie', 'crashmonitor', 'downloads', + 'extensions', 'exthelper', 'filewatcher', 'finalizationwitness', diff --git a/toolkit/modules/moz.build b/toolkit/modules/moz.build index d113b4e5ce..1d8d6c3551 100644 --- a/toolkit/modules/moz.build +++ b/toolkit/modules/moz.build @@ -28,7 +28,6 @@ EXTRA_JS_MODULES += [ 'debug.js', 'DeferredTask.jsm', 'Deprecated.jsm', - 'ExtensionStorage.jsm', 'FileUtils.jsm', 'Finder.jsm', 'FinderHighlighter.jsm', diff --git a/toolkit/mozapps/extensions/internal/XPIProvider.jsm b/toolkit/mozapps/extensions/internal/XPIProvider.jsm index 1b23267764..d0f43d13da 100644 --- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm +++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm @@ -1032,18 +1032,6 @@ function loadManifestFromDir(aDir) { * Throws with |jetpacksdk:true| if a Jetpack files were found * if Jetpack its self isn't built. */ -function loadManifestFromZipReader(aZipReader) { - // If WebExtension but not install.rdf throw an error - if (aZipReader.hasEntry(FILE_WEBEXT_MANIFEST)) { - if (!aZipReader.hasEntry(FILE_INSTALL_MANIFEST)) { - throw { - name: "UnsupportedExtension", - message: Services.appinfo.name + " does not support WebExtensions", - webext: true - }; - } - } - #ifndef MOZ_JETPACK // If Jetpack is not built throw an error if (aZipReader.hasEntry(FILE_JETPACK_MANIFEST_1) ||