toolkit/webapps/WebappsInstaller.jsm
author B2G Bumper Bot <release+b2gbumper@mozilla.com>
Fri, 31 Jan 2014 14:05:12 -0800
changeset 182343 4c86e7c1f546598713e5e1565b033562b4ed2be8
parent 182029 41ab0350201722beade55b059080d2ad62a66190
child 183594 336f75532f5f6956418941c283590edf9ab1a96d
child 190700 e220d12f5d8d472c72622cf84ca45a26772d0599
permissions -rw-r--r--
Bumping gaia.json for 4 gaia revision(s) a=gaia-bump ======== https://hg.mozilla.org/integration/gaia-central/rev/4188cf82dc3f Author: Miller Medeiros <miller@millermedeiros.com> Desc: Merge pull request #15630 from millermedeiros/803238-two-months-week-view Bug 803238 - Week view: If the text to display by default in the title (e.g. dow, month, year) exceeds the view length, use short names for day of week, month, year, etc r=gaye ======== https://hg.mozilla.org/integration/gaia-central/rev/41feabe49307 Author: Miller Medeiros <miller@millermedeiros.com> Desc: Bug 803238 - Week view: If the text to display by default in the title (e.g. dow, month, year) exceeds the view length, use short names for day of week, month, year, etc ======== https://hg.mozilla.org/integration/gaia-central/rev/c5b6fcb663c7 Author: Miller Medeiros <miller@millermedeiros.com> Desc: Merge pull request #15384 from millermedeiros/916411-long-title Bug 916411 - [fugu][Buri][Calendar]The event display abnormally when title/where/notes is long r=gaye ======== https://hg.mozilla.org/integration/gaia-central/rev/a1ecc59659e1 Author: millermedeiros <miller@millermedeiros.com> Desc: Bug 916411 - [fugu][Buri][Calendar]The event display abnormally when title/where/notes is long

/* 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/. */

this.EXPORTED_SYMBOLS = ["WebappsInstaller"];

const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
const Cr = Components.results;

Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/FileUtils.jsm");
Cu.import("resource://gre/modules/NetUtil.jsm");
Cu.import("resource://gre/modules/osfile.jsm");
Cu.import("resource://gre/modules/WebappOSUtils.jsm");
Cu.import("resource://gre/modules/AppsUtils.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/Promise.jsm");

// 0755
const PERMS_DIRECTORY = OS.Constants.libc.S_IRWXU |
                        OS.Constants.libc.S_IRGRP | OS.Constants.libc.S_IXGRP |
                        OS.Constants.libc.S_IROTH | OS.Constants.libc.S_IXOTH;

// 0644
const PERMS_FILE = OS.Constants.libc.S_IRUSR | OS.Constants.libc.S_IWUSR |
                   OS.Constants.libc.S_IRGRP |
                   OS.Constants.libc.S_IROTH;

const DESKTOP_DIR = OS.Constants.Path.desktopDir;
const HOME_DIR = OS.Constants.Path.homeDir;
const TMP_DIR = OS.Constants.Path.tmpDir;

this.WebappsInstaller = {
  shell: null,

  /**
   * Initializes the app object that takes care of the installation
   * and creates the profile directory for an application
   *
   * @param aData the data provided to the install function
   *
   * @returns NativeApp on success, null on error
   */
  init: function(aData) {
#ifdef XP_WIN
    this.shell = new WinNativeApp(aData);
#elifdef XP_MACOSX
    this.shell = new MacNativeApp(aData);
#elifdef XP_UNIX
    this.shell = new LinuxNativeApp(aData);
#else
    return null;
#endif

    try {
      if (Services.prefs.getBoolPref("browser.mozApps.installer.dry_run")) {
        return this.shell;
      }
    } catch (ex) {}

    try {
      this.shell.createAppProfile();
    } catch (ex) {
      Cu.reportError("Error installing app: " + ex);
      return null;
    }

    return this.shell;
  },

  /**
   * Creates a native installation of the web app in the OS
   *
   * @param aData the data provided to the install function
   * @param aManifest the manifest data provided by the web app
   * @param aZipPath path to the zip file for packaged apps (undefined for
   *                 hosted apps)
   */
  install: function(aData, aManifest, aZipPath) {
    try {
      if (Services.prefs.getBoolPref("browser.mozApps.installer.dry_run")) {
        return Promise.resolve();
      }
    } catch (ex) {}

    this.shell.init(aData, aManifest);

    return this.shell.install(aZipPath).then(() => {
      let data = {
        "installDir": this.shell.installDir,
        "app": {
          "manifest": aManifest,
          "origin": aData.app.origin
        }
      };

      Services.obs.notifyObservers(null, "webapp-installed", JSON.stringify(data));
    });
  }
}

/**
 * This function implements the common constructor for
 * the Windows, Mac and Linux native app shells. It sets
 * the app unique name. It's meant to be called as
 * NativeApp.call(this, aData) from the platform-specific
 * constructor.
 *
 * @param aData the data object provided to the install function
 *
 */
function NativeApp(aData) {
  let jsonManifest = aData.isPackage ? aData.app.updateManifest : aData.app.manifest;
  let manifest = new ManifestHelper(jsonManifest, aData.app.origin);

  aData.app.name = manifest.name;
  this.uniqueName = WebappOSUtils.getUniqueName(aData.app);

  this.appName = sanitize(manifest.name);
  this.appNameAsFilename = stripStringForFilename(this.appName);
}

