common/src/ExtensionSupport.jsm
author Jorg K <jorgk@jorgk.com>
Fri, 27 Sep 2019 11:52:07 +0200
changeset 36973 ddede6db83516288c25a8083879bdab02ce1b7b0
parent 36567 ab9b63f9d2266b4607b926ba227d89ae117bb3f9
child 37438 edadee96ce8163c1b6d1917ae8b2a1d12a18e152
permissions -rw-r--r--
Bug 1584461 - Disabled Nightly builds. a=me CLOSED TREE

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

/**
 * Helper functions for use by extensions that should ease them plug
 * into the application.
 */

this.EXPORTED_SYMBOLS = ["ExtensionSupport"];

const { AddonManager } = ChromeUtils.import(
  "resource://gre/modules/AddonManager.jsm"
);
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
// ChromeUtils.import("resource://gre/modules/Deprecated.jsm") - needed for warning.
const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");

var { fixIterator } = ChromeUtils.import(
  "resource:///modules/iteratorUtils.jsm"
);
const { IOUtils } = ChromeUtils.import("resource:///modules/IOUtils.js");

var extensionHooks = new Map();
var legacyExtensions = new Map();
var bootstrapExtensions = new Set();
var openWindowList;

var ExtensionSupport = {
  /**
   * A Map-like object which tracks legacy extension status. The "has" method
   * returns only active extensions for compatibility with existing code.
   */
  loadedLegacyExtensions: {
    set(id, state) {
      legacyExtensions.set(id, state);
    },
    get(id) {
      return legacyExtensions.get(id);
    },
    has(id) {
      if (!legacyExtensions.has(id)) {
        return false;
      }

      let state = legacyExtensions.get(id);
      return !["install", "enable"].includes(state.pendingOperation);
    },
    hasAnyState(id) {
      return legacyExtensions.has(id);
    },
    _maybeDelete(id, newPendingOperation) {
      if (!legacyExtensions.has(id)) {
        return;
      }

      let state = legacyExtensions.get(id);
      if (
        state.pendingOperation == "enable" &&
        newPendingOperation == "disable"
      ) {
        legacyExtensions.delete(id);
        this.notifyObservers(state);
      } else if (
        state.pendingOperation == "install" &&
        newPendingOperation == "uninstall"
      ) {
        legacyExtensions.delete(id);
        this.notifyObservers(state);
      }
    },
    notifyObservers(state) {
      let wrappedState = { wrappedJSObject: state };
      Services.obs.notifyObservers(wrappedState, "legacy-addon-status-changed");
    },
    // AddonListener
    onDisabled(ev) {
      this._maybeDelete(ev.id, "disable");
    },
    onUninstalled(ev) {
      this._maybeDelete(ev.id, "uninstall");
    },
    async listRemoved() {
      let running = [...legacyExtensions.keys()];
      let installed = await AddonManager.getAddonsByIDs(running);
      let runningButNotInstalled = [];
      for (let i = 0; i < running.length; i++) {
        if (installed[i] === null) {
          runningButNotInstalled.push(legacyExtensions.get(running[i]));
        }
      }
      return runningButNotInstalled;
    },
  },

  loadedBootstrapExtensions: bootstrapExtensions,

  loadAddonPrefs(addonFile) {
    function setPref(preferDefault, name, value) {
      let branch = preferDefault
        ? Services.prefs.getDefaultBranch("")
        : Services.prefs.getBranch("");

      if (typeof value == "boolean") {
        branch.setBoolPref(name, value);
      } else if (typeof value == "string") {
        if (value.startsWith("chrome://") && value.endsWith(".properties")) {
          let valueLocal = Cc[
            "@mozilla.org/pref-localizedstring;1"
          ].createInstance(Ci.nsIPrefLocalizedString);
          valueLocal.data = value;
          branch.setComplexValue(name, Ci.nsIPrefLocalizedString, valueLocal);
        } else {
          branch.setStringPref(name, value);
        }
      } else if (typeof value == "number" && Number.isInteger(value)) {
        branch.setIntPref(name, value);
      } else if (typeof value == "number" && Number.isFloat(value)) {
        // Floats are set as char prefs, then retrieved using getFloatPref
        branch.setCharPref(name, value);
      }
    }

    function walkExtensionPrefs(extensionRoot) {
      let prefFile = extensionRoot.clone();
      let foundPrefStrings = [];
      if (!prefFile.exists()) {
        return [];
      }

      if (prefFile.isDirectory()) {
        prefFile.append("defaults");
        prefFile.append("preferences");
        if (!prefFile.exists() || !prefFile.isDirectory()) {
          return [];
        }

        let unsortedFiles = [];
        for (let file of fixIterator(prefFile.directoryEntries, Ci.nsIFile)) {
          if (file.isFile() && file.leafName.toLowerCase().endsWith(".js")) {
            unsortedFiles.push(file);
          }
        }

        for (let file of unsortedFiles.sort((a, b) =>
          a.path < b.path ? 1 : -1
        )) {
          foundPrefStrings.push(IOUtils.loadFileToString(file));
        }
      } else if (prefFile.isFile() && prefFile.leafName.endsWith("xpi")) {
        let zipReader = Cc["@mozilla.org/libjar/zip-reader;1"].createInstance(
          Ci.nsIZipReader
        );
        zipReader.open(prefFile);
        let entries = zipReader.findEntries("defaults/preferences/*.js");
        let unsortedEntries = [];
        while (entries.hasMore()) {
          unsortedEntries.push(entries.getNext());
        }

        for (let entryName of unsortedEntries.sort().reverse()) {
          let stream = zipReader.getInputStream(entryName);
          let entrySize = zipReader.getEntry(entryName).realSize;
          if (entrySize > 0) {
            let content = NetUtil.readInputStreamToString(stream, entrySize, {
              charset: "utf-8",
              replacement: "?",
            });
            foundPrefStrings.push(content);
          }
        }
      }

      return foundPrefStrings;
    }

    let sandbox = new Cu.Sandbox(null);
    sandbox.pref = setPref.bind(undefined, true);
    sandbox.user_pref = setPref.bind(undefined, false);

    let prefDataStrings = walkExtensionPrefs(addonFile);
    for (let prefDataString of prefDataStrings) {
      try {
        Cu.evalInSandbox(prefDataString, sandbox);
      } catch (e) {
        Cu.reportError(
          "Error reading default prefs of addon " +
            addonFile.leafName +
            ": " +
            e
        );
      }
    }

    /*
    TODO: decide whether we need to warn the user/make addon authors to migrate away from these pref files.
    if (prefDataStrings.length > 0) {
      Deprecated.warning(addon.defaultLocale.name + " uses defaults/preferences/*.js files to load prefs",
                         "https://bugzilla.mozilla.org/show_bug.cgi?id=1414398");
    }
    */
  },

  /**
   * Register listening for windows getting opened that will run the specified callback function
   * when a matching window is loaded.
   *
   * @param aID {String}  Some identification of the caller, usually the extension ID.
   * @param aExtensionHook {Object}  The object describing the hook the caller wants to register.
   *        Members of the object can be (all optional, but one callback must be supplied):
   *        chromeURLs {Array}         An array of strings of document URLs on which
   *                                   the given callback should run. If not specified,
   *                                   run on all windows.
   *        onLoadWindow {function}    The callback function to run when window loads
   *                                   the matching document.
   *        onUnloadWindow {function}  The callback function to run when window
   *                                   unloads the matching document.
   *        Both callbacks receive the matching window object as argument.
   *
   * @return {boolean}  True if the passed arguments were valid and the caller could be registered.
   *                    False otherwise.
   */
  registerWindowListener(aID, aExtensionHook) {
    if (!aID) {
      Cu.reportError("No extension ID provided for the window listener");
      return false;
    }

    if (extensionHooks.has(aID)) {
      Cu.reportError(
        "Window listener for extension + '" + aID + "' already registered"
      );
      return false;
    }

    if (
      !("onLoadWindow" in aExtensionHook) &&
      !("onUnloadWindow" in aExtensionHook)
    ) {
      Cu.reportError(
        "The extension + '" + aID + "' does not provide any callbacks"
      );
      return false;
    }

    extensionHooks.set(aID, aExtensionHook);

    // Add our global listener if there isn't one already
    // (only when we have first caller).
    if (extensionHooks.size == 1) {
      Services.wm.addListener(this._windowListener);
    }

    if (openWindowList) {
      // We already have a list of open windows, notify the caller about them.
      openWindowList.forEach(domWindow =>
        ExtensionSupport._checkAndRunMatchingExtensions(domWindow, "load", aID)
      );
    } else {
      openWindowList = new Set();
      // Get the list of windows already open.
      let windows = Services.wm.getEnumerator(null);
      while (windows.hasMoreElements()) {
        let domWindow = windows.getNext();
        if (domWindow.document.location.href === "about:blank") {
          ExtensionSupport._waitForLoad(domWindow, aID);
        } else {
          ExtensionSupport._addToListAndNotify(domWindow, aID);
        }
      }
    }

    return true;
  },

  /**
   * Unregister listening for windows for the given caller.
   *
   * @param aID {String}  Some identification of the caller, usually the extension ID.
   *
   * @return {boolean}  True if the passed arguments were valid and the caller could be unregistered.
   *                    False otherwise.
   */
  unregisterWindowListener(aID) {
    if (!aID) {
      Cu.reportError("No extension ID provided for the window listener");
      return false;
    }

    let windowListener = extensionHooks.get(aID);
    if (!windowListener) {
      Cu.reportError(
        "Couldn't remove window listener for extension + '" + aID + "'"
      );
      return false;
    }

    extensionHooks.delete(aID);
    // Remove our global listener if there are no callers registered anymore.
    if (extensionHooks.size == 0) {
      Services.wm.removeListener(this._windowListener);
      openWindowList.clear();
      openWindowList = undefined;
    }

    return true;
  },

  get openWindows() {
    return openWindowList.values();
  },

  _windowListener: {
    // nsIWindowMediatorListener functions
    onOpenWindow(xulWindow) {
      // A new window has opened.
      let domWindow = xulWindow.docShell.domWindow;

      // Here we pass no caller ID, so all registered callers get notified.
      ExtensionSupport._waitForLoad(domWindow);
    },

    onCloseWindow(xulWindow) {
      // One of the windows has closed.
      let domWindow = xulWindow.docShell.domWindow;
      openWindowList.delete(domWindow);
    },
  },

  /**
   * Set up listeners to run the callbacks on the given window.
   *
   * @param aWindow {nsIDOMWindow}  The window to set up.
   * @param aID {String} Optional.  ID of the new caller that has registered right now.
   */
  _waitForLoad(aWindow, aID) {
    // Wait for the load event of the window. At that point
    // aWindow.document.location.href will not be "about:blank" any more.
    aWindow.addEventListener(
      "load",
      function() {
        ExtensionSupport._addToListAndNotify(aWindow, aID);
      },
      { once: true }
    );
  },

  /**
   * Once the window is fully loaded with the href referring to the XUL document,
   * add it to our list, attach the "unload" listener to it and notify interested
   * callers.
   *
   * @param aWindow {nsIDOMWindow}  The window to process.
   * @param aID {String} Optional.  ID of the new caller that has registered right now.
   */
  _addToListAndNotify(aWindow, aID) {
    openWindowList.add(aWindow);
    aWindow.addEventListener(
      "unload",
      function() {
        ExtensionSupport._checkAndRunMatchingExtensions(aWindow, "unload");
      },
      { once: true }
    );
    ExtensionSupport._checkAndRunMatchingExtensions(aWindow, "load", aID);
  },

  /**
   * Check if the caller matches the given window and run its callback function.
   *
   * @param aWindow {nsIDOMWindow}  The window to run the callbacks on.
   * @param aEventType {String}     Which callback to run if caller matches (load/unload).
   * @param aID {String}            Optional ID of the caller whose callback is to be run.
   *                                If not given, all registered callers are notified.
   */
  _checkAndRunMatchingExtensions(aWindow, aEventType, aID) {
    if (aID) {
      checkAndRunExtensionCode(extensionHooks.get(aID));
    } else {
      for (let extensionHook of extensionHooks.values()) {
        checkAndRunExtensionCode(extensionHook);
      }
    }

    /**
     * Check if the single given caller matches the given window
     * and run its callback function.
     *
     * @param aExtensionHook {Object}  The object describing the hook the caller
     *                                 has registered.
     */
    function checkAndRunExtensionCode(aExtensionHook) {
      let windowChromeURL = aWindow.document.location.href;
      // Check if extension applies to this document URL.
      if (
        "chromeURLs" in aExtensionHook &&
        !aExtensionHook.chromeURLs.some(url => url == windowChromeURL)
      ) {
        return;
      }

      // Run the relevant callback.
      switch (aEventType) {
        case "load":
          if ("onLoadWindow" in aExtensionHook) {
            aExtensionHook.onLoadWindow(aWindow);
          }
          break;
        case "unload":
          if ("onUnloadWindow" in aExtensionHook) {
            aExtensionHook.onUnloadWindow(aWindow);
          }
          break;
      }
    }
  },

  get registeredWindowListenerCount() {
    return extensionHooks.size;
  },
};

AddonManager.addAddonListener(ExtensionSupport.loadedLegacyExtensions);