firefox-scripts icon indicating copy to clipboard operation
firefox-scripts copied to clipboard

Fixs for some of recent breaks NOT ALL OF THEM #341

Open 117649 opened this issue 10 months ago • 0 comments

@xiaoxiaoflood see #341

I also have this Overlay.mjs patched for inline event could be a drop in fix for addons may using it.

/**
* Load overlays in a similar way as XUL did for legacy XUL add-ons.
*/

/* exported Overlays */

"use strict";

const lazy = {};
ChromeUtils.defineESModuleGetters(
  lazy,
  {setTimeout:
  "resource://gre/modules/Timer.sys.mjs"}
);

const { CustomizableUI } = ChromeUtils.importESModule("resource:///modules/CustomizableUI.sys.mjs");

const Globals = {};
Globals.widgets = {};

/**
 * The overlays class, providing support for loading overlays like they used to work. This class
 * should likely be called through its static method Overlays.load()
 */
export class Overlays {
  /**
   * Load overlays for the given window using the overlay provider, which can for example be a
   * ChromeManifest object.
   *
   * @param {ChromeManifest} overlayProvider        The overlay provider that contains information
   *                                                  about styles and overlays.
   * @param {DOMWindow} window                      The window to load into
   */
  static load(overlayProvider, window) {
    const instance = new Overlays(overlayProvider, window);

    const urls = overlayProvider.overlay.get(instance.location, false);
    return instance.load(urls);
  }

  /**
   * Constructs the overlays instance. This class should be called via Overlays.load() instead.
   *
   * @param {ChromeManifest} overlayProvider        The overlay provider that contains information
   *                                                  about styles and overlays.
   * @param {DOMWindow} window                      The window to load into
   */
  constructor(overlayProvider, window) {
    this.overlayProvider = overlayProvider;
    this.window = window;
    if (window.location.protocol == "about:") {
      this.location = window.location.protocol + window.location.pathname;
    } else {
      this.location = window.location.origin + window.location.pathname;
    }

      this.isCSPstrict = window.location == "chrome://browser/content/browser.xhtml"
        && window.document.csp.policyCount && window.document.csp.getPolicy(0) == "script-src-attr 'none' 'report-sample'";
  }

  /**
   * A shorthand to this.window.document
   */
  get document() {
    return this.window.document;
  }