NativeApp.prototype = {
  uniqueName: null,
  appName: null,
  appNameAsFilename: null,
  iconURI: null,
  developerName: null,
  shortDescription: null,
  categories: null,
  webappJson: null,
  runtimeFolder: null,
  manifest: null,

  /**
   * This function reads and parses the data from the app
   * manifest and stores it in the NativeApp object.
   *
   * @param aData the data object provided to the install function
   * @param aManifest the manifest data provided by the web app
   *
   */
  init: function(aData, aManifest) {
    let app = this.app = aData.app;
    let manifest = this.manifest = new ManifestHelper(aManifest,
                                                      app.origin);

    let origin = Services.io.newURI(app.origin, null, null);

    let biggestIcon = getBiggestIconURL(manifest.icons);
    try {
      let iconURI = Services.io.newURI(biggestIcon, null, null);
      if (iconURI.scheme == "data") {
        this.iconURI = iconURI;
      }
    } catch (ex) {}

    if (!this.iconURI) {
      try {
        this.iconURI = Services.io.newURI(origin.resolve(biggestIcon), null, null);
      }
      catch (ex) {}
    }

    if (manifest.developer) {
      if (manifest.developer.name) {
        let devName = sanitize(manifest.developer.name.substr(0, 128));
        if (devName) {
          this.developerName = devName;
        }
      }

      if (manifest.developer.url) {
        this.developerUrl = manifest.developer.url;
      }
    }

    if (manifest.description) {
      let firstLine = manifest.description.split("\n")[0];
      let shortDesc = firstLine.length <= 256
                      ? firstLine
                      : firstLine.substr(0, 253) + "…";
      this.shortDescription = sanitize(shortDesc);
    } else {
      this.shortDescription = this.appName;
    }

    this.categories = app.categories.slice(0);

    this.webappJson = {
      // The app registry is the Firefox profile from which the app
      // was installed.
      "registryDir": OS.Constants.Path.profileDir,
      "app": {
        "manifest": aManifest,
        "origin": app.origin,
        "manifestURL": app.manifestURL,
        "installOrigin": app.installOrigin,
        "categories": app.categories,
        "receipts": app.receipts,
        "installTime": app.installTime,
      }
    };

    if (app.etag) {
      this.webappJson.app.etag = app.etag;
    }

    if (app.packageEtag) {
      this.webappJson.app.packageEtag = app.packageEtag;
    }

    if (app.updateManifest) {
      this.webappJson.app.updateManifest = app.updateManifest;
    }

    this.runtimeFolder = OS.Constants.Path.libDir;
  },

  /**
   * This function retrieves the icon for an app.
   * If the retrieving fails, it uses the default chrome icon.
   */
  getIcon: function() {
    try {
      // If the icon is in the zip package, we should modify the url
      // to point to the zip file (we can't use the app protocol yet
      // because the app isn't installed yet).
      if (this.iconURI.scheme == "app") {
        let zipUrl = OS.Path.toFileURI(OS.Path.join(this.tmpInstallDir,
                                                    "application.zip"));

        let filePath = this.iconURI.QueryInterface(Ci.nsIURL).filePath;

        this.iconURI = Services.io.newURI("jar:" + zipUrl + "!" + filePath,
                                          null, null);
      }


      let [ mimeType, icon ] = yield downloadIcon(this.iconURI);
      yield this.processIcon(mimeType, icon);
    }
    catch(e) {
      Cu.reportError("Failure retrieving icon: " + e);

      let iconURI = Services.io.newURI(DEFAULT_ICON_URL, null, null);

      let [ mimeType, icon ] = yield downloadIcon(iconURI);
      yield this.processIcon(mimeType, icon);

      // Set the iconURI property so that the user notification will have the
      // correct icon.
      this.iconURI = iconURI;
    }
  },

  /**
   * Creates the profile to be used for this app.
   */
  createAppProfile: function() {
    let profSvc = Cc["@mozilla.org/toolkit/profile-service;1"]
                    .getService(Ci.nsIToolkitProfileService);

    try {
      this.appProfile = profSvc.createDefaultProfileForApp(this.uniqueName,
                                                           null, null);
    } catch (ex if ex.result == Cr.NS_ERROR_ALREADY_INITIALIZED) {}
  },
};

#ifdef XP_WIN

const PROGS_DIR = OS.Constants.Path.winStartMenuProgsDir;
const APP_DATA_DIR = OS.Constants.Path.winAppDataDir;

/*************************************
 * Windows app installer
 *
 * The Windows installation process will generate the following files:
 *
 * ${FolderName} = sanitized app name + "-" + manifest url hash
 *
 * %APPDATA%/${FolderName}
 *   - webapp.ini
 *   - webapp.json
 *   - ${AppName}.exe
 *   - ${AppName}.lnk
 *   / uninstall
 *     - webapp-uninstaller.exe
 *     - shortcuts_log.ini
 *     - uninstall.log
 *   / chrome/icons/default/
 *     - default.ico
 *
 * After the app runs for the first time, a profiles/ folder will also be
 * created which will host the user profile for this app.
 */

/**
 * Constructor for the Windows native app shell
 *
 * @param aData the data object provided to the install function
 */
