Bug 1108096 - Langpack support for b2g/gaia r=ferjm,sicking
☠☠ backed out by 4583ade26b7e ☠ ☠
authorFabrice Desré <fabrice@mozilla.com>
Sat, 10 Jan 2015 15:00:27 -0800
changeset 223262 df960e299bcd2bf93e95368623b5973056143f7e
parent 223261 36803557df5cc1e0d71a5d79ebf822769af99651
child 223263 44dd524de59fb671bc5fe4f43f371e4ddda55ca4
push id10769
push usercbook@mozilla.com
push dateMon, 12 Jan 2015 14:15:52 +0000
treeherderfx-team@0e9765732906 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersferjm, sicking
bugs1108096
milestone37.0a1
Bug 1108096 - Langpack support for b2g/gaia r=ferjm,sicking
dom/apps/AppsServiceChild.jsm
dom/apps/AppsUtils.jsm
dom/apps/Langpacks.jsm
dom/apps/Webapps.js
dom/apps/Webapps.jsm
dom/apps/moz.build
dom/apps/tests/langpack/event.html
dom/apps/tests/langpack/fr/app.json
dom/apps/tests/langpack/fr/app.properties
dom/apps/tests/langpack/index.html
dom/apps/tests/langpack/lang1.webapp
dom/apps/tests/langpack/lang1.webapp^headers^
dom/apps/tests/langpack/lang2.webapp
dom/apps/tests/langpack/lang2.webapp^headers^
dom/apps/tests/langpack/manifest.webapp
dom/apps/tests/langpack/manifest.webapp^headers^
dom/apps/tests/langpack/resources.html
dom/apps/tests/mochitest.ini
dom/apps/tests/test_langpacks.html
dom/tests/mochitest/webapps/test_list_api.xul
dom/webidl/Apps.webidl
--- a/dom/apps/AppsServiceChild.jsm
+++ b/dom/apps/AppsServiceChild.jsm
@@ -349,16 +349,25 @@ this.DOMApplicationRegistry = {
 
     let res = [];
     for (let id in this.webapps) {
       res.push(this.webapps[id]);
     }
     aCallback(res);
   },
 
+  getAdditionalLanguages: function(aManifestURL) {
+    for (let id in this.webapps) {
+      if (this.webapps[id].manifestURL == aManifestURL) {
+        return this.webapps[id].additionalLanguages || {};
+      }
+    }
+    return {};
+  },
+
   /**
    * nsIAppsService API
    */
   getAppByManifestURL: function getAppByManifestURL(aManifestURL) {
     debug("getAppByManifestURL " + aManifestURL);
     return AppsUtils.getAppByManifestURL(this.webapps, aManifestURL);
   },
 
--- a/dom/apps/AppsUtils.jsm
+++ b/dom/apps/AppsUtils.jsm
@@ -17,16 +17,20 @@ XPCOMUtils.defineLazyModuleGetter(this, 
   "resource://gre/modules/FileUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "WebappOSUtils",
   "resource://gre/modules/WebappOSUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
   "resource://gre/modules/NetUtil.jsm");
 