  /**
   * Loads the given urls into the window, recursively loading further overlays as provided by the
   * overlayProvider.
   *
   * @param {String[]} urls                         The urls to load
   */
  async load(urls) {
    const unloadedOverlays = new Set(this._collectOverlays(this.document).concat(urls));
    let forwardReferences = [];
    this.unloadedScripts = [];
    const unloadedSheets = [];
    this._toolbarsToResolve = [];
    const xulStore = Services.xulStore;
    this.persistedIDs = new Set();

    // Load css styles from the registry
    for (const sheet of this.overlayProvider.style.get(this.location, false)) {
      unloadedSheets.push(sheet);
    }

    if (!unloadedOverlays.size && !unloadedSheets.length) {
      return;
    }

    for (const url of unloadedOverlays) {
      unloadedOverlays.delete(url);
      const doc = await this.fetchOverlay(url);

      console.debug(`Applying ${url} to ${this.location}`);

      // clean the document a bit
      const emptyNodes = doc.evaluate(
        "//text()[normalize-space(.) = '']",
        doc,
        null,
        7,
        null
      );
      for (let i = 0, len = emptyNodes.snapshotLength; i < len; ++i) {
        const node = emptyNodes.snapshotItem(i);
        node.remove();
      }

      const commentNodes = doc.evaluate("//comment()", doc, null, 7, null);
      for (let i = 0, len = commentNodes.snapshotLength; i < len; ++i) {
        const node = commentNodes.snapshotItem(i);
        node.remove();
      }

      // Force a re-evaluation of inline styles to work around an issue
      // causing inline styles to be initially ignored.
      const styledNodes = doc.evaluate("//*[@style]", doc, null, 7, null);
      for (let i = 0, len = styledNodes.snapshotLength; i < len; ++i) {
        const node = styledNodes.snapshotItem(i);
        node.style.display = node.style.display; // eslint-disable-line no-self-assign
      }

      // Load css styles from the registry
      for (const sheet of this.overlayProvider.style.get(url, false)) {
        unloadedSheets.push(sheet);
      }

      // Load css processing instructions from the overlay
      const stylesheets = doc.evaluate(
        "/processing-instruction('xml-stylesheet')",
        doc,
        null,
        7,
        null
      );
      for (let i = 0, len = stylesheets.snapshotLength; i < len; ++i) {
        const node = stylesheets.snapshotItem(i);
        const match = node.nodeValue.match(/href=["']([^"']*)["']/);
        if (match) {
          unloadedSheets.push(new URL(match[1], node.baseURI).href);
        }
      }

      const t_unloadedOverlays = [];
      // Prepare loading further nested xul overlays from the overlay
      t_unloadedOverlays.push(...this._collectOverlays(doc));

      // Prepare loading further nested xul overlays from the registry
      for (const overlayUrl of this.overlayProvider.overlay.get(url, false)) {
        t_unloadedOverlays.push(overlayUrl);
      }

      t_unloadedOverlays.forEach(o => unloadedOverlays.add(o));

      // Run through all overlay nodes on the first level (hookup nodes). Scripts will be deferred
      // until later for simplicity (c++ code seems to process them earlier?).
      const t_forwardReferences = [];
      for (const node of doc.documentElement.children) {
        if (node.localName == "script") {
          this.unloadedScripts.push(node);
        } else {
          t_forwardReferences.push(node);
        }
      }
      forwardReferences.unshift(...t_forwardReferences);
    }

    const ids = xulStore.getIDsEnumerator(this.location);
    while (ids.hasMore()) {
      this.persistedIDs.add(ids.getNext());
    }

    // At this point, all (recursive) overlays are loaded. Unloaded scripts and sheets are ready and
    // in order, and forward references are good to process.
    let previous = 0;
    while (forwardReferences.length && forwardReferences.length != previous) {
      previous = forwardReferences.length;
      const unresolved = [];

      for (const ref of forwardReferences) {
        if (!this._resolveForwardReference(ref)) {
          unresolved.push(ref);
        }
      }

      forwardReferences = unresolved;
    }

    if (forwardReferences.length) {
      console.warn(
        `Could not resolve ${forwardReferences.length} references`,
        forwardReferences
      );
    }

    // Loading the sheets now to avoid race conditions with xbl bindings
    for (const sheet of unloadedSheets) {
      this.loadCSS(sheet);
    }

    this._decksToResolve = new Map();
    for (const id of this.persistedIDs.values()) {
      const element = this.document.getElementById(id);
      if (element) {
        const attrNames = xulStore.getAttributeEnumerator(this.location, id);
        while (attrNames.hasMore()) {
          const attrName = attrNames.getNext();
          const attrValue = xulStore.getValue(this.location, id, attrName);
          if (attrName == "selectedIndex" && element.localName == "deck") {
            this._decksToResolve.set(element, attrValue);
          } else if (
            (element != this.document.documentElement ||
            !["height", "screenX", "screenY", "sizemode", "width"].includes(
              attrName)) &&
            element.getAttribute(attrName) != attrValue.toString()
          ) {
            element.setAttribute(attrName, attrValue);
          }
        }
      }
    }

    // We've resolved all the forward references we can, we can now go ahead and load the scripts
    this.deferredLoad = [];
    for (const script of this.unloadedScripts) {
      this.deferredLoad.push(...this.loadScript(script));
    }

    if (this.document.readyState == "complete") {
      lazy.setTimeout(() => {
        this._finish();

        // Now execute load handlers since we are done loading scripts
        const bubbles = [];
        for (const {listener, useCapture} of this.deferredLoad) {
          if (useCapture) {
            this._fireEventListener(listener);
          } else {
            bubbles.push(listener);
          }
        }

        for (const listener of bubbles) {
          this._fireEventListener(listener);
        }
      });
    } else {
      this.document.defaultView.addEventListener(
        "load",
        this._finish.bind(this),
        {once: true}
      );
    }
  }

  _finish() {
    for (const [deck, selectedIndex] of this._decksToResolve.entries()) {
      deck.setAttribute("selectedIndex", selectedIndex);
    }

    for (const bar of this._toolbarsToResolve) {
      const currentSet = Services.xulStore.getValue(
        this.location,
        bar.id,
        "currentset"
      );
      if (currentSet) {
        bar.currentSet = currentSet;
      } else if (bar.getAttribute("defaultset")) {
        bar.currentSet = bar.getAttribute("defaultset");
      }
    }
  }

