/* 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); // 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; } } // 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 use the default one. // This order allows smaller icon frames to eventually override rich icon // frames. if (largestRichIcon) { setIconForLink(largestRichIcon, aChromeGlobal); } if (preferredIcon) { setIconForLink(preferredIcon, 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 (!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; } } }, };