function WinNativeApp(aData) {
  NativeApp.call(this, aData);

  if (aData.isPackage) {
    this.size = aData.app.updateManifest.size / 1024;
    this.isPackaged = true;
  }

  let filenameRE = new RegExp("[<>:\"/\\\\|\\?\\*]", "gi");

  this.appNameAsFilename = this.appNameAsFilename.replace(filenameRE, "");
  if (this.appNameAsFilename == "") {
    this.appNameAsFilename = "webapp";
  }

  this.webapprt = this.appNameAsFilename + ".exe";
  this.configJson = "webapp.json";
  this.webappINI = "webapp.ini";
  this.iconPath = OS.Path.join("chrome", "icons", "default", "default.ico");
  this.uninstallDir = "uninstall";
  this.uninstallerFile = OS.Path.join(this.uninstallDir,
                                      "webapp-uninstaller.exe");
  this.shortcutLogsINI = OS.Path.join(this.uninstallDir, "shortcuts_log.ini");

  this.uninstallSubkeyStr = this.uniqueName;
}

WinNativeApp.prototype = {
  __proto__: NativeApp.prototype,
  size: null,

  /**
   * Install the app in the system
   *
   */
  install: function(aZipPath) {
    return Task.spawn(function() {
      yield this._getInstallDir();

      try {
        yield this._createDirectoryStructure();
        yield this._copyPrebuiltFiles();
        this._createConfigFiles();

        if (aZipPath) {
          yield OS.File.move(aZipPath, OS.Path.join(this.tmpInstallDir,
                                                    "application.zip"));
        }

        yield this.getIcon();

        // Remove previously installed app
        this._removeInstallation(true);
      } catch (ex) {
        removeFiles([this.tmpInstallDir]);
        throw(ex);
      }

      try {
        // On Windows, the webapprt executable can't be overwritten while it's
        // running.
        // As it takes care of updating itself, there's no need to update
        // it here.
        let filesToIgnore = [ this.webapprt ];
        yield moveDirectory(this.tmpInstallDir, this.installDir, filesToIgnore);

        this._createShortcutFiles();
        this._writeSystemKeys();
      } catch (ex) {
        this._removeInstallation(false);
        throw(ex);
      }
    }.bind(this));
  },

  _getInstallDir: function() {
    // The ${InstallDir} is: sanitized app name + "-" + manifest url hash
    this.installDir = WebappOSUtils.getInstallPath(this.app);
    if (this.installDir) {
      if (this.uniqueName != OS.Path.basename(this.installDir)) {
        // Bug 919799: If the app is still in the registry, migrate its data to
        // the new format.
        throw("Updates for apps installed with the old naming scheme unsupported");
      }

      let shortcutLogsINIfile = getFile(this.installDir, this.shortcutLogsINI);
      // If it's a reinstallation (or an update) get the shortcut names
      // from the shortcut_log.ini file
      let parser = Cc["@mozilla.org/xpcom/ini-processor-factory;1"]
                     .getService(Ci.nsIINIParserFactory)
                     .createINIParser(shortcutLogsINIfile);
      this.shortcutName = parser.getString("STARTMENU", "Shortcut0");
    } else {
      this.installDir = OS.Path.join(APP_DATA_DIR, this.uniqueName);

      // Check in both directories to see if a shortcut with the same name
      // already exists.
      this.shortcutName = yield getAvailableFileName([ PROGS_DIR, DESKTOP_DIR ],
                                                     this.appNameAsFilename,
                                                     ".lnk");
    }
  },

  /**
   * Remove the current installation
   */
  _removeInstallation: function(keepProfile) {
    let uninstallKey;
    try {
      uninstallKey = Cc["@mozilla.org/windows-registry-key;1"]
                     .createInstance(Ci.nsIWindowsRegKey);
      uninstallKey.open(uninstallKey.ROOT_KEY_CURRENT_USER,
                        "SOFTWARE\\Microsoft\\Windows\\" +
                        "CurrentVersion\\Uninstall",
                        uninstallKey.ACCESS_WRITE);
      if(uninstallKey.hasChild(this.uninstallSubkeyStr)) {
        uninstallKey.removeChild(this.uninstallSubkeyStr);
      }
    } catch (e) {
    } finally {
      if(uninstallKey)
        uninstallKey.close();
    }

    let filesToRemove = [ OS.Path.join(DESKTOP_DIR, this.shortcutName),
                          OS.Path.join(PROGS_DIR, this.shortcutName) ];

    if (keepProfile) {
      [ this.iconPath, this.webapprt, this.configJson,
        this.webappINI, this.uninstallDir ].forEach((filePath) => {
        filesToRemove.push(OS.Path.join(this.installDir, filePath));
      });
    } else {
      filesToRemove.push(this.installDir);
      filesToRemove.push(this.tmpInstallDir);
    }

    removeFiles(filesToRemove);
  },

  /**
   * Creates the main directory structure.
   */
  _createDirectoryStructure: function() {
    let dir = getFile(TMP_DIR, this.uniqueName);
    dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, PERMS_DIRECTORY);
    this.tmpInstallDir = dir.path;

    yield OS.File.makeDir(OS.Path.join(this.tmpInstallDir, this.uninstallDir),
                          { ignoreExisting: true });

    // Recursively create the icon path's directory structure.
    let path = this.tmpInstallDir;
    let components = OS.Path.split(OS.Path.dirname(this.iconPath)).components;
    for (let component of components) {
      path = OS.Path.join(path, component);
      yield OS.File.makeDir(path, { ignoreExisting: true });
    }
  },

  /**
   * Copy the pre-built files into their destination folders.
   */
  _copyPrebuiltFiles: function() {
    yield OS.File.copy(OS.Path.join(this.runtimeFolder, "webapprt-stub.exe"),
                       OS.Path.join(this.tmpInstallDir, this.webapprt));

    yield OS.File.copy(OS.Path.join(this.runtimeFolder, "webapp-uninstaller.exe"),
                       OS.Path.join(this.tmpInstallDir, this.uninstallerFile));
  },

  /**
   * Creates the configuration files into their destination folders.
   */
  _createConfigFiles: function() {
    // ${InstallDir}/webapp.json
    writeToFile(OS.Path.join(this.tmpInstallDir, this.configJson),
                JSON.stringify(this.webappJson));

    let factory = Cc["@mozilla.org/xpcom/ini-processor-factory;1"]
                    .getService(Ci.nsIINIParserFactory);

    // ${InstallDir}/webapp.ini
    let webappINIfile = getFile(this.tmpInstallDir, this.webappINI);

    let writer = factory.createINIParser(webappINIfile)
                        .QueryInterface(Ci.nsIINIParserWriter);
    writer.setString("Webapp", "Name", this.appName);
    writer.setString("Webapp", "Profile", OS.Path.basename(this.installDir));
    writer.setString("Webapp", "Executable", this.appNameAsFilename);
    writer.setString("WebappRT", "InstallDir", this.runtimeFolder);
    writer.writeFile(null, Ci.nsIINIParserWriter.WRITE_UTF16);

    let shortcutLogsINIfile = getFile(this.tmpInstallDir, this.shortcutLogsINI);

    writer = factory.createINIParser(shortcutLogsINIfile)
                    .QueryInterface(Ci.nsIINIParserWriter);
    writer.setString("STARTMENU", "Shortcut0", this.shortcutName);
    writer.setString("DESKTOP", "Shortcut0", this.shortcutName);
    writer.setString("TASKBAR", "Migrated", "true");
    writer.writeFile(null, Ci.nsIINIParserWriter.WRITE_UTF16);

    // ${UninstallDir}/uninstall.log
    let uninstallContent = 
      "File: \\webapp.ini\r\n" +
      "File: \\webapp.json\r\n" +
      "File: \\webapprt.old\r\n" +
      "File: \\chrome\\icons\\default\\default.ico";
    if (this.isPackaged) {
      uninstallContent += "\r\nFile: \\application.zip";
    }

    writeToFile(OS.Path.join(this.tmpInstallDir, this.uninstallDir,
                             "uninstall.log"),
                uninstallContent);
  },

  /**
   * Writes the keys to the system registry that are necessary for the app operation
   * and uninstall process.
   */
  _writeSystemKeys: function() {
    let parentKey;
    let uninstallKey;
    let subKey;

    try {
      parentKey = Cc["@mozilla.org/windows-registry-key;1"]
                  .createInstance(Ci.nsIWindowsRegKey);
      parentKey.open(parentKey.ROOT_KEY_CURRENT_USER,
                     "SOFTWARE\\Microsoft\\Windows\\CurrentVersion",
                     parentKey.ACCESS_WRITE);
      uninstallKey = parentKey.createChild("Uninstall", parentKey.ACCESS_WRITE)
      subKey = uninstallKey.createChild(this.uninstallSubkeyStr, uninstallKey.ACCESS_WRITE);

      subKey.writeStringValue("DisplayName", this.appName);

      let uninstallerPath = OS.Path.join(this.installDir,
                                         this.uninstallerFile);

      subKey.writeStringValue("UninstallString", '"' + uninstallerPath + '"');
      subKey.writeStringValue("InstallLocation", '"' + this.installDir + '"');
      subKey.writeStringValue("AppFilename", this.appNameAsFilename);
      subKey.writeStringValue("DisplayIcon", OS.Path.join(this.installDir,
                                                          this.iconPath));

      let date = new Date();
      let year = date.getYear().toString();
      let month = date.getMonth();
      if (month < 10) {
        month = "0" + month;
      }
      let day = date.getDate();
      if (day < 10) {
        day = "0" + day;
      }
      subKey.writeStringValue("InstallDate", year + month + day);
      if (this.manifest.version) {
        subKey.writeStringValue("DisplayVersion", this.manifest.version);
      }
      if (this.developerName) {
        subKey.writeStringValue("Publisher", this.developerName);
      }
      subKey.writeStringValue("URLInfoAbout", this.developerUrl);
      if (this.size) {
        subKey.writeIntValue("EstimatedSize", this.size);
      }

      subKey.writeIntValue("NoModify", 1);
      subKey.writeIntValue("NoRepair", 1);
    } catch(ex) {
      throw(ex);
    } finally {
      if(subKey) subKey.close();
      if(uninstallKey) uninstallKey.close();
      if(parentKey) parentKey.close();
    }
  },

  /**
   * Creates a shortcut file inside the app installation folder and makes
   * two copies of it: one into the desktop and one into the start menu.
   */
  _createShortcutFiles: function() {
    let shortcut = getFile(this.installDir, this.shortcutName).
                      QueryInterface(Ci.nsILocalFileWin);

    /* function nsILocalFileWin.setShortcut(targetFile, workingDir, args,
                                            description, iconFile, iconIndex) */

    shortcut.setShortcut(getFile(this.installDir, this.webapprt),
                         getFile(this.installDir),
                         null,
                         this.shortDescription,
                         getFile(this.installDir, this.iconPath),
                         0);

    shortcut.copyTo(getFile(DESKTOP_DIR), this.shortcutName);
    shortcut.copyTo(getFile(PROGS_DIR), this.shortcutName);

    shortcut.followLinks = false;
    shortcut.remove(false);
  },

  /**
   * Process the icon from the imageStream as retrieved from
   * the URL by getIconForApp(). This will save the icon to the
   * topwindow.ico file.
   *
   * @param aMimeType     ahe icon mimetype
   * @param aImageStream  the stream for the image data
   */
  processIcon: function(aMimeType, aImageStream) {
    let deferred = Promise.defer();

    let imgTools = Cc["@mozilla.org/image/tools;1"]
                     .createInstance(Ci.imgITools);

    let imgContainer = imgTools.decodeImage(aImageStream, aMimeType);
    let iconStream = imgTools.encodeImage(imgContainer,
                                          "image/vnd.microsoft.icon",
                                          "format=bmp;bpp=32");

    let tmpIconFile = getFile(this.tmpInstallDir, this.iconPath);

    let outputStream = FileUtils.openSafeFileOutputStream(tmpIconFile);
    NetUtil.asyncCopy(iconStream, outputStream, function(aResult) {
      if (Components.isSuccessCode(aResult)) {
        deferred.resolve();
      } else {
        deferred.reject("Failure copying icon: " + aResult);
      }
    });

    return deferred.promise;
  }
}