  /**
   * Gets the overlays referenced by processing instruction on a document.
   *
   * @param {DOMDocument} document  The document to read instructions from
   * @return {String[]}             URLs of the overlays from the document
   */
  _collectOverlays(doc) {
    const urls = [];
    const instructions = doc.evaluate(
      "/processing-instruction('xul-overlay')",
      doc,
      null,
      7,
      null
    );
    for (let i = 0, len = instructions.snapshotLength; i < len; ++i) {
      const node = instructions.snapshotItem(i);
      const match = node.nodeValue.match(/href=["']([^"']*)["']/);
      if (match) {
        urls.push(match[1]);
      }
    }
    return urls;
  }

  /**
   * Fires a "load" event for the given listener, using the current window
   *
   * @param {EventListener|Function} listener       The event listener to call
   */
  _fireEventListener(listener) {
    const fakeEvent = new this.window.UIEvent("load", {view: this.window});
    if (typeof listener == "function") {
      listener(fakeEvent);
    } else if (listener && typeof listener == "object") {
      listener.handleEvent(fakeEvent);
    } else {
      console.error("Unknown listener type", listener);
    }
  }

  /**
   * Resolves forward references for the given node. If the node exists in the target document, it
   * is merged in with the target node. If the node has no id it is inserted at documentElement
   * level.
   *
   * @param {Element} node          The DOM Element to resolve in the target document.
   * @return {Boolean}              True, if the node was merged/inserted, false otherwise
   */
  _resolveForwardReference(node) {
    if (node.id) {
      const target = this.document.getElementById(node.id);
      if (node.localName == "template") {
        this._insertElement(this.document.documentElement, node);
        return true;
      } else if (node.localName == "toolbarpalette") {
        const toolboxes = this.window.document.querySelectorAll('toolbox');
        for (const toolbox of toolboxes) {
          let palette = toolbox.palette;

          if (palette &&
					this.window.gCustomizeMode._stowedPalette &&
					this.window.gCustomizeMode._stowedPalette.id == node.id &&
					palette == this.window.gCustomizeMode.visiblePalette) {
            palette = this.window.gCustomizeMode._stowedPalette;
          }

          if (palette && (palette.id == node.id || (node.id == "BrowserToolbarPalette" && toolbox == this.window.gNavToolbox))) {
            for (let button of node.childNodes) {
              if (button.id) {
                const existButton = this.window.document.getElementById(button.id);

                // If it's a placeholder created by us to deal with CustomizableUI, just use it.
                if (this.trueAttribute(existButton, 'CUI_placeholder')) {
                  this.removeAttribute(existButton, 'CUI_placeholder');
                  existButton.collapsed = false;
                  this.appendButton(this.window, palette, existButton);

                // we shouldn't be changing widgets, or adding with same id as other nodes
                } else if (!existButton) {
                  // Save a copy of the widget node in the sandbox,
                  // so CUI can use it when opening a new window without having to wait for the overlay.
                  if (!Globals.widgets[button.id]) {
                    Globals.widgets[button.id] = button;
                  }

                  if (this.isCSPstrict) [button, ...button.querySelectorAll("*")].forEach((el) => [...el.attributes].forEach((a) =>
                    a.name.startsWith("on") && (el.setAttribute("an" + a.name, el.getAttribute(a.name)), el.removeAttribute(a.name))));
                  // add the button if not found either in a toolbar or the palette
                  button = this.window.document.importNode(button, true);
                  this.appendButton(this.window, palette, button);
                }
              }
            }
            break;
          }
        }
        return true;
      } else if (!target) {
        if (node.hasAttribute("insertafter") || node.hasAttribute("insertbefore")) {
          this._insertElement(this.document.documentElement, node);
          return true;
        }
        console.debug(
          `The node ${node.id} could not be found, deferring to later`
        );
        return false;
      }

      this._mergeElement(target, node);
    } else {
      this._insertElement(this.document.documentElement, node);
    }
    return true;
  }

