Bug 1214955: [webext] Automatically localize all localizable manifest properties. r?billm draft
authorKris Maglione <maglione.k@gmail.com>
Fri, 26 Feb 2016 16:57:32 -0800
changeset 335106 8e274ecff28bfb7c2e1105fb55dbea295ac4b21a
parent 335069 36d6bc68fe0f21d87b306c7712e939d8ae537b88
child 515078 6f2764a78407af42c7af64490b121277b3616f6f
push id11725
push usermaglione.k@gmail.com
push dateSat, 27 Feb 2016 00:58:59 +0000
reviewersbillm
bugs1214955
milestone47.0a1
Bug 1214955: [webext] Automatically localize all localizable manifest properties. r?billm MozReview-Commit-ID: 2kvYT44NIE8
browser/components/extensions/ext-browserAction.js
browser/components/extensions/ext-pageAction.js
browser/components/extensions/ext-utils.js
browser/components/extensions/schemas/browser_action.json
browser/components/extensions/schemas/page_action.json
browser/components/extensions/test/browser/browser_ext_browserAction_context.js
browser/components/extensions/test/browser/browser_ext_pageAction_context.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/Schemas.jsm
toolkit/components/extensions/ext-backgroundPage.js
toolkit/components/extensions/schemas/manifest.json
toolkit/mozapps/extensions/internal/XPIProvider.jsm
--- a/browser/components/extensions/ext-browserAction.js
+++ b/browser/components/extensions/ext-browserAction.js
@@ -28,30 +28,23 @@ function BrowserAction(options, extensio
 
   let widgetId = makeWidgetId(extension.id);
   this.id = `${widgetId}-browser-action`;
   this.viewId = `PanelUI-webext-${widgetId}-browser-action-view`;
   this.widget = null;
 
   this.tabManager = TabManager.for(extension);
 
-  let title = extension.localize(options.default_title || "");
-  let popup = extension.localize(options.default_popup || "");
-  if (popup) {
-    popup = extension.baseURI.resolve(popup);
-  }
-
   this.defaults = {
     enabled: true,
-    title: title || extension.name,
+    title: options.default_title || extension.name,
     badgeText: "",
     badgeBackgroundColor: null,
-    icon: IconDetails.normalize({path: options.default_icon}, extension,
-                                null, true),
-    popup: popup,
+    icon: IconDetails.normalize({path: options.default_icon}, extension),
+    popup: options.default_popup || "",
   };
 
   this.tabContext = new TabContext(tab => Object.create(this.defaults),
                                    extension);
 
   EventEmitter.decorate(this);
 }
 