#elifdef XP_MACOSX

const USER_LIB_DIR = OS.Constants.Path.macUserLibDir;
const LOCAL_APP_DIR = OS.Constants.Path.macLocalApplicationsDir;

function MacNativeApp(aData) {
  NativeApp.call(this, aData);

  let filenameRE = new RegExp("[<>:\"/\\\\|\\?\\*]", "gi");
  this.appNameAsFilename = this.appNameAsFilename.replace(filenameRE, "");
  if (this.appNameAsFilename == "") {
    this.appNameAsFilename = "Webapp";
  }

  // The ${ProfileDir} is: sanitized app name + "-" + manifest url hash
  this.appProfileDir = OS.Path.join(USER_LIB_DIR, "Application Support",
                                    this.uniqueName);

  this.contentsDir = "Contents";
  this.macOSDir = OS.Path.join(this.contentsDir, "MacOS");
  this.resourcesDir = OS.Path.join(this.contentsDir, "Resources");
  this.iconFile = OS.Path.join(this.resourcesDir, "appicon.icns");
}

MacNativeApp.prototype = {
  __proto__: NativeApp.prototype,

  install: function(aZipPath) {
    return Task.spawn(function() {
      yield this._getInstallDir();

      try {
        yield this._createDirectoryStructure();
        this._copyPrebuiltFiles();
        this._createConfigFiles();

        if (aZipPath) {
          yield OS.File.move(aZipPath, OS.Path.join(this.tmpInstallDir,
                                                    "application.zip"));
        }

        yield this.getIcon();

        // Remove previously installed app
        this._removeInstallation(true);
      } catch (ex) {
        removeFiles([this.tmpInstallDir]);
        throw(ex);
      }

      try {
        // Move the temp installation directory to the /Applications directory
        yield moveDirectory(this.tmpInstallDir, this.installDir, []);
      } catch (ex) {
        this._removeInstallation(false);
        throw(ex);
      }
    }.bind(this));
  },

  _getInstallDir: function() {
    let [ oldUniqueName, installPath ] = WebappOSUtils.getLaunchTarget(this.app);
    if (installPath) {
      this.installDir = installPath;

      if (this.uniqueName != oldUniqueName) {
        // Bug 919799: If the app is still in the registry, migrate its data to
        // the new format.
        throw("Updates for apps installed with the old naming scheme unsupported");
      }
    } else {
      let localAppDir = getFile(LOCAL_APP_DIR);
      if (!localAppDir.isWritable()) {
        throw("Not enough privileges to install apps");
      }

      let destinationName = yield getAvailableFileName([ LOCAL_APP_DIR ],
                                                       this.appNameAsFilename,
                                                       ".app");

      this.installDir = OS.Path.join(LOCAL_APP_DIR, destinationName);
    }
  },

  _removeInstallation: function(keepProfile) {
    let filesToRemove = [this.installDir];

    if (!keepProfile) {
      filesToRemove.push(this.appProfileDir);
    }

    removeFiles(filesToRemove);
  },

  _createDirectoryStructure: function() {
    let dir = getFile(TMP_DIR, this.appNameAsFilename + ".app");
    dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, PERMS_DIRECTORY);
    this.tmpInstallDir = dir.path;

    yield OS.File.makeDir(this.appProfileDir,
                          { unixMode: PERMS_DIRECTORY, ignoreExisting: true });

    yield OS.File.makeDir(OS.Path.join(this.tmpInstallDir, this.contentsDir),
                          { unixMode: PERMS_DIRECTORY, ignoreExisting: true });

    yield OS.File.makeDir(OS.Path.join(this.tmpInstallDir, this.macOSDir),
                          { unixMode: PERMS_DIRECTORY, ignoreExisting: true });

    yield OS.File.makeDir(OS.Path.join(this.tmpInstallDir, this.resourcesDir),
                          { unixMode: PERMS_DIRECTORY, ignoreExisting: true });
  },

  _copyPrebuiltFiles: function() {
    let destDir = getFile(this.tmpInstallDir, this.macOSDir);
    let stub = getFile(this.runtimeFolder, "webapprt-stub");
    stub.copyTo(destDir, "webapprt");
  },

  _createConfigFiles: function() {
    // ${ProfileDir}/webapp.json
    writeToFile(OS.Path.join(this.appProfileDir, "webapp.json"),
                JSON.stringify(this.webappJson));

    // ${InstallDir}/Contents/MacOS/webapp.ini
    let applicationINI = getFile(this.tmpInstallDir, this.macOSDir, "webapp.ini");

    let writer = Cc["@mozilla.org/xpcom/ini-processor-factory;1"]
                   .getService(Ci.nsIINIParserFactory)
                   .createINIParser(applicationINI)
                   .QueryInterface(Ci.nsIINIParserWriter);
    writer.setString("Webapp", "Name", this.appName);
    writer.setString("Webapp", "Profile", OS.Path.basename(this.appProfileDir));
    writer.writeFile();
    applicationINI.permissions = PERMS_FILE;

    // ${InstallDir}/Contents/Info.plist
    let infoPListContent = '<?xml version="1.0" encoding="UTF-8"?>\n\
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n\
<plist version="1.0">\n\
  <dict>\n\
    <key>CFBundleDevelopmentRegion</key>\n\
    <string>English</string>\n\
    <key>CFBundleDisplayName</key>\n\
    <string>' + escapeXML(this.appName) + '</string>\n\
    <key>CFBundleExecutable</key>\n\
    <string>webapprt</string>\n\
    <key>CFBundleIconFile</key>\n\
    <string>appicon</string>\n\
    <key>CFBundleIdentifier</key>\n\
    <string>' + escapeXML(this.uniqueName) + '</string>\n\
    <key>CFBundleInfoDictionaryVersion</key>\n\
    <string>6.0</string>\n\
    <key>CFBundleName</key>\n\
    <string>' + escapeXML(this.appName) + '</string>\n\
    <key>CFBundlePackageType</key>\n\
    <string>APPL</string>\n\
    <key>CFBundleVersion</key>\n\
    <string>0</string>\n\
    <key>NSHighResolutionCapable</key>\n\
    <true/>\n\
    <key>NSPrincipalClass</key>\n\
    <string>GeckoNSApplication</string>\n\
    <key>FirefoxBinary</key>\n\
#expand     <string>__MOZ_MACBUNDLE_ID__</string>\n\
  </dict>\n\
</plist>';

    writeToFile(OS.Path.join(this.tmpInstallDir, this.contentsDir, "Info.plist"),
                infoPListContent);
  },

  /**
   * Process the icon from the imageStream as retrieved from
   * the URL by getIconForApp(). This will bundle the icon to the
   * app package at Contents/Resources/appicon.icns.
   *
   * @param aMimeType     the icon mimetype
   * @param aImageStream  the stream for the image data
   */
  processIcon: function(aMimeType, aIcon) {
    let deferred = Promise.defer();

    function conversionDone(aSubject, aTopic) {
      if (aTopic == "process-finished") {
        deferred.resolve();
      } else {
        deferred.reject("Failure converting icon.");
      }
    }

    let process = Cc["@mozilla.org/process/util;1"].
                  createInstance(Ci.nsIProcess);
    let sipsFile = getFile("/usr/bin/sips");

    process.init(sipsFile);
    process.runAsync(["-s",
                "format", "icns",
                aIcon.path,
                "--out", OS.Path.join(this.tmpInstallDir, this.iconFile),
                "-z", "128", "128"],
                9, conversionDone);

    return deferred.promise;
  }

}