  /**
   * Insert the node in the given parent, observing the insertbefore/insertafter/position attributes
   *
   * @param {Element} parent        The parent element to insert the node into.
   * @param {Element} node          The node to insert.
   */
  _insertElement(parent, node) {
    // These elements need their values set before they are added to
    // the document, or bad things happen.
    for (const element of node.querySelectorAll("menulist")) {
      if (element.id && this.persistedIDs.has(element.id)) {
        element.setAttribute(
          "value",
          Services.xulStore.getValue(this.location, element.id, "value")
        );
      }
    }

    if (node.localName == "toolbar") {
      this._toolbarsToResolve.push(node);
    } else {
      this._toolbarsToResolve.push(...node.querySelectorAll("toolbar"));
    }

    const nodes = node.querySelectorAll('script');
    for (const script of nodes) {
      this.deferredLoad.push(...this.loadScript(script));
    }

    let wasInserted = false;
    let pos = node.getAttribute("insertafter");
    let after = true;

    if (!pos) {
      pos = node.getAttribute("insertbefore");
      after = false;
    }

    if (this.isCSPstrict) [node, ...node.querySelectorAll("*")].forEach((el) => [...el.attributes].forEach((a) =>
      a.name.startsWith("on") && (el.setAttribute("an" + a.name, el.getAttribute(a.name)), el.removeAttribute(a.name))));

    if (pos) {
      for (const id of pos.split(",")) {
        const targetChild = this.document.getElementById(id);
        if (targetChild && parent.contains(targetChild.parentNode)) {
          targetChild.parentNode.insertBefore(
            node,
            after ? targetChild.nextElementSibling : targetChild
          );
          wasInserted = true;
          break;
        }
      }
    }

    if (!wasInserted) {
      // position is 1-based
      const position = parseInt(node.getAttribute("position"), 10);
      if (position > 0 && position - 1 <= parent.children.length) {
        parent.insertBefore(node, parent.children[position - 1]);
        wasInserted = true;
      }
    }

    if (!wasInserted) {
      parent.appendChild(node);
    }

    if (this.isCSPstrict) [node, ...node.querySelectorAll("*")].forEach((el) =>
      [...el.attributes].forEach((a) => {
        if (a.name.startsWith("anon"))
          el.addEventListener(a.name.replace(/^anon/, ''), new Function("event", "with(event.view){" + a.textContent + "}"));
      }));
  }

  /**
   * Merge the node into the target, adhering to the removeelement attribute, merging further
   * attributes into the target node, and merging children as appropriate for xul nodes. If a child
   * has an id, it will be searched in the target document and recursively merged.
   *
   * @param {Element} target        The node to merge into
   * @param {Element} node          The node that is being merged
   */
  _mergeElement(target, node) {
    for (const attribute of node.attributes) {
      if (attribute.name !== "id") {
        if (attribute.name == "removeelement" && attribute.value == "true") {
          target.remove();
          return;
        }

        target.setAttributeNS(
          attribute.namespaceURI,
          attribute.name,
          attribute.value
        );
      }
    }

    for (const nodes of node.children) {
      if (nodes.localName == "script") {
        this.deferredLoad.push(...this.loadScript(nodes));
      }
    }

    for (let i = 0, len = node.childElementCount; i < len; i++) {
      const child = node.firstElementChild;
      child.remove();

      const elementInDocument = child.id ?
        this.document.getElementById(child.id) :
        null;
      const parentId = elementInDocument ? elementInDocument.parentNode.id : null;

      if (parentId && parentId == target.id) {
        this._mergeElement(elementInDocument, child);
      } else {
        this._insertElement(target, child);
      }
    }
  }

  /**
   * Fetches the overlay from the given chrome:// or resource:// URL.
   *
   * @param {String} srcUrl          The URL to load
   * @return {Promise<XMLDocument>}  Returns a promise that is resolved with the XML document.
   */
   fetchOverlay(srcUrl) {
    if (!srcUrl.startsWith("chrome://") && !srcUrl.startsWith("resource://")) {
      throw new Error(
        "May only load overlays from chrome:// or resource:// uris"
      );
    }

    return new Promise((resolve, reject) => {
      const xhr = new this.window.XMLHttpRequest();
      xhr.overrideMimeType("application/xml");
      xhr.open("GET", srcUrl, true);

      // Elevate the request, so DTDs will work. Should not be a security issue since we
      // only load chrome, resource and file URLs, and that is our privileged chrome package.
      try {
        xhr.channel.owner = Services.scriptSecurityManager.getSystemPrincipal();
      } catch (ex) {
        console.error(`Failed to set system principal while fetching overlay ${srcUrl}`);
        xhr.close();
        reject("Failed to set system principal");
      }

      xhr.onload = () => resolve(xhr.responseXML);
      xhr.onerror = () => reject(`Failed to load ${srcUrl} to ${this.location}`);
      xhr.send(null);
    });
  }