+XPCOMUtils.defineLazyServiceGetter(this, "appsService",
+                                   "@mozilla.org/AppsService;1",
+                                   "nsIAppsService");
+
 // Shared code for AppsServiceChild.jsm, TrustedHostedAppsUtils.jsm,
 // Webapps.jsm and Webapps.js
 
 this.EXPORTED_SYMBOLS =
   ["AppsUtils", "ManifestHelper", "isAbsoluteURI", "mozIApplication"];
 
 function debug(s) {
   //dump("-*- AppsUtils.jsm: " + s + "\n");
@@ -480,23 +484,31 @@ this.AppsUtils = {
   },
 
   allowUnsignedAddons: false, // for testing purposes.
 
   /**
    * Checks if the app role is allowed:
    * Only certified apps can be themes.
    * Only privileged or certified apps can be addons.
+   * Langpacks need to be privileged.
    * @param aRole   : the role assigned to this app.
    * @param aStatus : the APP_STATUS_* for this app.
    */
   checkAppRole: function(aRole, aStatus) {
     if (aRole == "theme" && aStatus !== Ci.nsIPrincipal.APP_STATUS_CERTIFIED) {
       return false;
     }
+    if (aRole == "langpack" && aStatus !== Ci.nsIPrincipal.APP_STATUS_PRIVILEGED) {
+      let allow = false;
+      try  {
+        allow = Services.prefs.getBoolPref("dom.apps.allow_unsigned_langpacks");
+      } catch(e) {}
+      return allow;
+    }
     if (!this.allowUnsignedAddons &&
         (aRole == "addon" &&
          aStatus !== Ci.nsIPrincipal.APP_STATUS_CERTIFIED &&
          aStatus !== Ci.nsIPrincipal.APP_STATUS_PRIVILEGED)) {
       return false;
     }
     return true;
   },
@@ -727,17 +739,26 @@ this.AppsUtils = {
 
     // Convert the binary hash data to a hex string.
     return [toHexString(hash.charCodeAt(i)) for (i in hash)].join("");
   },
 
   // Returns the hash for a JS object.
   computeObjectHash: function(aObject) {
     return this.computeHash(JSON.stringify(aObject));
-  }
+  },
+
+  getAppManifestURLFromWindow: function(aWindow) {
+    let appId = aWindow.document.nodePrincipal.appId;
+    if (appId === Ci.nsIScriptSecurityManager.NO_APP_ID) {
+      return null;
+    }
+
+    return appsService.getManifestURLByLocalId(appId);
+  },
 }
 
 /**
  * Helper object to access manifest information with locale support
  */
 this.ManifestHelper = function(aManifest, aOrigin, aManifestURL) {
   // If the app is packaged, we resolve uris against the origin.
   // If it's not, against the manifest url.
new file mode 100644
--- /dev/null
+++ b/dom/apps/Langpacks.jsm
@@ -0,0 +1,315 @@
+/* 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 Cu = Components.utils;
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/AppsUtils.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "ppmm",
+                                   "@mozilla.org/parentprocessmessagemanager;1",
+                                   "nsIMessageBroadcaster");
+
+this.EXPORTED_SYMBOLS = ["Langpacks"];
+
+let debug = Services.prefs.getBoolPref("dom.mozApps.debug")
+  ? (aMsg) => {
+      dump("-*-*- Langpacks: " + aMsg + "\n");
+    }
+  : (aMsg) => {};
+
+/**
+  * Langpack support
+  *
+  * Manifest format is:
+  *
+  * "languages-target" : { "app://*.gaiamobile.org/manifest.webapp": "2.2" },
+  * "languages-provided": {
+  * "de": {
+  *   "version": 201411051234,
+  *   "name": "Deutsch",
+  *   "apps": {
+  *     "app://calendar.gaiamobile.org/manifest.webapp": "/de/calendar",
+  *     "app://email.gaiamobile.org/manifest.webapp": "/de/email"
+  *    }
+  *  },
+  *  "role" : "langpack"
+  */
+
+this.Langpacks = {
+
+  _data: {},
+  _broadcaster: null,
+  _appIdFromManifestURL: null,
+
+  init: function() {
+    ppmm.addMessageListener("Webapps:GetLocalizationResource", this);
+  },
+
+  registerRegistryFunctions: function(aBroadcaster, aIdGetter) {
+    this._broadcaster = aBroadcaster;
+    this._appIdFromManifestURL = aIdGetter;
+  },
+
+  receiveMessage: function(aMessage) {
+    let data = aMessage.data;
+    let mm = aMessage.target;
+    switch (aMessage.name) {
+      case "Webapps:GetLocalizationResource":
+        this.getLocalizationResource(data, mm);
+        break;
+      default:
+        debug("Unexpected message: " + aMessage.name);
+    }
+  },
+
+  getAdditionalLanguages: function(aManifestURL) {
+    debug("getAdditionalLanguages " + aManifestURL);
+    let res = { langs: {} };
+    let langs = res.langs;
+    if (this._data[aManifestURL]) {
+      res.appId = this._data[aManifestURL].appId;
+      for (let lang in this._data[aManifestURL].langs) {
+        if (!langs[lang]) {
+          langs[lang] = [];
+        }
+        let current = this._data[aManifestURL].langs[lang];
+        langs[lang].push({
+          version: current.version,
+          name: current.name,
+          target: current.target
+        });
+      }
+    }
+    debug("Languages found: " + uneval(res));
+    return res;
+  },
+
+  sendAppUpdate: function(aManifestURL) {
+    debug("sendAppUpdate " + aManifestURL);
+    if (!this._broadcaster) {
+      debug("No broadcaster!");
+      return;
+    }
+
+    let res = this.getAdditionalLanguages(aManifestURL);
+    let message = {
+      id: res.appId,
+      app: {
+        additionalLanguages: res.langs
+      }
+    }
+    this._broadcaster("Webapps:UpdateState", message);
+  },
+
+  getLocalizationResource: function(aData, aMm) {
+    debug("getLocalizationResource " + uneval(aData));
+
+    function sendError(aMsg, aCode) {
+      debug(aMsg);
+      aMm.sendAsyncMessage("Webapps:GetLocalizationResource:Return",
+        { requestID: aData.requestID, oid: aData.oid, error: aCode });
+    }
+
+    // No langpack available for this app.
+    if (!this._data[aData.manifestURL]) {
+      return sendError("No langpack for this app.", "NoLangpack");
+    }
+
+    // We have langpack(s) for this app, but not for this language.
+    if (!this._data[aData.manifestURL].langs[aData.lang]) {
+      return sendError("No language " + aData.lang + " for this app.",
+                       "UnavailableLanguage");
+    }
+
+    // Check that we have the right version.
+    let item = this._data[aData.manifestURL].langs[aData.lang];
+    if (item.target != aData.version) {
+      return sendError("No version " + aData.version + " for this app.",
+                       "UnavailableVersion");
+    }
+
+    // The path can't be an absolute uri.
+    if (isAbsoluteURI(aData.path)) {
+      return sendError("url can't be absolute.", "BadUrl");
+    }
+
+    let href = item.url + aData.path;
+    debug("Will load " + href);
+
+    let xhr =  Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
+                 .createInstance(Ci.nsIXMLHttpRequest);
+    xhr.mozBackgroundRequest = true;
+    xhr.open("GET", href);
+
+    // Default to text response type, but the webidl binding takes care of
+    // validating the dataType value.
+    xhr.responseType = "text";
+    if (aData.dataType === "json") {
+      xhr.responseType = "json";
+    } else if (aData.dataType === "binary") {
+      xhr.responseType = "blob";
+    }
+
+    xhr.addEventListener("load", function() {
+      debug("Success loading " + href);
+      if (xhr.status >= 200 && xhr.status < 400) {
+        aMm.sendAsyncMessage("Webapps:GetLocalizationResource:Return",
+          { requestID: aData.requestID, oid: aData.oid, data: xhr.response });
+      } else {
+        sendError("Error loading " + href, "UnavailableResource");
+      }
+    });
+    xhr.addEventListener("error", function() {
+      sendError("Error loading " + href, "UnavailableResource");
+    });
+    xhr.send(null);
+  },
+
+  // Validates the langpack part of a manifest.
+  checkManifest: function(aManifest) {
+    if (!("languages-target" in aManifest)) {
+      debug("Error: no 'languages-target' property.")
+      return false;
+    }
+
+    if (!("languages-provided" in aManifest)) {
+      debug("Error: no 'languages-provided' property.")
+      return false;
+    }
+
+    for (let lang in aManifest["languages-provided"]) {
+      let item = aManifest["languages-provided"][lang];
+
+      if (!item.version) {
+        debug("Error: missing 'version' in languages-provided." + lang);
+        return false;
+      }
+
+      if (typeof item.version !== "number") {
+        debug("Error: languages-provided." + lang +
+              ".version must be a number but is a " + (typeof item.version));
+        return false;
+      }
+
+      if (!item.apps) {
+        debug("Error: missing 'apps' in languages-provided." + lang);
+        return false;
+      }
+
+      for (let app in item.apps) {
+        // Keys should be manifest urls, ie. absolute urls.
+        if (!isAbsoluteURI(app)) {
+          debug("Error: languages-provided." + lang + "." + app +
+                " must be an absolute manifest url.");
+          return false;
+        }
+
+        if (typeof item.apps[app] !== "string") {
+          debug("Error: languages-provided." + lang + ".apps." + app +
+                " value must be a string but is " + (typeof item.apps[app]) +
+                " : " + item.apps[app]);
+          return false;
+        }
+      }
+    }
+    return true;
+  },
+
+  // Check if this app is a langpack and update registration if needed.
+  register: function(aApp, aManifest) {
+    debug("register app " + aApp.manifestURL + " role=" + aApp.role);
+
+    if (aApp.role !== "langpack") {
+      debug("Not a langpack.");
+      // Not a langpack, but that's fine.
+      return;
+    }
+
+    if (!this.checkManifest(aManifest)) {
+      debug("Invalid langpack manifest.");
+      return;
+    }
+
+    let platformVersion = aManifest["languages-target"]
+                                   ["app://*.gaiamobile.org/manifest.webapp"];
+    let origin = Services.io.newURI(aApp.origin, null, null);
+
+    for (let lang in aManifest["languages-provided"]) {
+      let item = aManifest["languages-provided"][lang];
+      let version = item.version;   // The langpack version, not the platform.
+      let name = item.name || lang; // If no name specified, default to lang.
+      for (let app in item.apps) {
+        let sendEvent = false;
+        if (!this._data[app] ||
+            !this._data[app].langs[lang] ||
+            this._data[app].langs[lang].version > version) {
+          if (!this._data[app]) {
+            this._data[app] = {
+              appId: this._appIdFromManifestURL(app),
+              langs: {}
+            };
+          }
+          this._data[app].langs[lang] = {
+            version: version,
+            target: platformVersion,
+            name: name,
+            url: origin.resolve(item.apps[app]),
+            from: aApp.manifestURL
+          }
+          sendEvent = true;
+          debug("Registered " + app + " -> " + uneval(this._data[app].langs[lang]));
+        }
+
+        // Fire additionallanguageschange event.
+        // This will only be dispatched to documents using the langpack api.
+        if (sendEvent) {
+          this.sendAppUpdate(app);
+          ppmm.broadcastAsyncMessage(
+            "Webapps:AdditionalLanguageChange",
+            { manifestURL: app,
+              languages: this.getAdditionalLanguages(app).langs });
+        }
+      }
+    }
+  },
+
+  // Check if this app is a langpack and update registration by removing all
+  // the entries from this app.
+  unregister: function(aApp, aManifest) {
+    debug("unregister app " + aApp.manifestURL + " role=" + aApp.role);
+
+      if (aApp.role !== "langpack") {
+        debug("Not a langpack.");
+        // Not a langpack, but that's fine.
+        return;
+      }
+
+      for (let app in this._data) {
+        let sendEvent = false;
+        for (let lang in this._data[app].langs) {
+          if (this._data[app].langs[lang].from == aApp.manifestURL) {
+            sendEvent = true;
+            delete this._data[app].langs[lang];
+          }
+        }
+        // Fire additionallanguageschange event.
+        // This will only be dispatched to documents using the langpack api.
+        if (sendEvent) {
+          this.sendAppUpdate(app);
+          ppmm.broadcastAsyncMessage(
+              "Webapps:AdditionalLanguageChange",
+              { manifestURL: app,
+                languages: this.getAdditionalLanguages(app).langs });
+        }
+      }
+  }
+}
+
+Langpacks.init();
\ No newline at end of file
--- a/dom/apps/Webapps.js
+++ b/dom/apps/Webapps.js
@@ -39,21 +39,28 @@ function convertAppsArray(aApps, aWindow
 function WebappsRegistry() {
 }
 
 WebappsRegistry.prototype = {
   __proto__: DOMRequestIpcHelper.prototype,
 
   receiveMessage: function(aMessage) {
     let msg = aMessage.json;
-    if (msg.oid != this._id)
-      return
-    let req = this.getRequest(msg.requestID);
-    if (!req)
-      return;
+    let req;
+    if (msg.oid === this._id) {
+      if (aMessage.name == "Webapps:GetLocalizationResource:Return") {
+        req = this.takePromiseResolver(msg.requestID);
+      } else {
+        req = this.getRequest(msg.requestID);
+      }
+      if (!req) {
+        return;
+      }
+    }
+
     let app = msg.app;
     switch (aMessage.name) {
       case "Webapps:Install:Return:OK":
         this.removeMessageListeners("Webapps:Install:Return:KO");
         Services.DOMRequest.fireSuccess(req, createContentApplicationObject(this._window, app));
         cpmm.sendAsyncMessage("Webapps:Install:Return:Ack",
                               { manifestURL : app.manifestURL });
         break;
@@ -73,16 +80,36 @@ WebappsRegistry.prototype = {
       case "Webapps:CheckInstalled:Return:OK":
         this.removeMessageListeners(aMessage.name);
         Services.DOMRequest.fireSuccess(req, msg.app);
         break;
       case "Webapps:GetInstalled:Return:OK":
         this.removeMessageListeners(aMessage.name);
         Services.DOMRequest.fireSuccess(req, convertAppsArray(msg.apps, this._window));
         break;
+      case "Webapps:AdditionalLanguageChange":
+        // Check if the current page is from the app receiving the event.
+        let manifestURL = AppsUtils.getAppManifestURLFromWindow(this._window);
+        if (manifestURL && manifestURL == msg.manifestURL) {
+          // Let's dispatch an "additionallanguageschange" event on the document.
+          let doc = this._window.document;
+          let event = doc.createEvent("CustomEvent");
+          event.initCustomEvent("additionallanguageschange", true, true,
+                                Cu.cloneInto(msg.languages, this._window));
+          doc.dispatchEvent(event);
+        }
+        break;
+      case "Webapps:GetLocalizationResource:Return":
+        this.removeMessageListeners(["Webapps:GetLocalizationResource:Return"]);
+        if (msg.error) {
+          req.reject(new this._window.DOMError(msg.error));
+        } else {
+          req.resolve(Cu.cloneInto(msg.data, this._window));
+        }
+        break;
     }
     this.removeRequest(msg.requestID);
   },
 
   _getOrigin: function(aURL) {
     let uri = Services.io.newURI(aURL, null, null);
     return uri.prePath;
   },
@@ -226,46 +253,97 @@ WebappsRegistry.prototype = {
         : this._window.DOMApplicationsManager._create(this._window, mgmt.wrappedJSObject);
     }
     return this._mgmt;
   },
 
   uninit: function() {
     this._mgmt = null;
     cpmm.sendAsyncMessage("Webapps:UnregisterForMessages",
-                          ["Webapps:Install:Return:OK"]);
+                          ["Webapps:Install:Return:OK",
+                           "Webapps:AdditionalLanguageChange"]);
   },
 
   installPackage: function(aURL, aParams) {
     let request = this.createRequest();
 
     let uri = this._validateURL(aURL, request);
 
     if (uri && this._ensureForeground(request)) {
       this.addMessageListeners("Webapps:Install:Return:KO");
       cpmm.sendAsyncMessage("Webapps:InstallPackage",
                             this._prepareInstall(uri, request, aParams, true));
     }
 
     return request;
   },
 
+  _getCurrentAppManifestURL: function() {
+    let appId = this._window.document.nodePrincipal.appId;
+    if (appId === Ci.nsIScriptSecurityManager.NO_APP_ID) {
+      return null;
+    }
+
+    return appsService.getManifestURLByLocalId(appId);
+  },
+
+  getAdditionalLanguages: function() {
+    let manifestURL = AppsUtils.getAppManifestURLFromWindow(this._window);
+
+    return new this._window.Promise((aResolve, aReject) => {
+      if (!manifestURL) {
+        aReject("NotInApp");
+      } else {
+        let langs = DOMApplicationRegistry.getAdditionalLanguages(manifestURL);
+        aResolve(Cu.cloneInto(langs, this._window));
+      }
+    });
+  },
+
+  getLocalizationResource: function(aLanguage, aVersion, aPath, aType) {
+    let manifestURL = AppsUtils.getAppManifestURLFromWindow(this._window);
+
+    if (!manifestURL) {
+      return new Promise((aResolve, aReject) => {
+        aReject("NotInApp");
+      });
+    }
+
+    this.addMessageListeners(["Webapps:GetLocalizationResource:Return"]);
+    return this.createPromise((aResolve, aReject) => {
+      cpmm.sendAsyncMessage("Webapps:GetLocalizationResource", {
+        manifestURL: manifestURL,
+        lang: aLanguage,
+        version: aVersion,
+        path: aPath,
+        dataType: aType,
+        oid: this._id,
+        requestID: this.getPromiseResolverId({
+          resolve: aResolve,
+          reject: aReject
+        })
+      });
+    });
+  },
+
   // nsIDOMGlobalPropertyInitializer implementation
   init: function(aWindow) {
     const prefs = new Preferences();
 
     this._window = aWindow;
 
-    this.initDOMRequestHelper(aWindow, "Webapps:Install:Return:OK");
+    this.initDOMRequestHelper(aWindow, ["Webapps:Install:Return:OK",
+                                        "Webapps:AdditionalLanguageChange"]);
 
     let util = this._window.QueryInterface(Ci.nsIInterfaceRequestor)
                            .getInterface(Ci.nsIDOMWindowUtils);
     this._id = util.outerWindowID;
     cpmm.sendAsyncMessage("Webapps:RegisterForMessages",
-                          { messages: ["Webapps:Install:Return:OK"]});
+                          { messages: ["Webapps:Install:Return:OK",
+                                       "Webapps:AdditionalLanguageChange"]});
 
     let principal = aWindow.document.nodePrincipal;
     let appId = principal.appId;
     let app = appId && appsService.getAppByLocalId(appId);
 
     let isCurrentHomescreen = app &&
       app.manifestURL == prefs.get("dom.mozApps.homescreenURL") &&
       app.appStatus != Ci.nsIPrincipal.APP_STATUS_NOT_INSTALLED;
--- a/dom/apps/Webapps.jsm
+++ b/dom/apps/Webapps.jsm
@@ -76,16 +76,19 @@ XPCOMUtils.defineLazyModuleGetter(this, 
   "resource://gre/modules/WebappOSUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
   "resource://gre/modules/NetUtil.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "ScriptPreloader",
                                   "resource://gre/modules/ScriptPreloader.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "Langpacks",
+                                  "resource://gre/modules/Langpacks.jsm");
+
 XPCOMUtils.defineLazyModuleGetter(this, "TrustedHostedAppsUtils",
                                   "resource://gre/modules/TrustedHostedAppsUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "ImportExport",
                                   "resource://gre/modules/ImportExport.jsm");
 
 #ifdef MOZ_WIDGET_GONK
 XPCOMUtils.defineLazyGetter(this, "libcutils", function() {
@@ -238,16 +241,19 @@ this.DOMApplicationRegistry = {
     Services.obs.addObserver(this, "memory-pressure", false);
 
     AppDownloadManager.registerCancelFunction(this.cancelDownload.bind(this));
 
     this.appsFile = FileUtils.getFile(DIRECTORY_NAME,
                                       ["webapps", "webapps.json"], true).path;
 
     this.loadAndUpdateApps();
+
+    Langpacks.registerRegistryFunctions(this.broadcastMessage.bind(this),
+                                        this._appIdForManifestURL.bind(this));
   },
 
   // loads the current registry, that could be empty on first run.
   loadCurrentRegistry: function() {
     return AppsUtils.loadJSONAsync(this.appsFile).then((aData) => {
       if (!aData) {
         return;
       }
@@ -419,16 +425,17 @@ this.DOMApplicationRegistry = {
         let localeManifest = new ManifestHelper(aResult.manifest, app.origin, app.manifestURL);
         this._saveWidgetsFullPath(localeManifest, app);
 
         if (app.appStatus >= Ci.nsIPrincipal.APP_STATUS_PRIVILEGED) {
           app.redirects = this.sanitizeRedirects(aResult.redirects);
         }
         app.kind = this.appKind(app, aResult.manifest);
         UserCustomizations.register(aResult.manifest, app);
+        Langpacks.register(app, aResult.manifest);
       });
 
       // Nothing else to do but notifying we're ready.
       this.notifyAppsRegistryReady();
     }
   }),
 
   updateDataStoreForApp: Task.async(function*(aId) {
@@ -1144,16 +1151,17 @@ this.DOMApplicationRegistry = {
         if (app.appStatus >= Ci.nsIPrincipal.APP_STATUS_PRIVILEGED) {
           app.redirects = this.sanitizeRedirects(manifest.redirects);
         }
         app.kind = this.appKind(app, aResult.manifest);
         this._registerSystemMessages(manifest, app);
         this._registerInterAppConnections(manifest, app);
         appsToRegister.push({ manifest: manifest, app: app });
         UserCustomizations.register(manifest, app);
+        Langpacks.register(app, manifest);
       });
       this._safeToClone.resolve();
       this._registerActivitiesForApps(appsToRegister, aRunUpdate);
     });
   },
 
   observe: function(aSubject, aTopic, aData) {
     if (aTopic == "xpcom-shutdown") {
@@ -1515,16 +1523,18 @@ this.DOMApplicationRegistry = {
 
     let res = {};
     let done = false;
 
     // We allow cloning the registry when the local processing has been done.
     this.safeToClone.then( () => {
       for (let id in this.webapps) {
         tmp.push({ id: id });
+        this.webapps[id].additionalLanguages =
+          Langpacks.getAdditionalLanguages(this.webapps[id].manifestURL).langs;
       }
       this._readManifests(tmp).then(
         function(manifests) {
           manifests.forEach((item) => {
             res[item.id] = item.manifest;
           });
           done = true;
         }
@@ -1959,16 +1969,20 @@ this.DOMApplicationRegistry = {
       }
       delete app.staged;
     }
 
     delete app.retryingDownload;
 
     // Update the asm.js scripts we need to compile.
     yield ScriptPreloader.preload(app, newManifest);
+
+    // Update langpack information.
+    Langpacks.register(app, newManifest);
+
     yield this._saveApps();
     // Update the handlers and permissions for this app.
     this.updateAppHandlers(oldManifest, newManifest, app);
 
     let updateManifest = yield AppsUtils.loadJSONAsync(staged.path);
     let appObject = AppsUtils.cloneAppObject(app);
     appObject.updateManifest = updateManifest;
     this.notifyUpdateHandlers(appObject, newManifest, appFile.path);
@@ -2076,21 +2090,23 @@ this.DOMApplicationRegistry = {
       this._registerSystemMessages(aNewManifest, aApp);
       this._registerActivities(aNewManifest, aApp, true);
       this._registerInterAppConnections(aNewManifest, aApp);
     } else {
       // Nothing else to do but notifying we're ready.
       this.notifyAppsRegistryReady();
     }
 
-    // Update user customizations.
+    // Update user customizations and langpacks.
     if (aOldManifest) {
       UserCustomizations.unregister(aOldManifest, aApp);
+      Langpacks.unregister(aApp, aOldManifest);
     }
     UserCustomizations.register(aNewManifest, aApp);
+    Langpacks.register(aApp, aNewManifest);
   },
 
   checkForUpdate: function(aData, aMm) {
     debug("checkForUpdate for " + aData.manifestURL);
 
     function sendError(aError) {
       debug("checkForUpdate error " + aError);
       aData.error = aError;
@@ -3180,16 +3196,19 @@ this.DOMApplicationRegistry = {
       app: app,
       manifest: aManifest,
       manifestURL: aNewApp.manifestURL
     });
 
     // Check if we have asm.js code to preload for this application.
     yield ScriptPreloader.preload(aNewApp, aManifest);
 
+    // Update langpack information.
+    yield Langpacks.register(aNewApp, aManifest);
+
     this.broadcastMessage("Webapps:FireEvent", {
       eventType: ["downloadsuccess", "downloadapplied"],
       manifestURL: aNewApp.manifestURL
     });
   }),
 
   _nextLocalId: function() {
     let id = Services.prefs.getIntPref("dom.mozApps.maxLocalId") + 1;
@@ -4089,16 +4108,17 @@ this.DOMApplicationRegistry = {
 
     // Then notify observers.
     Services.obs.notifyObservers(null, "webapps-uninstall", JSON.stringify(aApp));
 
     if (supportSystemMessages()) {
       this._unregisterActivities(aApp.manifest, aApp);
     }
     UserCustomizations.unregister(aApp.manifest, aApp);
+    Langpacks.unregister(aApp, aApp.manifest);
 
     let dir = this._getAppDir(id);
     try {
       dir.remove(true);
     } catch (e) {}
 
     delete this.webapps[id];
 
--- a/dom/apps/moz.build
+++ b/dom/apps/moz.build
@@ -29,16 +29,17 @@ EXTRA_COMPONENTS += [
     'Webapps.manifest',
 ]
 
 EXTRA_JS_MODULES += [
     'AppDownloadManager.jsm',
     'AppsServiceChild.jsm',
     'FreeSpaceWatcher.jsm',
     'InterAppCommService.jsm',
+    'Langpacks.jsm',
     'OfflineCacheInstaller.jsm',
     'PermissionsInstaller.jsm',
     'PermissionsTable.jsm',
     'StoreTrustAnchor.jsm',
     'UserCustomizations.jsm',
 ]
 
 EXTRA_PP_JS_MODULES += [
new file mode 100644
--- /dev/null
+++ b/dom/apps/tests/langpack/event.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Langpack Test : event</title>
+    <script>
+var baseURL = "http://mochi.test:8888/tests/dom/apps/tests/langpack/";
+var eventCount = 0;
+
+function languageChanged(evt) {
+  eventCount++;
+  alert(JSON.stringify(evt.detail));
+  if (eventCount == 1) {
+    var req = navigator.mozApps.install(baseURL + "lang2.webapp");
+  }
+}
+
+// Set up the event handler, and install an app.
+function run() {
+  document.addEventListener("additionallanguageschange", languageChanged);
+
+  navigator.mozApps.install(baseURL + "lang1.webapp");
+}
+    </script>
+  </head>
+  <body onload="run()">
+    <h1>Langpack Test : event</h1>
+  </body>
+</html>
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/dom/apps/tests/langpack/fr/app.json
@@ -0,0 +1,1 @@
+{ "hello" : "Bonjour" }
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/dom/apps/tests/langpack/fr/app.properties
@@ -0,0 +1,1 @@
+hello=Bonjour
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/dom/apps/tests/langpack/index.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Langpack Test : getAdditionalLanguages()</title>
+    <script>
+function run() {
+  navigator.mozApps.getAdditionalLanguages().then(languages => {
+    alert(JSON.stringify(languages));
+  });
+}
+    </script>
+  </head>
+  <body onload="run()">
+    <h1>Langpack Test : getAdditionalLanguages()</h1>
+  </body>
+</html>
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/dom/apps/tests/langpack/lang1.webapp
@@ -0,0 +1,14 @@
+{
+  "name": "French locale",
+  "languages-target" : { "app://*.gaiamobile.org/manifest.webapp": "2.2" },
+  "languages-provided": {
+    "fr": {
+      "version": 201411051234,
+      "name": "Français",
+      "apps": {
+        "http://mochi.test:8888/tests/dom/apps/tests/langpack/manifest.webapp": "tests/dom/apps/tests/langpack/fr/"
+       }
+     }
+   },
+   "role" : "langpack"
+}
new file mode 100644
--- /dev/null
+++ b/dom/apps/tests/langpack/lang1.webapp^headers^
@@ -0,0 +1,1 @@
+Content-Type: application/manifest+json
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/dom/apps/tests/langpack/lang2.webapp
@@ -0,0 +1,21 @@
+{
+  "name": "German an Polish locales",
+  "languages-target" : { "app://*.gaiamobile.org/manifest.webapp": "2.2" },
+  "languages-provided": {
+    "de": {
+      "version": 201411051234,
+      "name": "Deutsch",
+      "apps": {
+        "http://mochi.test:8888/tests/dom/apps/tests/langpack/manifest.webapp": "tests/dom/apps/tests/langpack/de/"
+       }
+     },
+     "pl": {
+      "version": 201411051234,
+      "name": "Polski",
+      "apps": {
+        "http://mochi.test:8888/tests/dom/apps/tests/langpack/manifest.webapp": "tests/dom/apps/tests/langpack/pl/"
+       }
+     }
+   },
+   "role" : "langpack"
+}
new file mode 100644
--- /dev/null
+++ b/dom/apps/tests/langpack/lang2.webapp^headers^
@@ -0,0 +1,1 @@
+Content-Type: application/manifest+json
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/dom/apps/tests/langpack/manifest.webapp
@@ -0,0 +1,3 @@
+{
+  "name": "Localization test app"
+}
new file mode 100644
--- /dev/null
+++ b/dom/apps/tests/langpack/manifest.webapp^headers^
@@ -0,0 +1,1 @@
+Content-Type: application/manifest+json
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/dom/apps/tests/langpack/resources.html
@@ -0,0 +1,92 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Langpack Test : resources</title>
+    <script>
+function success(data) {
+  return new Promise(function(resolve, reject) {
+    if (typeof data === "object") {
+      // Read what's inside the blob.
+      var reader = new FileReader();
+      reader.onload = function(e) {
+        alert(e.target.result);
+        resolve();
+      };
+      reader.readAsText(data);
+    } else {
+      alert(data);
+      resolve();
+    }
+  });
+}
+
+function successJSON(data) {
+  return new Promise(function(resolve, reject) {
+    alert(JSON.stringify(data));
+    resolve();
+  });
+}
+
+function error(domError) {
+  return new Promise(function(resolve, reject) {
+    alert(domError.name);
+    resolve();
+  });
+}
+
+// Error: Bad resource.
+function test1() {
+  return navigator.mozApps.getLocalizationResource("fr", "2.2", "./foo.html", "binary")
+                          .then(success, error);
+}
+
+// Error: Unknown locale.
+function test2() {
+  return navigator.mozApps.getLocalizationResource("es", "2.2", "./foo.html", "binary")
+                          .then(success, error);
+}
+
+// Error: Bad version.
+function test3() {
+  return navigator.mozApps.getLocalizationResource("fr", "2.0", "./foo.html", "binary")
+                          .then(success, error);
+}
+
+// Error: Absolute url.
+function test4() {
+  return navigator.mozApps.getLocalizationResource("fr", "2.2", "http://example.com/foo.html", "binary")
+                          .then(success, error);
+}
+
+// Ok, binary data.
+function test5() {
+  return navigator.mozApps.getLocalizationResource("fr", "2.2", "./app.properties", "binary")
+                          .then(success, error);
+}
+
+// Ok, text data.
+function test6() {
+  return navigator.mozApps.getLocalizationResource("fr", "2.2", "./app.properties", "text")
+                          .then(success, error);
+}
+
+// Ok, json data.
+function test7() {
+  return navigator.mozApps.getLocalizationResource("fr", "2.2", "./app.json", "json")
+                          .then(successJSON, error);
+}
+
+function run() {
+  test1().then(test2)
+         .then(test3)
+         .then(test4)
+         .then(test5)
+         .then(test6)
+         .then(test7);
+}
+    </script>
+  </head>
+  <body onload="run()">
+    <h1>Langpack Test : resources</h1>
+  </body>
+</html>
\ No newline at end of file
--- a/dom/apps/tests/mochitest.ini
+++ b/dom/apps/tests/mochitest.ini
@@ -17,32 +17,34 @@ support-files =
   file_trusted_app.template.webapp
   file_invalidWidget_app.template.webapp
   file_packaged_app.sjs
   file_packaged_app.template.html
   file_packaged_app.template.webapp
   file_widget_app.template.webapp
   file_widget_app.template.html
   file_test_widget.js
+  langpack/*
   signed_app.sjs
   signed_app_template.webapp
   signed/*
   test_packaged_app_common.js
   marketplace/*
   pkg_install_iframe.html
 
 [test_app_addons.html]
 skip-if = os == "android" || toolkit == "gonk" # embed-apps doesn't work in mochitest app
 [test_app_enabled.html]
 [test_app_update.html]
 skip-if = os == "android" || toolkit == "gonk" # embed-apps doesn't work in mochitest app
 [test_bug_795164.html]
 [test_import_export.html]
 [test_install_multiple_apps_origin.html]
 [test_install_receipts.html]
+[test_langpacks.html]
 [test_marketplace_pkg_install.html]
 skip-if = buildapp == "b2g" || toolkit == "android" # see bug 989806
 [test_packaged_app_install.html]
 skip-if = (toolkit == 'android' && processor == 'x86') #x86 only
 [test_packaged_app_update.html]
 skip-if = (toolkit == 'android' && processor == 'x86') #x86 only
 [test_receipt_operations.html]
 [test_signed_pkg_install.html]
new file mode 100644
--- /dev/null
+++ b/dom/apps/tests/test_langpacks.html
@@ -0,0 +1,221 @@
+<!DOCTYPE HTML><!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1108096
+-->
+<html>
+  <head>
+  <meta charset="utf-8">
+  <title>Test for Bug 1108096 - Langpack support</title>
+  <script type="text/javascript" src="/MochiKit/MochiKit.js"></script>
+  <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+  <script type="application/javascript;version=1.7">
+/**
+  * Test for Bug 1108096
+  * This file covers testing langpacks.
+  *
+  * The setup is as follows:
+  * - app is the localizable application.
+  * - langpack1 provides the French locale.
+  * - langpack2 provides the German and Polish locales.
+  */
+
+SimpleTest.waitForExplicitFinish();
+
+const uriPrefix = "http://mochi.test:8888/tests/dom/apps/tests/langpack/";
+
+let appManifestURL = uriPrefix + "manifest.webapp";
+let lang1ManifestURL = uriPrefix + "lang1.webapp";
+let lang2ManifestURL = uriPrefix + "lang2.webapp";
+
+let gGenerator = runTest();
+
+function go() {
+  gGenerator.next();
+}
+
+function continueTest() {
+  try {
+    gGenerator.next();
+  } catch (e if e instanceof StopIteration) {
+    SimpleTest.finish();
+  }
+}
+
+function mozAppsError() {
+  ok(false, "mozApps error: " + this.error.name);
+  SimpleTest.finish();
+}
+
+// Triggers one navigation test to the given page.
+// Waits for alert() messages before tearing down the iframe.
+function openPage(pageURL, messages) {
+  info("Navigating to " + pageURL);
+  let ifr = document.createElement("iframe");
+  let listener = function(event) {
+    let message = messages.shift();
+    is(event.detail.message, message, "Checking alert message for " + pageURL);
+    if (messages.length == 0) {
+      ifr.removeEventListener("mozbrowsershowmodalprompt", listener);
+      ifr.parentNode.removeChild(ifr);
+      continueTest();
+    }
+  }
+
+  ifr.addEventListener("mozbrowsershowmodalprompt", listener, false);
+
+  // Open the app url in an iframe.
+  ifr.setAttribute("mozapp", appManifestURL);
+  ifr.setAttribute("mozbrowser", "true");
+  ifr.setAttribute("src", uriPrefix + pageURL);
+  document.getElementById("container").appendChild(ifr);
+}
+
+let apps = [];
+
+function installApp(manifestURL) {
+  info("About to install app at " + manifestURL);
+  let req = navigator.mozApps.install(manifestURL);
+  req.onsuccess = function() {
+    is(req.result.manifestURL, manifestURL, "app installed");
+    if (req.result.installState == "installed") {
+      is(req.result.installState, "installed", "app downloaded");
+      continueTest();
+    } else {
+      req.result.ondownloadapplied = function() {
+        is(req.result.installState, "installed", "app downloaded");
+        continueTest();
+      }
+    }
+  }
+  req.onerror = mozAppsError;
+}
+
+function runTest() {
+  // Set up.
+  SpecialPowers.setAllAppsLaunchable(true);
+  SpecialPowers.pushPrefEnv({'set': [
+    ["dom.mozBrowserFramesEnabled", true],
+    ["dom.apps.allow_unsigned_langpacks", true] ]},continueTest);
+  yield undefined;
+
+  SpecialPowers.pushPermissions(
+    [{ "type": "webapps-manage", "allow": 1, "context": document },
+     { "type": "embed-apps", "allow": 1, "context": document },
+     { "type": "browser", "allow": 1, "context": document } ],
+    continueTest);
+  yield undefined;
+
+  navigator.mozApps.mgmt.oninstall = function(evt) {
+    apps.push(evt.application);
+  };
+
+  let _ = JSON.stringify;
+
+  SpecialPowers.autoConfirmAppInstall(continueTest);
+  yield undefined;
+
+  SpecialPowers.autoConfirmAppUninstall(continueTest);
+  yield undefined;
+
+  // Install test app.
+  installApp(appManifestURL);
+  yield undefined;
+
+  // Opens the iframe to the test page, initial state.
+  // No locale is available.
+  openPage("index.html", [_({})]);
+  yield undefined;
+
+  // Install the fr langpack.
+  installApp(lang1ManifestURL);
+  yield undefined;
+
+  // Opens the iframe to the test page.
+  // Only the French locale is available.
+  openPage("index.html",
+    [_({"fr":[{"version":201411051234,"name":"Français","target":"2.2"}]})]);
+  yield undefined;
+
+  // Install the de and pl langpack.
+  installApp(lang2ManifestURL);
+  yield undefined;
+
+  // Opens the iframe to the test page.
+  // French, German and Polish locales are available.
+  openPage("index.html",
+    [_({"fr":[{"version":201411051234,"name":"Français","target":"2.2"}],"de":[{"version":201411051234,"name":"Deutsch","target":"2.2"}],"pl":[{"version":201411051234,"name":"Polski","target":"2.2"}]})]);
+  yield undefined;
+
+  // Uninstall the second langpack.
+  {
+    let app = apps.pop();
+    info("Uninstalling " + app.manifestURL);
+    req = navigator.mozApps.mgmt.uninstall(app);
+    req.onsuccess = continueTest;
+    req.onerror = mozAppsError;
+    yield undefined;
+  }
+
+  // Opens the iframe to the test page.
+  // Only the French locale is available.
+  openPage("index.html",
+    [_({"fr":[{"version":201411051234,"name":"Français","target":"2.2"}]})]);
+  yield undefined;
+
+  // Uninstall the first langpack.
+  {
+    let app = apps.pop();
+    info("Uninstalling " + app.manifestURL);
+    req = navigator.mozApps.mgmt.uninstall(app);
+    req.onsuccess = continueTest;
+    req.onerror = mozAppsError;
+    yield undefined;
+  }
+
+  // Opens the iframe to the test page, initial state.
+  // No locale is available.
+  openPage("index.html",
+    ["{}"]);
+  yield undefined;
+
+  // Opens the iframe to the event test page.
+  // Will get additionallanguageschange events.
+  openPage("event.html",
+    [_({"fr":[{"version":201411051234,"name":"Français","target":"2.2"}]}),
+     _({"fr":[{"version":201411051234,"name":"Français","target":"2.2"}],"de":[{"version":201411051234,"name":"Deutsch","target":"2.2"}]}),
+     _({"fr":[{"version":201411051234,"name":"Français","target":"2.2"}],"de":[{"version":201411051234,"name":"Deutsch","target":"2.2"}],"pl":[{"version":201411051234,"name":"Polski","target":"2.2"}]})]);
+  yield undefined;
+
+  // Opens the iframe to the resource test page.
+  openPage("resources.html",
+    ["UnavailableResource",
+     "UnavailableLanguage",
+     "UnavailableVersion",
+     "BadUrl",
+     "hello=Bonjour",
+     "hello=Bonjour",
+     _({"hello":"Bonjour"})]);
+  yield undefined;
+
+  // Clean up after ourselves by uninstalling apps.
+  info(apps.length + " applications to uninstall.");
+  while (apps.length) {
+    let app = apps.pop();
+    req = navigator.mozApps.mgmt.uninstall(app);
+    req.onsuccess = continueTest;
+    req.onerror = mozAppsError;
+    yield undefined;
+  }
+}
+
+  </script>
+  </head>
+<body onload="go()">
+<p id="display"></p>
+<div id="content" style="display: none">
+</div>
+<pre id="test">
+</pre>
+<div id="container"></div>
+</body>
+</html>
--- a/dom/tests/mochitest/webapps/test_list_api.xul
+++ b/dom/tests/mochitest/webapps/test_list_api.xul
@@ -15,17 +15,19 @@
   <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=741549"
      target="_blank">Mozilla Bug 741549</a>
   </body>
 
 <script>
 
 var props = {
   checkInstalled: "function",
+  getAdditionalLanguages: "function",
   getInstalled: "function",
+  getLocalizationResource: "function",
   getSelf: "function",
   install: "function",
   installPackage: "function",
   mgmt: "object",
 };
 
 isDeeply([p for (p in navigator.mozApps)].sort(), Object.keys(props).sort(),
          "navigator.mozApps has only the expected properties");
--- a/dom/webidl/Apps.webidl
+++ b/dom/webidl/Apps.webidl
@@ -4,26 +4,49 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/.
  */
 
 dictionary InstallParameters {
   sequence<DOMString> receipts = [];
   sequence<DOMString> categories = [];
 };
 
+dictionary LanguageDesc {
+  DOMString target;
+  DOMString version;
+  DOMString name;
+};
+
+enum LocaleResourceType {
+  "binary",
+  "json",
+  "text"
+};
+
 [NoInterfaceObject, NavigatorProperty="mozApps",
  JSImplementation="@mozilla.org/webapps;1"]
 interface DOMApplicationsRegistry {
   [CheckPermissions="webapps-manage"]
   readonly attribute DOMApplicationsManager mgmt;
   DOMRequest install(DOMString url, optional InstallParameters params);
   DOMRequest installPackage(DOMString url, optional InstallParameters params);
   DOMRequest getSelf();
   DOMRequest getInstalled();
   DOMRequest checkInstalled(DOMString manifestUrl);
+
+  // Language pack API.
+  // These promises will be rejected if the page is not in an app context,
+  // i.e. they are implicitely acting on getSelf().
+  Promise<MozMap<sequence<LanguageDesc>>> getAdditionalLanguages();
+  // Resolves to a different object depending on the dataType value.
+  Promise<any>
+    getLocalizationResource(DOMString language,
+                            DOMString version,
+                            DOMString path,
+                            LocaleResourceType dataType);
 };
 
 [JSImplementation="@mozilla.org/webapps/application;1", ChromeOnly]
 interface DOMApplication : EventTarget {
   // manifest and updateManifest will be turned into dictionaries once
   // in bug 1053033 once bug 963382 is fixed.
   readonly attribute any manifest;
   readonly attribute any updateManifest;
@@ -66,28 +89,28 @@ interface DOMApplication : EventTarget {
   DOMRequest checkForUpdate();
 
   /**
    * Inter-App Communication APIs.
    *
    * https://wiki.mozilla.org/WebAPI/Inter_App_Communication_Alt_proposal
    *
    */
-   Promise<MozInterAppConnection> connect(DOMString keyword, optional any rules);
+  Promise<MozInterAppConnection> connect(DOMString keyword, optional any rules);
 
-   Promise<sequence<MozInterAppMessagePort>> getConnections();
+  Promise<sequence<MozInterAppMessagePort>> getConnections();
 
-    // Receipts handling functions.
-    DOMRequest addReceipt(optional DOMString receipt);
-    DOMRequest removeReceipt(optional DOMString receipt);
-    DOMRequest replaceReceipt(optional DOMString oldReceipt,
-                              optional DOMString newReceipt);
+  // Receipts handling functions.
+  DOMRequest addReceipt(optional DOMString receipt);
+  DOMRequest removeReceipt(optional DOMString receipt);
+  DOMRequest replaceReceipt(optional DOMString oldReceipt,
+                            optional DOMString newReceipt);
 
-    // Export this app as a shareable Blob.
-    Promise<Blob> export();
+  // Export this app as a shareable Blob.
+  Promise<Blob> export();
 };
 
 [JSImplementation="@mozilla.org/webapps/manager;1",
  ChromeOnly,
  CheckPermissions="webapps-manage"]
 interface DOMApplicationsManager : EventTarget {
   DOMRequest getAll();
   DOMRequest getNotInstalled();