#elifdef XP_UNIX

function LinuxNativeApp(aData) {
  NativeApp.call(this, aData);

  this.iconFile = "icon.png";
  this.webapprt = "webapprt-stub";
  this.configJson = "webapp.json";
  this.webappINI = "webapp.ini";

  let xdg_data_home = Cc["@mozilla.org/process/environment;1"]
                        .getService(Ci.nsIEnvironment)
                        .get("XDG_DATA_HOME");
  if (!xdg_data_home) {
    xdg_data_home = OS.Path.join(HOME_DIR, ".local", "share");
  }

  this.desktopINI = OS.Path.join(xdg_data_home, "applications",
                                 "owa-" + this.uniqueName + ".desktop");
}

LinuxNativeApp.prototype = {
  __proto__: NativeApp.prototype,

  install: function(aZipPath) {
    return Task.spawn(function() {
      this._getInstallDir();

      try {
        this._createDirectoryStructure();
        this._copyPrebuiltFiles();
        this._createConfigFiles();

        if (aZipPath) {
          yield OS.File.move(aZipPath, OS.Path.join(this.tmpInstallDir,
                                                    "application.zip"));
        }

        yield this.getIcon();

        // Remove previously installed app
        this._removeInstallation(true);
      } catch (ex) {
        removeFiles([this.tmpInstallDir]);
        throw(ex);
      }

      try {
        yield moveDirectory(this.tmpInstallDir, this.installDir, []);

        this._createSystemFiles();
      } catch (ex) {
        this._removeInstallation(false);
        throw(ex);
      }
    }.bind(this));
  },

  _getInstallDir: function() {
    // The ${InstallDir} and desktop entry filename are: sanitized app name +
    // "-" + manifest url hash
    this.installDir = WebappOSUtils.getInstallPath(this.app);
    if (this.installDir) {
      let baseName = OS.Path.basename(this.installDir)
      let oldUniqueName = baseName.substring(1, baseName.length);
      if (this.uniqueName != oldUniqueName) {
        // Bug 919799: If the app is still in the registry, migrate its data to
        // the new format.
        throw("Updates for apps installed with the old naming scheme unsupported");
      }
    } else {
      this.installDir = OS.Path.join(HOME_DIR, "." + this.uniqueName);
    }
  },

  _removeInstallation: function(keepProfile) {
    let filesToRemove = [this.desktopINI];

    if (keepProfile) {
      [ this.iconFile, this.webapprt,
        this.configJson, this.webappINI ].forEach((filePath) => {
        filesToRemove.push(OS.Path.join(this.installDir, filePath));
      });
    } else {
      filesToRemove.push(this.installDir);
      filesToRemove.push(this.tmpInstallDir);
    }

    removeFiles(filesToRemove);
  },

  _createDirectoryStructure: function() {
    let dir = getFile(TMP_DIR, this.uniqueName);
    dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, PERMS_DIRECTORY);
    this.tmpInstallDir = dir.path;
  },

  _copyPrebuiltFiles: function() {
    let destDir = getFile(this.tmpInstallDir);
    let stub = getFile(this.runtimeFolder, this.webapprt);

    stub.copyTo(destDir, null);
  },

  /**
   * Translate marketplace categories to freedesktop.org categories.
   *
   * @link http://standards.freedesktop.org/menu-spec/menu-spec-latest.html#category-registry
   *
   * @return an array of categories
   */
  _translateCategories: function() {
    let translations = {
      "books": "Education;Literature",
      "business": "Finance",
      "education": "Education",
      "entertainment": "Amusement",
      "sports": "Sports",
      "games": "Game",
      "health-fitness": "MedicalSoftware",
      "lifestyle": "Amusement",
      "music": "Audio;Music",
      "news-weather": "News",
      "photo-video": "Video;AudioVideo;Photography",
      "productivity": "Office",
      "shopping": "Amusement",
      "social": "Chat",
      "travel": "Amusement",
      "reference": "Science;Education;Documentation",
      "maps-navigation": "Maps",
      "utilities": "Utility"
    };

    // The trailing semicolon is needed as written in the freedesktop specification
    let categories = "";
    for (let category of this.categories) {
      let catLower = category.toLowerCase();
      if (catLower in translations) {
        categories += translations[catLower] + ";";
      }
    }

    return categories;
  },

  _createConfigFiles: function() {
    // ${InstallDir}/webapp.json
    writeToFile(OS.Path.join(this.tmpInstallDir, this.configJson),
                JSON.stringify(this.webappJson));

    let webappsBundle = Services.strings.createBundle("chrome://global/locale/webapps.properties");

    // ${InstallDir}/webapp.ini
    let webappINIfile = getFile(this.tmpInstallDir, this.webappINI);

    let writer = Cc["@mozilla.org/xpcom/ini-processor-factory;1"]
                   .getService(Ci.nsIINIParserFactory)
                   .createINIParser(webappINIfile)
                   .QueryInterface(Ci.nsIINIParserWriter);
    writer.setString("Webapp", "Name", this.appName);
    writer.setString("Webapp", "Profile", this.uniqueName);
    writer.setString("Webapp", "UninstallMsg", webappsBundle.formatStringFromName("uninstall.notification", [this.appName], 1));
    writer.setString("WebappRT", "InstallDir", this.runtimeFolder);
    writer.writeFile();
  },

  _createSystemFiles: function() {
    let webappsBundle = Services.strings.createBundle("chrome://global/locale/webapps.properties");

    let webapprtPath = OS.Path.join(this.installDir, this.webapprt);

    // $XDG_DATA_HOME/applications/owa-<webappuniquename>.desktop
    let desktopINIfile = getFile(this.desktopINI);

    let writer = Cc["@mozilla.org/xpcom/ini-processor-factory;1"]
                   .getService(Ci.nsIINIParserFactory)
                   .createINIParser(desktopINIfile)
                   .QueryInterface(Ci.nsIINIParserWriter);
    writer.setString("Desktop Entry", "Name", this.appName);
    writer.setString("Desktop Entry", "Comment", this.shortDescription);
    writer.setString("Desktop Entry", "Exec", '"' + webapprtPath + '"');
    writer.setString("Desktop Entry", "Icon", OS.Path.join(this.installDir,
                                                           this.iconFile));
    writer.setString("Desktop Entry", "Type", "Application");
    writer.setString("Desktop Entry", "Terminal", "false");

    let categories = this._translateCategories();
    if (categories)
      writer.setString("Desktop Entry", "Categories", categories);

    writer.setString("Desktop Entry", "Actions", "Uninstall;");
    writer.setString("Desktop Action Uninstall", "Name", webappsBundle.GetStringFromName("uninstall.label"));
    writer.setString("Desktop Action Uninstall", "Exec", webapprtPath + " -remove");

    writer.writeFile();

    desktopINIfile.permissions = PERMS_FILE | OS.Constants.libc.S_IXUSR;
  },

  /**
   * Process the icon from the imageStream as retrieved from
   * the URL by getIconForApp().
   *
   * @param aMimeType     ahe icon mimetype
   * @param aImageStream  the stream for the image data
   */
  processIcon: function(aMimeType, aImageStream) {
    let deferred = Promise.defer();

    let imgTools = Cc["@mozilla.org/image/tools;1"]
                     .createInstance(Ci.imgITools);

    let imgContainer = imgTools.decodeImage(aImageStream, aMimeType);
    let iconStream = imgTools.encodeImage(imgContainer, "image/png");

    let iconFile = getFile(this.tmpInstallDir, this.iconFile);
    let outputStream = FileUtils.openSafeFileOutputStream(iconFile);
    NetUtil.asyncCopy(iconStream, outputStream, function(aResult) {
      if (Components.isSuccessCode(aResult)) {
        deferred.resolve();
      } else {
        deferred.reject("Failure copying icon: " + aResult);
      }
    });

    return deferred.promise;
  }
}

