Bug 915838 - Provide add-ons a standard directory to store data, settings. r=Unfocused
authorAlexander J. Vincent <ajvincent@gmail.com>
Wed, 16 Oct 2013 17:10:50 -0700
changeset 183205 3fa7b987fb9e84fac97b220e13f81071be25ae7d
parent 183204 205ea7c36d13b5d07db4d7fc7b59df72fd7397fd
child 183206 ec02487ed239a2989cfc11c0bbbffb0c76fd3baf
push idunknown
push userunknown
push dateunknown
reviewersUnfocused
bugs915838
milestone32.0a1
Bug 915838 - Provide add-ons a standard directory to store data, settings. r=Unfocused
toolkit/mozapps/extensions/internal/XPIProvider.jsm
toolkit/mozapps/extensions/test/addons/test_data_directory/install.rdf
toolkit/mozapps/extensions/test/xpcshell/test_dataDirectory.js
toolkit/mozapps/extensions/test/xpcshell/xpcshell-shared.ini
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -392,27 +392,48 @@ SafeInstallOperation.prototype = {
    * files that have been moved will be moved back to their original location.
    *
    * @param  aFile
    *         The file or directory to be moved.
    * @param  aTargetDirectory
    *         The directory to move into, this is expected to be an empty
    *         directory.
    */
-  move: function SIO_move(aFile, aTargetDirectory) {
+  moveUnder: function SIO_move(aFile, aTargetDirectory) {
     try {
       this._installDirEntry(aFile, aTargetDirectory, false);
     }
     catch (e) {
       this.rollback();
       throw e;
     }
   },
 
   /**
+   * Renames a file to a new location.  If an error occurs then all
+   * files that have been moved will be moved back to their original location.
+   *
+   * @param  aOldLocation
+   *         The old location of the file.
+   * @param  aNewLocation
+   *         The new location of the file.
+   */
+  moveTo: function(aOldLocation, aNewLocation) {
+    try {
+      let oldFile = aOldLocation.clone(), newFile = aNewLocation.clone();
+      oldFile.moveTo(newFile.parent, newFile.leafName);
+      this._installedFiles.push({ oldFile: oldFile, newFile: newFile, isMoveTo: true});
+    }
+    catch(e) {
+      this.rollback();
+      throw e;
+    }
+  },
+
+  /**
    * Copies a file or directory into a new directory. If an error occurs then
    * all new files that have been created will be removed.
    *
    * @param  aFile
    *         The file or directory to be copied.
    * @param  aTargetDirectory
    *         The directory to copy into, this is expected to be an empty
    *         directory.
@@ -430,17 +451,20 @@ SafeInstallOperation.prototype = {
   /**
    * Rolls back all the moves that this operation performed. If an exception
    * occurs here then both old and new directories are left in an indeterminate
    * state
    */
   rollback: function SIO_rollback() {
     while (this._installedFiles.length > 0) {
       let move = this._installedFiles.pop();
-      if (move.newFile.isDirectory()) {
+      if (move.isMoveTo) {
+        move.newFile.moveTo(oldDir.parent, oldDir.leafName);
+      }
+      else if (move.newFile.isDirectory()) {
         let oldDir = move.oldFile.parent.clone();
         oldDir.append(move.oldFile.leafName);
         oldDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
       }
       else if (!move.oldFile) {
         // No old file means this was a copied file
         move.newFile.remove(true);
       }
@@ -6145,16 +6169,32 @@ AddonInternal.prototype = {
           aTargetApp.maxVersion = aUpdateTarget.maxVersion;
         }
       });
     });
     this.appDisabled = !isUsableAddon(this);
   },
 
   /**
+   * getDataDirectory tries to execute the callback with two arguments:
+   * 1) the path of the data directory within the profile,
+   * 2) any exception generated from trying to build it.
+   */
+  getDataDirectory: function(callback) {
+    let parentPath = OS.Path.join(OS.Constants.Path.profileDir, "extension-data");
+    let dirPath = OS.Path.join(parentPath, this.id);
+
+    Task.spawn(function*() {
+      yield OS.File.makeDir(parentPath, {ignoreExisting: true});
+      yield OS.File.makeDir(dirPath, {ignoreExisting: true});
+    }).then(() => callback(dirPath, null),
+            e => callback(dirPath, e));
+  },
+
+  /**
    * toJSON is called by JSON.stringify in order to create a filtered version
    * of this object to be serialized to a JSON file. A new object is returned
    * with copies of all non-private properties. Functions, getters and setters
    * are not copied.
    *
    * @param  aKey
    *         The key that this object is being serialized as in the JSON.
    *         Unused here since this is always the main object serialized
@@ -6246,17 +6286,18 @@ function AddonWrapper(aAddon) {
     }
 
     return [objValue, false];
   }
 
   ["id", "syncGUID", "version", "type", "isCompatible", "isPlatformCompatible",
    "providesUpdatesSecurely", "blocklistState", "blocklistURL", "appDisabled",
    "softDisabled", "skinnable", "size", "foreignInstall", "hasBinaryComponents",
-   "strictCompatibility", "compatibilityOverrides", "updateURL"].forEach(function(aProp) {
+   "strictCompatibility", "compatibilityOverrides", "updateURL",
+   "getDataDirectory"].forEach(function(aProp) {
      this.__defineGetter__(aProp, function AddonWrapper_propertyGetter() aAddon[aProp]);
   }, this);
 
   ["fullDescription", "developerComments", "eula", "supportURL",
    "contributionURL", "contributionAmount", "averageRating", "reviewCount",
    "reviewURL", "totalDownloads", "weeklyDownloads", "dailyUsers",
    "repositoryStatus"].forEach(function(aProp) {
     this.__defineGetter__(aProp, function AddonWrapper_repoPropertyGetter() {
@@ -7047,41 +7088,66 @@ DirectoryInstallLocation.prototype = {
     let transaction = new SafeInstallOperation();
 
     let self = this;
     function moveOldAddon(aId) {
       let file = self._directory.clone();
       file.append(aId);
 
       if (file.exists())
-        transaction.move(file, trashDir);
+        transaction.moveUnder(file, trashDir);
 
       file = self._directory.clone();
       file.append(aId + ".xpi");
       if (file.exists()) {
         flushJarCache(file);
-        transaction.move(file, trashDir);
+        transaction.moveUnder(file, trashDir);
       }
     }
 
     // If any of these operations fails the finally block will clean up the
     // temporary directory
     try {
       moveOldAddon(aId);
-      if (aExistingAddonID && aExistingAddonID != aId)
+      if (aExistingAddonID && aExistingAddonID != aId) {
         moveOldAddon(aExistingAddonID);
 
+        {
+          // Move the data directories.
+          /* XXX ajvincent We can't use OS.File:  installAddon isn't compatible
+           * with Promises, nor is SafeInstallOperation.  Bug 945540 has been filed
+           * for porting to OS.File.
+           */
+          let oldDataDir = FileUtils.getDir(
+            KEY_PROFILEDIR, ["extension-data", aExistingAddonID], false, true
+          );
+
+          if (oldDataDir.exists()) {
+            let newDataDir = FileUtils.getDir(
+              KEY_PROFILEDIR, ["extension-data", aId], false, true
+            );
+            if (newDataDir.exists()) {
+              let trashData = trashDir.clone();
+              trashData.append("data-directory");
+              transaction.moveUnder(newDataDir, trashData);
+            }
+
+            transaction.moveTo(oldDataDir, newDataDir);
+          }
+        }
+      }
+
       if (aCopy) {
         transaction.copy(aSource, this._directory);
       }
       else {
         if (aSource.isFile())
           flushJarCache(aSource);
 
-        transaction.move(aSource, this._directory);
+        transaction.moveUnder(aSource, this._directory);
       }
     }
     finally {
       // It isn't ideal if this cleanup fails but it isn't worth rolling back
       // the install because of it.
       try {
         recursiveRemove(trashDir);
       }
@@ -7144,17 +7210,17 @@ DirectoryInstallLocation.prototype = {
     if (file.leafName != aId) {
       logger.debug("uninstallAddon: flushing jar cache " + file.path + " for addon " + aId);
       flushJarCache(file);
     }
 
     let transaction = new SafeInstallOperation();
 
     try {
-      transaction.move(file, trashDir);
+      transaction.moveUnder(file, trashDir);
     }
     finally {
       // It isn't ideal if this cleanup fails, but it is probably better than
       // rolling back the uninstall at this point
       try {
         recursiveRemove(trashDir);
       }
       catch (e) {
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/addons/test_data_directory/install.rdf
@@ -0,0 +1,22 @@
+<?xml version="1.0"?>
+
+<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+
+  <Description about="urn:mozilla:install-manifest">
+    <em:id>datadirectory1@tests.mozilla.org</em:id>
+    <em:version>1.0</em:version>
+
+    <!-- Front End MetaData -->
+    <em:name>Test Data Directory 1</em:name>
+    <em:description>Test Description</em:description>
+
+    <em:targetApplication>
+      <Description>
+        <em:id>xpcshell@tests.mozilla.org</em:id>
+        <em:minVersion>1</em:minVersion>
+        <em:maxVersion>1</em:maxVersion>
+      </Description>
+    </em:targetApplication>
+  </Description>
+</RDF>
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_dataDirectory.js
@@ -0,0 +1,50 @@
+/* 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/.
+ */
+
+// Disables security checking our updates which haven't been signed
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+var ADDON = {
+  id: "datadirectory1@tests.mozilla.org",
+  addon: "test_data_directory"
+};
+
+var expectedDir = gProfD.clone();
+expectedDir.append("extension-data");
+expectedDir.append(ADDON.id);
+
+function run_test() {
+    do_test_pending();
+    do_check_false(expectedDir.exists());
+
+    createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2", "1.9");
+    startupManager();
+
+    installAllFiles([do_get_addon(ADDON.addon)], function() {
+        restartManager();
+
+        AddonManager.getAddonByID(ADDON.id, function(item) {
+            item.getDataDirectory(promise_callback);
+        });
+    });
+}
+
+function promise_callback() {
+    do_check_eq(arguments.length, 2);
+    var expectedDir = gProfD.clone();
+    expectedDir.append("extension-data");
+    expectedDir.append(ADDON.id);
+
+    do_check_eq(arguments[0], expectedDir.path);
+    do_check_true(expectedDir.exists());
+    do_check_true(expectedDir.isDirectory());
+
+    do_check_eq(arguments[1], null);
+
+    // Cleanup.
+    expectedDir.parent.remove(true);
+
+    do_test_finished();
+}
--- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell-shared.ini
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell-shared.ini
@@ -146,16 +146,17 @@ fail-if = os == "android"
 [test_checkcompatibility.js]
 [test_checkCompatibility_themeOverride.js]
 [test_childprocess.js]
 [test_ChromeManifestParser.js]
 [test_compatoverrides.js]
 [test_corrupt.js]
 [test_corrupt_strictcompat.js]
 [test_corruptfile.js]
+[test_dataDirectory.js]
 [test_default_providers_pref.js]
 [test_dictionary.js]
 [test_langpack.js]
 [test_disable.js]
 [test_distribution.js]
 [test_dss.js]
 # Bug 676992: test consistently fails on Android
 fail-if = os == "android"