Bug 1365709 Crude strawman for webextension langpacks draft
authorAndrew Swan <aswan@mozilla.com>
Tue, 11 Jul 2017 13:56:23 -0700
changeset 651128 10b41a68e9285f39858156c96f264723a9035d10
parent 650685 ab30809b4c8e53b8723c062340960680ca419c7f
child 727596 0d8733f6775d2245720c04ce327fd692b8549b63
push id75604
push useraswan@mozilla.com
push dateWed, 23 Aug 2017 08:28:39 +0000
bugs1365709
milestone57.0a1
Bug 1365709 Crude strawman for webextension langpacks MozReview-Commit-ID: 8vkj14nougX
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/schemas/manifest.json
toolkit/mozapps/extensions/internal/XPIInstall.jsm
toolkit/mozapps/extensions/internal/XPIProvider.jsm
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -290,16 +290,17 @@ UninstallObserver.init();
 // 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.resourceURL = rootURI.spec;
 
     this.manifest = null;
+    this.type = 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();
@@ -448,16 +449,20 @@ this.ExtensionData = class {
   }
 
   // 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() {
+    if (this.type != "extension") {
+      return null;
+    }
+
     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);
@@ -503,32 +508,40 @@ this.ExtensionData = class {
 
       logError: error => {
         this.manifestWarning(error);
       },
 
       preprocessors: {},
     };
 
+    let manifestType = "manifest.WebExtensionManifest";
     if (this.manifest.theme) {
+      this.type = "theme";
+      // XXX create a separate manifest type for themes
       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);
       }
+    } else if (this.manifest["langpack-id"]) {
+      this.type = "langpack";
+      manifestType = "manifest.WebExtensionLangpackManifest";
+    } else {
+      this.type = "extension";
     }
 
     if (this.localeData) {
       context.preprocessors.localize = (value, context) => this.localize(value);
     }
 
-    let normalized = Schemas.normalize(this.manifest, "manifest.WebExtensionManifest", context);
+    let normalized = Schemas.normalize(this.manifest, manifestType, context);
     if (normalized.error) {
       this.manifestError(normalized.error);
       return null;
     }
 
     manifest = normalized.value;
 
     let id;