  /**
   * Loads scripts described by the given script node. The node can either have a src attribute, or
   * be an inline script with textContent.
   *
   * @param {Element} node                          The <script> element to load the script from
   * @return {Object[]}                             An object with listener and useCapture,
   *                                                  describing load handlers the script creates
   *                                                  when first run.
   */
  loadScript(node) {
    const deferredLoad = [];

    const oldAddEventListener = this.window.addEventListener;
    if (this.document.readyState == "complete") {
      this.window.addEventListener = function(
        type,
        listener,
        useCapture,
        ...args
      ) {
        if (type == "load") {
          if (typeof useCapture == "object") {
            useCapture = useCapture.capture;
          }

          if (typeof useCapture == "undefined") {
            useCapture = true;
          }
          deferredLoad.push({listener, useCapture});
          return null;
        }
        return oldAddEventListener.call(
          this,
          type,
          listener,
          useCapture,
          ...args
        );
      };
    }

    if (node.hasAttribute("src")) {
      const url = new URL(node.getAttribute("src"), node.baseURI).href;
      console.debug(`Loading script ${url} into ${this.window.location}`);
      try {
        Services.scriptloader.loadSubScript(url, this.window);
      } catch (ex) {
        Cu.reportError(ex);
      }
    } else if (node.textContent) {
      console.debug(`Loading eval'd script into ${this.window.location}`);
      try {
        const dataURL =
          "data:application/javascript," + encodeURIComponent(node.textContent);
        // It would be great if we could have script errors show the right url, but for now
        // loadSubScript will have to do.
        Services.scriptloader.loadSubScript(dataURL, this.window);
      } catch (ex) {
        Cu.reportError(ex);
      }
    }

    if (this.document.readyState == "complete") {
      this.window.addEventListener = oldAddEventListener;
    }

    // This works because we only care about immediately executed addEventListener calls and
    // loadSubScript is synchronous. Everyone else should be checking readyState anyway.
    return deferredLoad;
  }

  /**
   * Load the CSS stylesheet from the given url
   *
   * @param {String} url        The url to load from
   * @return {Element}          An HTML link element for this stylesheet
   */
  loadCSS(url) {
    console.debug(`Loading ${url} into ${this.window.location}`);

    const winUtils = this.window.windowUtils;
    winUtils.loadSheetUsingURIString(url, winUtils.AUTHOR_SHEET);
  }

  trueAttribute(obj, attr) {
    if (!obj || !obj.getAttribute) {
      return false;
    }

    return (obj.getAttribute(attr) == 'true');
  }

  removeAttribute(obj, attr) {
    if (!obj || !obj.removeAttribute) {
      return;
    }
    obj.removeAttribute(attr);
  }

  appendButton(aWindow, palette, node) {
    if (!node.parentNode) {
      palette.appendChild(node);
    }
    const id = node.id;

    const widget = CustomizableUI.getWidget(id);
    if (!widget || widget.provider != CustomizableUI.PROVIDER_API) {
      try {
        CustomizableUI.createWidget(this.getWidgetData(node, palette));
      } catch (ex) {
        Cu.reportError(ex);
      }
    } else {
      try {
        CustomizableUI.ensureWidgetPlacedInWindow(id, aWindow);
      } catch (ex) {
        Cu.reportError(ex);
      }
    }

    return node;
  }