#endif

/* Helper Functions */

/**
 * Async write a data string into a file
 *
 * @param aPath     the path to the file to write to
 * @param aData     a string with the data to be written
 */
function writeToFile(aPath, aData) {
  return Task.spawn(function() {
    let data = new TextEncoder().encode(aData);
    let file = yield OS.File.open(aPath, { truncate: true }, { unixMode: PERMS_FILE });
    yield file.write(data);
    yield file.close();
  });
}

/**
 * Removes unprintable characters from a string.
 */
function sanitize(aStr) {
  let unprintableRE = new RegExp("[\\x00-\\x1F\\x7F]" ,"gi");
  return aStr.replace(unprintableRE, "");
}

/**
 * Strips all non-word characters from the beginning and end of a string
 */
function stripStringForFilename(aPossiblyBadFilenameString) {
  //strip everything from the front up to the first [0-9a-zA-Z]

  let stripFrontRE = new RegExp("^\\W*","gi");
  let stripBackRE = new RegExp("\\s*$","gi");

  let stripped = aPossiblyBadFilenameString.replace(stripFrontRE, "");
  stripped = stripped.replace(stripBackRE, "");
  return stripped;
}

/**
 * Finds a unique name available in a folder (i.e., non-existent file)
 *
 * @param aPathSet a set of paths that represents the set of
 * directories where we want to write
 * @param aName   string with the filename (minus the extension) desired
 * @param aExtension string with the file extension, including the dot

 * @return file name or null if folder is unwritable or unique name
 *         was not available
 */
