Blob Blame History Raw
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

var EXPORTED_SYMBOLS = [ "ContentLinkHandler" ];

ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
ChromeUtils.import("resource://gre/modules/Services.jsm");

ChromeUtils.defineModuleGetter(this, "Feeds",
  "resource:///modules/Feeds.jsm");
ChromeUtils.defineModuleGetter(this, "BrowserUtils",
  "resource://gre/modules/BrowserUtils.jsm");

const SIZES_TELEMETRY_ENUM = {
  NO_SIZES: 0,
  ANY: 1,
  DIMENSION: 2,
  INVALID: 3,
};

const FAVICON_PARSING_TIMEOUT = 100;
const FAVICON_RICH_ICON_MIN_WIDTH = 96;

const TYPE_ICO = "image/x-icon";
const TYPE_SVG = "image/svg+xml";

/*
 * Create a nsITimer.
 *
 * @param {function} aCallback A timeout callback function.
 * @param {Number} aDelay A timeout interval in millisecond.
 * @return {nsITimer} A nsITimer object.
 */
function setTimeout(aCallback, aDelay) {
  let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
  timer.initWithCallback(aCallback, aDelay, Ci.nsITimer.TYPE_ONE_SHOT);
  return timer;
}

/*
 * Extract the icon width from the size attribute. It also sends the telemetry
 * about the size type and size dimension info.
 *
 * @param {Array} aSizes An array of strings about size.
 * @return {Number} A width of the icon in pixel.
 */
function extractIconSize(aSizes) {
  let width = -1;
  let sizesType;
  const re = /^([1-9][0-9]*)x[1-9][0-9]*$/i;

  if (aSizes.length) {
    for (let size of aSizes) {
      if (size.toLowerCase() == "any") {
        sizesType = SIZES_TELEMETRY_ENUM.ANY;
        break;
      } else {
        let values = re.exec(size);
        if (values && values.length > 1) {
          sizesType = SIZES_TELEMETRY_ENUM.DIMENSION;
          width = parseInt(values[1]);
          break;
        } else {
          sizesType = SIZES_TELEMETRY_ENUM.INVALID;
          break;
        }
      }
    }
  } else {
    sizesType = SIZES_TELEMETRY_ENUM.NO_SIZES;
  }

  // Telemetry probes for measuring the sizes attribute
  // usage and available dimensions.
  Services.telemetry.getHistogramById("LINK_ICON_SIZES_ATTR_USAGE").add(sizesType);
  if (width > 0)
    Services.telemetry.getHistogramById("LINK_ICON_SIZES_ATTR_DIMENSION").add(width);

  return width;
}

/*
 * Get link icon URI from a link dom node.
 *
 * @param {DOMNode} aLink A link dom node.
 * @return {nsIURI} A uri of the icon.
 */
function getLinkIconURI(aLink) {
  let targetDoc = aLink.ownerDocument;
  let uri = Services.io.newURI(aLink.href, targetDoc.characterSet);
  try {
    uri = uri.mutate().setUserPass("").finalize();
  } catch (e) {
    // some URIs are immutable
  }
  return uri;
}

/*
 * Set the icon via sending the "Link:Seticon" message.
 *
 * @param {Object} aIconInfo The IconInfo object looks like {
 *   iconUri: icon URI,
 *   loadingPrincipal: icon loading principal
 * }.
 * @param {Object} aChromeGlobal A global chrome object.
 */
function setIconForLink(aIconInfo, aChromeGlobal) {
  aChromeGlobal.sendAsyncMessage(
    "Link:SetIcon",
    { url: aIconInfo.iconUri.spec,
      loadingPrincipal: aIconInfo.loadingPrincipal,
      requestContextID: aIconInfo.requestContextID,
      canUseForTab: !aIconInfo.isRichIcon,
    });
}

/**
 * Guess a type for an icon based on its declared type or file extension.
 */
function guessType(icon) {
  // No type with no icon
  if (!icon) {
    return "";
  }

  // Use the file extension to guess at a type we're interested in
  if (!icon.type) {
    let extension = icon.iconUri.filePath.split(".").pop();
    switch (extension) {
      case "ico":
        return TYPE_ICO;
      case "svg":
        return TYPE_SVG;
    }
  }

  // Fuzzily prefer the type or fall back to the declared type
  return icon.type == "image/vnd.microsoft.icon" ? TYPE_ICO : icon.type || "";
}