@@ -543,62 +556,79 @@ this.ExtensionData = class {
     if (!this.id) {
       this.id = id;
     }
 
     let apiNames = new Set();
     let dependencies = new Set();
     let originPermissions = new Set();
     let permissions = new Set();
+    let webAccessibleResources = [];
 
-    for (let perm of manifest.permissions) {
-      if (perm === "geckoProfiler") {
-        const acceptedExtensions = Services.prefs.getStringPref("extensions.geckoProfiler.acceptedExtensionIds", "");
-        if (!acceptedExtensions.split(",").includes(id)) {
-          this.manifestError("Only whitelisted extensions are allowed to access the geckoProfiler.");
-          continue;
+    if (this.type == "extension") {
+      for (let perm of manifest.permissions) {
+        if (perm === "geckoProfiler") {
+          const acceptedExtensions = Services.prefs.getStringPref("extensions.geckoProfiler.acceptedExtensionIds", "");
+          if (!acceptedExtensions.split(",").includes(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);
+        }
+
+        let type = classifyPermission(perm);
+        if (type.origin) {
+          let matcher = new MatchPattern(perm, {ignorePath: true});
+
+          perm = matcher.pattern;
+          originPermissions.add(perm);
+        } else if (type.api) {
+          apiNames.add(type.api);
+        }
+
+        permissions.add(perm);
+      }
+
+      if (this.id) {
+        // An extension always gets permission to its own url.
+        let matcher = new MatchPattern(this.getURL(), {ignorePath: true});
+        originPermissions.add(matcher.pattern);
+
+        // Apply optional permissions
+        let perms = await ExtensionPermissions.get(this);
+        for (let perm of perms.permissions) {
+          permissions.add(perm);
+        }
+        for (let origin of perms.origins) {
+          originPermissions.add(origin);
         }
       }
 
-      let type = classifyPermission(perm);
-      if (type.origin) {
-        let matcher = new MatchPattern(perm, {ignorePath: true});
-
-        perm = matcher.pattern;
-        originPermissions.add(perm);
-      } else if (type.api) {
-        apiNames.add(type.api);
+      for (let api of apiNames) {
+        dependencies.add(`${api}@experiments.addons.mozilla.org`);
       }
 
-      permissions.add(perm);
-    }
-
-    if (this.id) {
-      // An extension always gets permission to its own url.
-      let matcher = new MatchPattern(this.getURL(), {ignorePath: true});
-      originPermissions.add(matcher.pattern);
-
-      // Apply optional permissions
-      let perms = await ExtensionPermissions.get(this);
-      for (let perm of perms.permissions) {
-        permissions.add(perm);
-      }
-      for (let origin of perms.origins) {
-        originPermissions.add(origin);
+      // Normalize all patterns to contain a single leading /
+      if (manifest.web_accessible_resources) {
+        webAccessibleResources = manifest.web_accessible_resources
+          .map(path => path.replace(/^\/*/, "/"));
       }
     }
 
-    for (let api of apiNames) {
-      dependencies.add(`${api}@experiments.addons.mozilla.org`);
-    }
-
-    // Normalize all patterns to contain a single leading /
-    let webAccessibleResources = (manifest.web_accessible_resources || [])
-        .map(path => path.replace(/^\/*/, "/"));
-
     return {apiNames, dependencies, originPermissions, id, manifest, permissions,
             webAccessibleResources};
   }
 
   // Reads the extension's |manifest.json| file, and stores its
   // parsed contents in |this.manifest|.
   async loadManifest() {
     let [manifestData] = await Promise.all([
--- a/toolkit/components/extensions/schemas/manifest.json
+++ b/toolkit/components/extensions/schemas/manifest.json
@@ -207,16 +207,110 @@
             }
           }
 
         },
 
         "additionalProperties": { "$ref": "UnrecognizedProperty" }
       },
       {
+        "id": "WebExtensionLangpackManifest",
+        "type": "object",
+        "description": "Represents a WebExtension language pack 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"
+          },
+
+          "langpack-id": {
+            "type": "string",
+            "pattern": "^[-A-Za-z]+$"
+          },
+
+          "languages": {
+            "type": "array",
+            "items": {
+              "type": "string",
+              "pattern": "^[-A-Za-z]+$"
+            }
+          },
+
+          "chrome_entries": {
+            "type": "array",
+            "items": { "type": "string" }
+          }
+        }
+      },
+      {
         "id": "ThemeIcons",
         "type": "object",
         "properties": {
           "light": {
             "$ref": "ExtensionURL",
             "description": "A light icon to use for dark themes"
           },
           "dark": {
--- a/toolkit/mozapps/extensions/internal/XPIInstall.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIInstall.jsm
@@ -329,33 +329,32 @@ async function loadManifestFromWebManife
   // all locales.
   let locales = (extension.errors.length == 0) ?
                 await extension.initAllLocales() : null;
 
   if (extension.errors.length > 0) {
     throw new Error("Extension is invalid");
   }
 
-  let theme = Boolean(manifest.theme);
-
   let bss = (manifest.browser_specific_settings && manifest.browser_specific_settings.gecko)
       || (manifest.applications && manifest.applications.gecko) || {};
   if (manifest.browser_specific_settings && manifest.applications) {
     logger.warn("Ignoring applications property in manifest");
   }
 
   // A * is illegal in strict_min_version
   if (bss.strict_min_version && bss.strict_min_version.split(".").some(part => part == "*")) {
     throw new Error("The use of '*' in strict_min_version is invalid");
   }
 
   let addon = new AddonInternal();
   addon.id = bss.id;
   addon.version = manifest.version;
-  addon.type = "webextension" + (theme ? "-theme" : "");
+  addon.type = extension.type == "extension" ?
+               "webextension" : `webextension-${extension.type}`;
   addon.unpack = false;
   addon.strictCompatibility = true;
   addon.bootstrap = true;
   addon.hasBinaryComponents = false;
   addon.multiprocessCompatible = true;
   addon.internalName = null;
   addon.updateURL = bss.update_url;
   addon.updateKey = null;
@@ -430,17 +429,17 @@ async function loadManifestFromWebManife
   addon.targetApplications = [{
     id: TOOLKIT_ID,
     minVersion: bss.strict_min_version,
     maxVersion: bss.strict_max_version,
   }];
 
   addon.targetPlatforms = [];
   // Themes are disabled by default, except when they're installed from a web page.
-  addon.userDisabled = theme;
+  addon.userDisabled = (extension.type == "theme");
   addon.softDisabled = addon.blocklistState == nsIBlocklistService.STATE_SOFTBLOCKED;
 
   return addon;
 }
 
 /**
  * Reads an AddonInternal object from an RDF stream.
  *
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -18,16 +18,19 @@ Cu.import("resource://gre/modules/XPCOMU
 Cu.import("resource://gre/modules/AddonManager.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonRepository: "resource://gre/modules/addons/AddonRepository.jsm",
   AddonSettings: "resource://gre/modules/addons/AddonSettings.jsm",
   AppConstants: "resource://gre/modules/AppConstants.jsm",
   ChromeManifestParser: "resource://gre/modules/ChromeManifestParser.jsm",
   Extension: "resource://gre/modules/Extension.jsm",
+  ExtensionData: "resource://gre/modules/Extension.jsm",
+  FileSource: "resource://gre/modules/L10nRegistry.jsm",
+  L10nRegistry: "resource://gre/modules/L10nRegistry.jsm",
   LightweightThemeManager: "resource://gre/modules/LightweightThemeManager.jsm",
   FileUtils: "resource://gre/modules/FileUtils.jsm",
   ZipUtils: "resource://gre/modules/ZipUtils.jsm",
   NetUtil: "resource://gre/modules/NetUtil.jsm",
   PermissionsUtils: "resource://gre/modules/PermissionsUtils.jsm",
   OS: "resource://gre/modules/osfile.jsm",
   ConsoleAPI: "resource://gre/modules/Console.jsm",
   ProductAddonChecker: "resource://gre/modules/addons/ProductAddonChecker.jsm",
@@ -205,16 +208,17 @@ const BOOTSTRAP_REASONS = {
 };
 
 // Some add-on types that we track internally are presented as other types
 // externally
 const TYPE_ALIASES = {
   "apiextension": "extension",
   "webextension": "extension",
   "webextension-theme": "theme",
+  "webextension-langpack": "locale",
 };
 
 const CHROME_TYPES = new Set([
   "extension",
   "locale",
   "experiment",
 ]);
 
@@ -1105,16 +1109,38 @@ function recordAddonTelemetry(aAddon) {
   if (locale) {
     if (locale.name)
       XPIProvider.setTelemetry(aAddon.id, "name", locale.name);
     if (locale.creator)
       XPIProvider.setTelemetry(aAddon.id, "creator", locale.creator);
   }
 }
 
+class LangpackBootstrapScope {
+  install(data, reason) {}
+  uninstall(data, reason) {}
+
+  async startup(data, reason) {
+    let manifest = data.manifest;
+
+    this.langpackId = manifest["langpack-id"];
+    this.handle = aomStartup.registerChrome(data.resourceURI, manifest.entries);
+
+    let locales = manifest.languages;
+    let basePath = `resource://${this.langpackId}`;
+    const source = new FileSource(this.langpackId, locales, basePath);
+    L10nRegistry.registerSource(source);
+  }
+
+  shutdown(data, reason) {
+    L10nRegistry.unregisterSource(this.langpackId);
+    this.handle.destruct();
+  }
+}
+
 /**
  * The on-disk state of an individual XPI, created from an Object
  * as stored in the addonStartup.json file.
  */
 const JSON_FIELDS = Object.freeze([
   "bootstrapped",
   "changed",
   "dependencies",
@@ -4216,16 +4242,18 @@ this.XPIProvider = {
                                     addonId: aId,
                                     metadata: { addonID: aId } });
       logger.error("Attempted to load bootstrap scope from missing directory " + aFile.path);
       return;
     }
 
     if (isWebExtension(aType)) {
       activeAddon.bootstrapScope = Extension.getBootstrapScope(aId, aFile);
+    } else if (aType == "webextension-langpack") {
+      activeAddon.bootstrapScope = new LangpackBootstrapScope();
     } else {
       let uri = getURIForResourceInFile(aFile, "bootstrap.js").spec;
       if (aType == "dictionary")
         uri = "resource://gre/modules/addons/SpellCheckDictionaryBootstrap.js"
       else if (aType == "apiextension")
         uri = "resource://gre/modules/addons/APIExtensionBootstrap.js"
 
       activeAddon.bootstrapScope =