function getAvailableFileName(aPathSet, aName, aExtension) {
  return Task.spawn(function*() {
    let name = aName + aExtension;

    function checkUnique(aName) {
      return Task.spawn(function*() {
        for (let path of aPathSet) {
          if (yield OS.File.exists(OS.Path.join(path, aName))) {
            return false;
          }
        }

        return true;
      });
    }

    if (yield checkUnique(name)) {
      return name;
    }

    // If we're here, the plain name wasn't enough. Let's try modifying the name
    // by adding "(" + num + ")".
    for (let i = 2; i < 100; i++) {
      name = aName + " (" + i + ")" + aExtension;

      if (yield checkUnique(name)) {
        return name;
      }
    }

    throw "No available filename";
  });
}

/**
 * Attempts to remove files or directories.
 *
 * @param aPaths An array with paths to files to remove
 */
function removeFiles(aPaths) {
  for (let path of aPaths) {
    let file = getFile(path);

    try {
      if (file.exists()) {
        file.followLinks = false;
        file.remove(true);
      }
    } catch(ex) {}
  }
}

/**
 * Move (overwriting) the contents of one directory into another.
 *
 * @param srcPath A path to the source directory
 * @param destPath A path to the destination directory
 * @param filesToIgnore An array of files. If one of those files can't be
 *                      overwritten the function will not fail.
 */