/*
 * Timeout callback function for loading favicon.
 *
 * @param {Map} aFaviconLoads A map of page URL and FaviconLoad object pairs,
 *   where the FaviconLoad object looks like {
 *     timer: a nsITimer object,
 *     iconInfos: an array of IconInfo objects
 *   }
 * @param {String} aPageUrl A page URL string for this callback.
 * @param {Object} aChromeGlobal A global chrome object.
 */
function faviconTimeoutCallback(aFaviconLoads, aPageUrl, aChromeGlobal) {
  let load = aFaviconLoads.get(aPageUrl);
  if (!load)
    return;

  let preferredIcon;
  let preferredWidth = 16 * Math.ceil(aChromeGlobal.content.devicePixelRatio);
  let bestSizedIcon;
  // Other links with the "icon" tag are the default icons
  let defaultIcon;
  // Rich icons are either apple-touch or fluid icons, or the ones of the
  // dimension 96x96 or greater
  let largestRichIcon;

  for (let icon of load.iconInfos) {
    if (!icon.isRichIcon) {
      // First check for svg. If it's not available check for an icon with a
      // size adapt to the current resolution. If both are not available, prefer
      // ico files. When multiple icons are in the same set, the latest wins.
      if (guessType(icon) == TYPE_SVG) {
        preferredIcon = icon;
      } else if (icon.width == preferredWidth && guessType(preferredIcon) != TYPE_SVG) {
        preferredIcon = icon;
      } else if (guessType(icon) == TYPE_ICO && (!preferredIcon || guessType(preferredIcon) == TYPE_ICO)) {
        preferredIcon = icon;
      }

      // Check for an icon larger yet closest to preferredWidth, that can be
      // downscaled efficiently.
      if (icon.width >= preferredWidth &&
          (!bestSizedIcon || bestSizedIcon.width >= icon.width)) {
        bestSizedIcon = icon;
      }
    }

    // Note that some sites use hi-res icons without specifying them as
    // apple-touch or fluid icons.
    if (icon.isRichIcon || icon.width >= FAVICON_RICH_ICON_MIN_WIDTH) {
      if (!largestRichIcon || largestRichIcon.width < icon.width) {
        largestRichIcon = icon;
      }
    } else {
      defaultIcon = icon;
    }
  }

  // Now set the favicons for the page in the following order:
  // 1. Set the best rich icon if any.
  // 2. Set the preferred one if any, otherwise check if there's a better
  //    sized fit.
  // This order allows smaller icon frames to eventually override rich icon
  // frames.
  if (largestRichIcon) {
    setIconForLink(largestRichIcon, aChromeGlobal);
  }
  if (preferredIcon) {
    setIconForLink(preferredIcon, aChromeGlobal);
  } else if (bestSizedIcon) {
    setIconForLink(bestSizedIcon, aChromeGlobal);
  } else if (defaultIcon) {
    setIconForLink(defaultIcon, aChromeGlobal);
  }

  load.timer = null;
  aFaviconLoads.delete(aPageUrl);
}

/*
 * Get request context ID of the link dom node's document.
 *
 * @param {DOMNode} aLink A link dom node.
 * @return {Number} The request context ID.
 *                  Return null when document's load group is not available.
 */
function getLinkRequestContextID(aLink) {
  try {
    return aLink.ownerDocument.documentLoadGroup.requestContextID;
  } catch (e) {
    return null;
  }
}

/*
 * Favicon link handler.
 *
 * @param {DOMNode} aLink A link dom node.
 * @param {bool} aIsRichIcon A bool to indicate if the link is rich icon.
 * @param {Object} aChromeGlobal A global chrome object.
 * @param {Map} aFaviconLoads A map of page URL and FaviconLoad object pairs.
 * @return {bool} Returns true if the link is successfully handled.
 */