--- a/browser/components/extensions/ext-pageAction.js
+++ b/browser/components/extensions/ext-pageAction.js
@@ -14,28 +14,21 @@ var pageActionMap = new WeakMap();
 // Handles URL bar icons, including the |page_action| manifest entry
 // and associated API.
 function PageAction(options, extension) {
   this.extension = extension;
   this.id = makeWidgetId(extension.id) + "-page-action";
 
   this.tabManager = TabManager.for(extension);
 
-  let title = extension.localize(options.default_title || "");
-  let popup = extension.localize(options.default_popup || "");
-  if (popup) {
-    popup = extension.baseURI.resolve(popup);
-  }
-
   this.defaults = {
     show: false,
-    title: title || extension.name,
-    icon: IconDetails.normalize({path: options.default_icon}, extension,
-                                null, true),
-    popup: popup && extension.baseURI.resolve(popup),
+    title: options.default_title || extension.name,
+    icon: IconDetails.normalize({path: options.default_icon}, extension),
+    popup: options.default_popup || "",
   };
 
   this.tabContext = new TabContext(tab => Object.create(this.defaults),
                                    extension);
 
   this.tabContext.on("location-change", this.handleLocationChange.bind(this)); // eslint-disable-line mozilla/balanced-listeners
 
   // WeakMap[ChromeWindow -> <xul:image>]
--- a/browser/components/extensions/ext-utils.js
+++ b/browser/components/extensions/ext-utils.js
@@ -31,17 +31,17 @@ global.IconDetails = {
   // 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, localize = false) {
+  normalize(details, extension, context = null) {
     let result = {};
 
     try {
       if (details.imageData) {
         let imageData = details.imageData;
 
         // The global might actually be from Schema.jsm, which
         // normalizes most of our arguments. In that case it won't have
@@ -68,22 +68,17 @@ global.IconDetails = {
 
         let baseURI = context ? context.uri : extension.baseURI;
 
         for (let size of Object.keys(path)) {
           if (!INTEGER.test(size)) {
             throw new Error(`Invalid icon size ${size}, must be an integer`);
           }
 
-          let url = path[size];
-          if (localize) {
-            url = extension.localize(url);
-          }
-
-          url = baseURI.resolve(path[size]);
+          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.
           Services.scriptSecurityManager.checkLoadURIStrWithPrincipal(
             extension.principal, url,
             Services.scriptSecurityManager.DISALLOW_SCRIPT);
--- a/browser/components/extensions/schemas/browser_action.json
+++ b/browser/components/extensions/schemas/browser_action.json
@@ -7,19 +7,31 @@
     "namespace": "manifest",
     "types": [
       {
         "$extend": "WebExtensionManifest",
         "properties": {
           "browser_action": {
             "type": "object",
             "properties": {
-              "default_title": { "type": "string", "optional": true },
-              "default_icon": { "$ref": "IconPath", "optional": true },
-              "default_popup": { "type": "string", "format": "relativeUrl", "optional": true }
+              "default_title": {
+                "type": "string",
+                "optional": true,
+                "preprocess": "localize"
+              },
+              "default_icon": {
+                "$ref": "IconPath",
+                "optional": true
+              },
+              "default_popup": {
+                "type": "string",
+                "format": "relativeUrl",
+                "optional": true,
+                "preprocess": "localize"
+              }
             },
             "optional": true
           }
         }
       }
     ]
   },
   {
--- a/browser/components/extensions/schemas/page_action.json
+++ b/browser/components/extensions/schemas/page_action.json
@@ -7,19 +7,31 @@
     "namespace": "manifest",
     "types": [
       {
         "$extend": "WebExtensionManifest",
         "properties": {
           "page_action": {
             "type": "object",
             "properties": {
-              "default_title": { "type": "string", "optional": true },
-              "default_icon": { "$ref": "IconPath", "optional": true },
-              "default_popup": { "type": "string", "format": "relativeUrl", "optional": true }
+              "default_title": {
+                "type": "string",
+                "optional": true,
+                "preprocess": "localize"
+              },
+              "default_icon": {
+                "$ref": "IconPath",
+                "optional": true
+              },
+              "default_popup": {
+                "type": "string",
+                "format": "relativeUrl",
+                "optional": true,
+                "preprocess": "localize"
+              }
             },
             "optional": true
           }
         }
       }
     ]
   },
   {
--- a/browser/components/extensions/test/browser/browser_ext_browserAction_context.js
+++ b/browser/components/extensions/test/browser/browser_ext_browserAction_context.js
@@ -77,16 +77,18 @@ function* runTests(options) {
 
       nextTest();
     });
   }
 
   let extension = ExtensionTestUtils.loadExtension({
     manifest: options.manifest,
 
+    files: options.files || {},
+
     background: `(${background})(${options.getTests})`,
   });
 
 
   let browserActionId = makeWidgetId(extension.id) + "-browser-action";
 
   function checkDetails(details) {
     let button = document.getElementById(browserActionId);
@@ -135,22 +137,39 @@ function* runTests(options) {
   yield extension.unload();
 }
 
 add_task(function* testTabSwitchContext() {
   yield runTests({
     manifest: {
       "browser_action": {
         "default_icon": "default.png",
-        "default_popup": "default.html",
-        "default_title": "Default Title",
+        "default_popup": "__MSG_popup__",
+        "default_title": "Default __MSG_title__",
       },
+
+      "default_locale": "en",
+
       "permissions": ["tabs"],
     },
 
+    "files": {
+      "_locales/en/messages.json": {
+        "popup": {
+          "message": "default.html",
+          "description": "Popup",
+        },
+
+        "title": {
+          "message": "Title",
+          "description": "Title",
+        },
+      },
+    },
+
     getTests(tabs, expectDefaults) {
       let details = [
         {"icon": browser.runtime.getURL("default.png"),
          "popup": browser.runtime.getURL("default.html"),
          "title": "Default Title",
          "badge": "",
          "badgeBackgroundColor": null},
         {"icon": browser.runtime.getURL("1.png"),
--- a/browser/components/extensions/test/browser/browser_ext_pageAction_context.js
+++ b/browser/components/extensions/test/browser/browser_ext_pageAction_context.js
@@ -77,16 +77,18 @@ function* runTests(options) {
     });
 
     runTests();
   }
 
   let extension = ExtensionTestUtils.loadExtension({
     manifest: options.manifest,
 
+    files: options.files || {},
+
     background: `(${background})(${options.getTests})`,
   });
 
   let pageActionId = makeWidgetId(extension.id) + "-page-action";
   let currentWindow = window;
   let windows = [];
 
   function checkDetails(details) {
@@ -149,23 +151,39 @@ function* runTests(options) {
 
 add_task(function* testTabSwitchContext() {
   yield runTests({
     manifest: {
       "name": "Foo Extension",
 
       "page_action": {
         "default_icon": "default.png",
-        "default_popup": "default.html",
-        "default_title": "Default Title \u263a",
+        "default_popup": "__MSG_popup__",
+        "default_title": "Default __MSG_title__ \u263a",
       },
 
+      "default_locale": "en",
+
       "permissions": ["tabs"],
     },
 
+    "files": {
+      "_locales/en/messages.json": {
+        "popup": {
+          "message": "default.html",
+          "description": "Popup",
+        },
+
+        "title": {
+          "message": "Title",
+          "description": "Title",
+        },
+      },
+    },
+
     getTests(tabs) {
       let details = [
         {"icon": browser.runtime.getURL("default.png"),
          "popup": browser.runtime.getURL("default.html"),
          "title": "Default Title \u263a"},
         {"icon": browser.runtime.getURL("1.png"),
          "popup": browser.runtime.getURL("default.html"),
          "title": "Default Title \u263a"},
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -634,30 +634,42 @@ ExtensionData.prototype = {
 
   // Reads the extension's |manifest.json| file, and stores its
   // parsed contents in |this.manifest|.
   readManifest() {
     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: {},
       };
 
-      let normalized = Schemas.normalize(manifest, "manifest.WebExtensionManifest", context);
+      if (this.localeData) {
+        context.preprocessors.localize = this.localize.bind(this);
+      }
+
+      let normalized = Schemas.normalize(this.manifest, "manifest.WebExtensionManifest", context);
       if (normalized.error) {
         this.manifestError(normalized.error);
-        this.manifest = manifest;
       } else {
         this.manifest = normalized.value;
       }
 
       try {
         this.id = this.manifest.applications.gecko.id;
       } catch (e) {
         // Errors are handled by the type checks above.
--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -78,29 +78,27 @@ function getValueBaseType(value) {
   return t;
 }
 
 class Context {
   constructor(params) {
     this.params = params;
 
     this.path = [];
+    this.preprocessors = {};
 
     let props = ["addListener", "callFunction", "callAsyncFunction",
                  "hasListener", "removeListener",
-                 "getProperty", "setProperty"];
+                 "getProperty", "setProperty",
+                 "checkLoadURL", "logError",
+                 "preprocessors"];
     for (let prop of props) {
-      this[prop] = params[prop];
-    }
-
-    if ("checkLoadURL" in params) {
-      this.checkLoadURL = params.checkLoadURL;
-    }
-    if ("logError" in params) {
-      this.logError = params.logError;
+      if (prop in params) {
+        this[prop] = params[prop];
+      }
     }
   }
 
   get cloneScope() {
     return this.params.cloneScope;
   }
 
   get url() {
@@ -262,16 +260,34 @@ class Entry {
      *
      * 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;
     }
+
+    /**
+     * 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;
+  }
+
+  /**
+   * Preprocess the given value with the preprocessor declared in
+   * `preprocessor`.
+   */
+  preprocess(value, context) {
+    if (this.preprocessor in context.preprocessors) {
+      return context.preprocessors[this.preprocessor](value, context);
+    }
+    return value;
   }
 
   /**
    * Logs a deprecation warning for this entry, based on the value of
    * its `deprecated` property.
    */
   logDeprecation(context, value = null) {
     let message = "This property is deprecated";
@@ -330,17 +346,17 @@ class Type extends Entry {
     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};
+      return {value: this.preprocess(value, context)};
     }
     return context.error(`Expected ${type} instead of ${JSON.stringify(value)}`);
   }
 }
 
 // Type that allows any value.
 class AnyType extends Type {
   normalize(value, context) {
@@ -429,16 +445,17 @@ class StringType extends Type {
     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 {value};
       }
       return context.error(`Invalid enumeration value ${JSON.stringify(value)}`);
     }
 
@@ -505,16 +522,17 @@ class ObjectType extends Type {
     return baseType == "object";
   }
 
   normalize(value, context) {
     let v = this.normalizeBase("object", value, context);
     if (v.error) {
       return v;
     }
+    value = v.value;
 
     if (this.isInstanceOf) {
       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");
       }
 
@@ -634,17 +652,17 @@ class SubModuleType extends Type {
 
 class NumberType extends Type {
   normalize(value, context) {
     let r = this.normalizeBase("number", value, context);
     if (r.error) {
       return r;
     }
 
-    if (isNaN(value) || !Number.isFinite(value)) {
+    if (isNaN(r.value) || !Number.isFinite(r.value)) {
       return context.error("NaN or infinity are not valid");
     }
 
     return r;
   }
 
   checkBaseType(baseType) {
     return baseType == "number" || baseType == "integer";
@@ -658,16 +676,17 @@ class IntegerType extends Type {
     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");
     }
 
     if (value < this.minimum) {
       return context.error(`Integer ${value} is too small (must be at least ${this.minimum})`);
@@ -702,16 +721,17 @@ class ArrayType extends Type {
     this.maxItems = maxItems;
   }
 
   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) {
         return element;
       }
       result.push(element.value);
@@ -1027,17 +1047,17 @@ this.Schemas = {
     ns.set(symbol, value);
   },
 
   parseType(path, type, extraProperties = []) {
     let allowedProperties = new Set(extraProperties);
 
     // Do some simple validation of our own schemas.
     function checkTypeProperties(...extra) {
-      let allowedSet = new Set([...allowedProperties, ...extra, "description", "deprecated"]);
+      let allowedSet = new Set([...allowedProperties, ...extra, "description", "deprecated", "preprocess"]);
       for (let prop of Object.keys(type)) {
         if (!allowedSet.has(prop)) {
           throw new Error(`Internal error: Namespace ${path.join(".")} has invalid type property "${prop}" in type "${type.id || JSON.stringify(type)}"`);
         }
       }
     }
 
     if ("choices" in type) {
--- a/toolkit/components/extensions/ext-backgroundPage.js
+++ b/toolkit/components/extensions/ext-backgroundPage.js
@@ -71,25 +71,18 @@ BackgroundPage.prototype = {
       if (event.target != window.document) {
         return;
       }
       event.currentTarget.removeEventListener("load", loadListener, true);
 
       if (this.scripts) {
         let doc = window.document;
         for (let script of this.scripts) {
-          let url = this.extension.baseURI.resolve(script);
-
-          if (!this.extension.isExtensionURL(url)) {
-            this.extension.manifestError("Background scripts must be files within the extension");
-            continue;
-          }
-
           let tag = doc.createElement("script");
-          tag.setAttribute("src", url);
+          tag.setAttribute("src", script);
           tag.async = false;
           doc.body.appendChild(tag);
         }
       }
 
       if (this.extension.onStartup) {
         this.extension.onStartup();
       }
--- a/toolkit/components/extensions/schemas/manifest.json
+++ b/toolkit/components/extensions/schemas/manifest.json
@@ -38,33 +38,48 @@
                   }
                 }
               }
             }
           },
 
           "name": {
             "type": "string",
-            "optional": false
+            "optional": false,
+            "preprocess": "localize"
+          },
+
+          "short_name": {
+            "type": "string",
+            "optional": true,
+            "preprocess": "localize"
           },
 
           "description": {
             "type": "string",
-            "optional": true
+            "optional": true,
+            "preprocess": "localize"
+          },
+
+          "creator": {
+            "type": "string",
+            "optional": true,
+            "preprocess": "localize"
           },
 
           "version": {
             "type": "string",
             "optional": false
           },
 
           "homepage_url": {
             "type": "string",
             "format": "url",
-            "optional": true
+            "optional": true,
+            "preprocess": "localize"
           },
 
           "icons": {
             "type": "object",
             "optional": true,
             "patternProperties": {
               "^[1-9]\\d*$": { "type": "string" }
             }
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -893,21 +893,25 @@ var loadManifestFromWebManifest = Task.a
   // WebExtensions don't use iconURLs
   addon.iconURL = null;
   addon.icon64URL = null;
   addon.icons = manifest.icons || {};
 
   addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT;
 
   function getLocale(aLocale) {
+    // Use the raw manifest, here, since we need values with their
+    // localization placeholders still in place.
+    let rawManifest = extension.rawManifest;
+
     let result = {
-      name: extension.localize(manifest.name, aLocale),
-      description: extension.localize(manifest.description, aLocale),
-      creator: null,
-      homepageURL: null,
+      name: extension.localize(rawManifest.name, aLocale),
+      description: extension.localize(rawManifest.description, aLocale),
+      creator: extension.localize(rawManifest.creator, aLocale),
+      homepageURL: extension.localize(rawManifest.homepage_url, aLocale),
 
       developers: null,
       translators: null,
       contributors: null,
       locales: [aLocale],
     };
     return result;
   }