function moveDirectory(srcPath, destPath, filesToIgnore) {
  let srcDir = getFile(srcPath);
  let destDir = getFile(destPath);

  let entries = srcDir.directoryEntries;
  let array = [];
  while (entries.hasMoreElements()) {
    let entry = entries.getNext().QueryInterface(Ci.nsIFile);

    if (entry.isDirectory()) {
      yield moveDirectory(entry.path, OS.Path.join(destPath, entry.leafName));
    } else {
      try {
        entry.moveTo(destDir, entry.leafName);
      } catch (ex if ex.result == Cr.NS_ERROR_FILE_ACCESS_DENIED) {
        if (filesToIgnore.indexOf(entry.leafName) != -1) {
          yield OS.File.remove(entry.path);
        } else {
          throw(ex);
        }
      }
    }
  }

  // The source directory is now empty, remove it.
  yield OS.File.removeEmptyDir(srcPath);
}

function escapeXML(aStr) {
  return aStr.toString()
             .replace(/&/g, "&amp;")
             .replace(/"/g, "&quot;")
             .replace(/'/g, "&apos;")
             .replace(/</g, "&lt;")
             .replace(/>/g, "&gt;");
}

// Helper to create a nsIFile from a set of path components
function getFile() {
  let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
  file.initWithPath(OS.Path.join.apply(OS.Path, arguments));
  return file;
}

/* More helpers for handling the app icon */
#include WebappsIconHelpers.js