  getWidgetData(node, palette) {
    // let's default this one
    const data = { removable: true };

    if (node.attributes) {
      for (const attr of node.attributes) {
        if (attr.value == 'true') {
          data[attr.name] = true;
        } else if (attr.value == 'false') {
          data[attr.name] = false;
        } else {
          data[attr.name] = attr.value;
        }
      }
    }

    const WT = ['button', 'view', 'button-and-view', 'custom'];
    if (!data.type && node.tagName == 'toolbarbutton') data.type = 'button';
    if (!WT.includes(data.type)) data.type = 'custom';
    else {
      // here we should have code to handle the <toolbarbutton> in overlay that use widget types making widge out of them
      // by convert 'on*' attributeis to function.
      for (const key of Object.keys(data).filter(t => t.startsWith('on') || (this.isCSPstrict && t.startsWith('anon')))) {
        const f = new Function("event", "with(event.view){" + data[key] + "}");
        key = key.replace(/^an/, '');
        data['on' + key.charAt(2).toUpperCase() + key.slice(3)] = f;
        delete data[key];
      }
    }

    // createWidget() defaults the removable state to true as of bug 947987
    if (!data.removable && !data.defaultArea) {
      data.defaultArea = (node.parentNode) ? node.parentNode.id : palette.id;
    }

    if (data.type == 'custom') {
      data.palette = palette;

      data.onBuild = function (aDocument, aDestroy) {
        // Find the node in the DOM tree
        node = aDocument.getElementById(this.id);

        // If it doesn't exist, find it in a palette.
        // We make sure the button is in either place at all times.
        if (!node) {
          const toolboxes = aDocument.querySelectorAll('toolbox');
          for (const toolbox of toolboxes) {
            let tbPalette = toolbox.palette;
            if (tbPalette) {
              if (tbPalette == aDocument.defaultView.gCustomizeMode.visiblePalette) {
                tbPalette = aDocument.defaultView.gCustomizeMode._stowedPalette;
              }
              const child = [...tbPalette.childNodes].find(item => item.id == this.id, this);
              if (child) {
                node = child;
                break;
              }
            }
          }
        }

        // If it doesn't exist there either, CustomizableUI is using the widget information before it has been overlayed (i.e. opening a new window).
        // We get a placeholder for it, then we'll replace it later when the window overlays.
        if (!node && !aDestroy) {
          node = aDocument.importNode(Globals.widgets[this.id], true);
          node?.setAttribute('CUI_placeholder', 'true');
          node.collapsed = true;
        }

        return node;
      };

      const self = this;
      // unregisterArea()'ing the toolbar can nuke the nodes, we need to make sure ours are moved to the palette
      data.onWidgetAfterDOMChange = function (aNode) {
        if (aNode.id == this.id &&
          !aNode.parentNode &&
          !self.trueAttribute(aNode.ownerDocument.documentElement, 'customizing') && // it always ends up in the palette in this case
          this.palette) {
          this.palette.appendChild(aNode);
        }
      };

      data.onWidgetDestroyed = function (aId) {
        if (aId == this.id) {
          const browserEnumerator = Services.wm.getEnumerator('navigator:browser');
          const handler = win => {
            const widget = data.onBuild(win.document, true);
            if (widget) {
              widget.remove();
            }
          };
          while (browserEnumerator.hasMoreElements()) {
            const window = browserEnumerator.getNext();

            if (!window || !window.addEventListener) {
              continue;
            }

            if (window.document.readyState == "complete") {
              try {
                handler(window);
              } catch (ex) {
                Cu.reportError(ex);
              }
              continue;
            }

            const runOnce = function (event) {
              try {
                window.removeEventListener("load", runOnce, false);
              } catch (ex) {
                if (ex.message == "can't access dead object") {
                  const scriptError = Cc["@mozilla.org/scripterror;1"].createInstance(Ci.nsIScriptError);
                  scriptError.init(
                    "Can't access dead object. This shouldn't cause any problems.",
                    ex.sourceName || ex.fileName || null,
                    ex.sourceLine || null,
                    ex.lineNumber || null,
                    ex.columnNumber || null,
                    scriptError.warningFlag,
                    "XPConnect JavaScript"
                  );
                  Services.console.logMessage(scriptError);
                }
                Cu.reportError(ex);
              } // Prevents some can't access dead object errors
              if (event !== undefined) {
                try {
                  handler(window);
                } catch (ex) {
                  Cu.reportError(ex);
                }
              }
            };

            window.addEventListener("load", runOnce, false);
          }
          CustomizableUI.removeListener(this);
        }
      };

      CustomizableUI.addListener(data);
    }

    return data;
  }
}

117649 avatar Feb 12 '25 10:02 117649