function handleFaviconLink(aLink, aIsRichIcon, aChromeGlobal, aFaviconLoads) {
  let pageUrl = aLink.ownerDocument.documentURI;
  let iconUri = getLinkIconURI(aLink);
  if (!iconUri)
    return false;

  // Extract the size type and width.
  let width = extractIconSize(aLink.sizes);
  let iconInfo = {
    iconUri,
    width,
    isRichIcon: aIsRichIcon,
    type: aLink.type,
    loadingPrincipal: aLink.ownerDocument.nodePrincipal,
    requestContextID: getLinkRequestContextID(aLink)
  };

  if (aFaviconLoads.has(pageUrl)) {
    let load = aFaviconLoads.get(pageUrl);
    load.iconInfos.push(iconInfo);
    // Re-initialize the timer
    load.timer.delay = FAVICON_PARSING_TIMEOUT;
  } else {
    let timer = setTimeout(() => faviconTimeoutCallback(aFaviconLoads, pageUrl, aChromeGlobal),
                                                        FAVICON_PARSING_TIMEOUT);
    let load = { timer, iconInfos: [iconInfo] };
    aFaviconLoads.set(pageUrl, load);
  }
  return true;
}

var ContentLinkHandler = {
  init(chromeGlobal) {
    const faviconLoads = new Map();
    chromeGlobal.addEventListener("DOMLinkAdded", event => {
      this.onLinkEvent(event, chromeGlobal, faviconLoads);
    });
    chromeGlobal.addEventListener("DOMLinkChanged", event => {
      this.onLinkEvent(event, chromeGlobal, faviconLoads);
    });
    chromeGlobal.addEventListener("unload", event => {
      for (const [pageUrl, load] of faviconLoads) {
        load.timer.cancel();
        load.timer = null;
        faviconLoads.delete(pageUrl);
      }
    });
  },

  onLinkEvent(event, chromeGlobal, faviconLoads) {
    var link = event.originalTarget;
    var rel = link.rel && link.rel.toLowerCase();
    if (!link || !link.ownerDocument || !rel || !link.href)
      return;

    // Ignore sub-frames (bugs 305472, 479408).
    let window = link.ownerGlobal;
    if (window != window.top)
      return;

    // Note: following booleans only work for the current link, not for the
    // whole content
    var feedAdded = false;
    var iconAdded = false;
    var searchAdded = false;
    var rels = {};
    for (let relString of rel.split(/\s+/))
      rels[relString] = true;

    for (let relVal in rels) {
      let isRichIcon = true;

      switch (relVal) {
        case "feed":
        case "alternate":
          if (!feedAdded && event.type == "DOMLinkAdded") {
            if (!rels.feed && rels.alternate && rels.stylesheet)
              break;

            if (Feeds.isValidFeed(link, link.ownerDocument.nodePrincipal, "feed" in rels)) {
              chromeGlobal.sendAsyncMessage("Link:AddFeed",
                                            {type: link.type,
                                             href: link.href,
                                             title: link.title});
              feedAdded = true;
            }
          }
          break;
        case "icon":
          isRichIcon = false;
          // Fall through to rich icon handling
        case "apple-touch-icon":
        case "apple-touch-icon-precomposed":
        case "fluid-icon":
          if (link.hasAttribute("mask") || // Masked icons are not supported yet.
              iconAdded ||
              !Services.prefs.getBoolPref("browser.chrome.site_icons")) {
            break;
          }

          iconAdded = handleFaviconLink(link, isRichIcon, chromeGlobal, faviconLoads);
          break;
        case "search":
          if (Services.policies &&
              !Services.policies.isAllowed("installSearchEngine")) {
            break;
          }
          if (!searchAdded && event.type == "DOMLinkAdded") {
            var type = link.type && link.type.toLowerCase();
            type = type.replace(/^\s+|\s*(?:;.*)?$/g, "");

            let re = /^(?:https?|ftp):/i;
            if (type == "application/opensearchdescription+xml" && link.title &&
                re.test(link.href)) {
              let engine = { title: link.title, href: link.href };
              chromeGlobal.sendAsyncMessage("Link:AddSearch",
                                            {engine,
                                             url: link.ownerDocument.documentURI});
              searchAdded = true;
            }
          }
          break;
      }
    }
  },
};