1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
7 ChromeUtils.defineESModuleGetters(lazy, {
8 DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
11 const STREAM_SEGMENT_SIZE = 4096;
12 const PR_UINT32_MAX = 0xffffffff;
14 const BinaryInputStream = Components.Constructor(
15 "@mozilla.org/binaryinputstream;1",
16 "nsIBinaryInputStream",
19 const StorageStream = Components.Constructor(
20 "@mozilla.org/storagestream;1",
24 const BufferedOutputStream = Components.Constructor(
25 "@mozilla.org/network/buffered-output-stream;1",
26 "nsIBufferedOutputStream",
30 const SIZES_TELEMETRY_ENUM = {
37 const FAVICON_PARSING_TIMEOUT = 100;
38 const FAVICON_RICH_ICON_MIN_WIDTH = 96;
39 const PREFERRED_WIDTH = 16;
41 // URL schemes that we don't want to load and convert to data URLs.
42 const LOCAL_FAVICON_SCHEMES = ["chrome", "about", "resource", "data"];
44 const MAX_FAVICON_EXPIRATION = 7 * 24 * 60 * 60 * 1000;
45 const MAX_ICON_SIZE = 2048;
47 const TYPE_ICO = "image/x-icon";
48 const TYPE_SVG = "image/svg+xml";
50 function promiseBlobAsDataURL(blob) {
51 return new Promise((resolve, reject) => {
52 let reader = new FileReader();
53 reader.addEventListener("load", () => resolve(reader.result));
54 reader.addEventListener("error", reject);
55 reader.readAsDataURL(blob);
59 function promiseBlobAsOctets(blob) {
60 return new Promise((resolve, reject) => {
61 let reader = new FileReader();
62 reader.addEventListener("load", () => {
63 resolve(Array.from(reader.result).map(c => c.charCodeAt(0)));
65 reader.addEventListener("error", reject);
66 reader.readAsBinaryString(blob);
70 function promiseImage(stream, type) {
71 return new Promise((resolve, reject) => {
72 let imgTools = Cc["@mozilla.org/image/tools;1"].getService(Ci.imgITools);
74 imgTools.decodeImageAsync(
78 if (!Components.isSuccessCode(result)) {
85 Services.tm.currentThread
91 constructor(iconInfo) {
95 if (iconInfo.node.crossOrigin === "anonymous") {
96 securityFlags = Ci.nsILoadInfo.SEC_REQUIRE_CORS_INHERITS_SEC_CONTEXT;
97 } else if (iconInfo.node.crossOrigin === "use-credentials") {
99 Ci.nsILoadInfo.SEC_REQUIRE_CORS_INHERITS_SEC_CONTEXT |
100 Ci.nsILoadInfo.SEC_COOKIES_INCLUDE;
103 Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT;
106 this.channel = Services.io.newChannelFromURI(
109 iconInfo.node.nodePrincipal,
110 iconInfo.node.nodePrincipal,
112 Ci.nsILoadInfo.SEC_ALLOW_CHROME |
113 Ci.nsILoadInfo.SEC_DISALLOW_SCRIPT,
114 Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON
117 if (this.channel instanceof Ci.nsIHttpChannel) {
118 this.channel.QueryInterface(Ci.nsIHttpChannel);
119 let referrerInfo = Cc["@mozilla.org/referrer-info;1"].createInstance(
122 // Sometimes node is a document and sometimes it is an element. We need
123 // to set the referrer info correctly either way.
124 if (iconInfo.node.nodeType == iconInfo.node.DOCUMENT_NODE) {
125 referrerInfo.initWithDocument(iconInfo.node);
127 referrerInfo.initWithElement(iconInfo.node);
129 this.channel.referrerInfo = referrerInfo;
131 this.channel.loadFlags |=
132 Ci.nsIRequest.LOAD_BACKGROUND |
133 Ci.nsIRequest.VALIDATE_NEVER |
134 Ci.nsIRequest.LOAD_FROM_CACHE;
135 // Sometimes node is a document and sometimes it is an element. This is
136 // the easiest single way to get to the load group in both those cases.
137 this.channel.loadGroup =
138 iconInfo.node.ownerGlobal.document.documentLoadGroup;
139 this.channel.notificationCallbacks = this;
141 if (this.channel instanceof Ci.nsIHttpChannelInternal) {
142 this.channel.blockAuthPrompt = true;
146 Services.prefs.getBoolPref("network.http.tailing.enabled", true) &&
147 this.channel instanceof Ci.nsIClassOfService
149 this.channel.addClassFlags(
150 Ci.nsIClassOfService.Tail | Ci.nsIClassOfService.Throttleable
156 this._deferred = Promise.withResolvers();
158 // Clear the references when we succeed or fail.
159 let cleanup = () => {
161 this.dataBuffer = null;
164 this._deferred.promise.then(cleanup, cleanup);
166 this.dataBuffer = new StorageStream(STREAM_SEGMENT_SIZE, PR_UINT32_MAX);
168 // storage streams do not implement writeFrom so wrap it with a buffered stream.
169 this.stream = new BufferedOutputStream(
170 this.dataBuffer.getOutputStream(0),
171 STREAM_SEGMENT_SIZE * 2
175 this.channel.asyncOpen(this);
177 this._deferred.reject(e);
180 return this._deferred.promise;
188 this.channel.cancel(Cr.NS_BINDING_ABORTED);
193 onDataAvailable(request, inputStream, offset, count) {
194 this.stream.writeFrom(inputStream, count);
197 asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
198 if (oldChannel == this.channel) {
199 this.channel = newChannel;
202 callback.onRedirectVerifyCallback(Cr.NS_OK);
205 async onStopRequest(request, statusCode) {
206 if (request != this.channel) {
207 // Indicates that a redirect has occurred. We don't care about the result
208 // of the original channel.
215 if (!Components.isSuccessCode(statusCode)) {
216 if (statusCode == Cr.NS_BINDING_ABORTED) {
217 this._deferred.reject(
218 Components.Exception(
219 `Favicon load from ${this.icon.iconUri.spec} was cancelled.`,
224 this._deferred.reject(
225 Components.Exception(
226 `Favicon at "${this.icon.iconUri.spec}" failed to load.`,
234 if (this.channel instanceof Ci.nsIHttpChannel) {
235 if (!this.channel.requestSucceeded) {
236 this._deferred.reject(
237 Components.Exception(
238 `Favicon at "${this.icon.iconUri.spec}" failed to load: ${this.channel.responseStatusText}.`,
239 { data: { httpStatus: this.channel.responseStatus } }
246 // By default don't store icons added after "pageshow".
247 let canStoreIcon = this.icon.beforePageShow;
249 // Don't store icons responding with Cache-Control: no-store, but always
250 // allow root domain icons.
253 this.icon.iconUri.filePath != "/favicon.ico" &&
254 this.channel instanceof Ci.nsIHttpChannel &&
255 this.channel.isNoStoreResponse()
257 canStoreIcon = false;
260 if (ex.result != Cr.NS_ERROR_NOT_AVAILABLE) {
266 // Attempt to get an expiration time from the cache. If this fails, we'll
268 let expiration = Date.now() + MAX_FAVICON_EXPIRATION;
270 // This stuff isn't available after onStopRequest returns (so don't start
271 // any async operations before this!).
272 if (this.channel instanceof Ci.nsICacheInfoChannel) {
274 expiration = Math.min(
275 this.channel.cacheTokenExpirationTime * 1000,
279 // Ignore failures to get the expiration time.
284 let stream = new BinaryInputStream(this.dataBuffer.newInputStream(0));
285 let buffer = new ArrayBuffer(this.dataBuffer.length);
286 stream.readArrayBuffer(buffer.byteLength, buffer);
288 let type = this.channel.contentType;
289 let blob = new Blob([buffer], { type });
291 if (type != "image/svg+xml") {
292 let octets = await promiseBlobAsOctets(blob);
293 let sniffer = Cc["@mozilla.org/image/loader;1"].createInstance(
296 type = sniffer.getMIMETypeFromContent(
303 throw Components.Exception(
304 `Favicon at "${this.icon.iconUri.spec}" did not match a known mimetype.`,
309 blob = blob.slice(0, blob.size, type);
313 image = await promiseImage(this.dataBuffer.newInputStream(0), type);
315 throw Components.Exception(
316 `Favicon at "${this.icon.iconUri.spec}" could not be decoded.`,
321 if (image.width > MAX_ICON_SIZE || image.height > MAX_ICON_SIZE) {
322 throw Components.Exception(
323 `Favicon at "${this.icon.iconUri.spec}" is too large.`,
329 let dataURL = await promiseBlobAsDataURL(blob);
331 this._deferred.resolve({
337 this._deferred.reject(e);
342 if (iid.equals(Ci.nsIChannelEventSink)) {
345 throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
350 * Extract the icon width from the size attribute. It also sends the telemetry
351 * about the size type and size dimension info.
353 * @param {Array} aSizes An array of strings about size.
354 * @return {Number} A width of the icon in pixel.
356 function extractIconSize(aSizes) {
359 const re = /^([1-9][0-9]*)x[1-9][0-9]*$/i;
362 for (let size of aSizes) {
363 if (size.toLowerCase() == "any") {
364 sizesType = SIZES_TELEMETRY_ENUM.ANY;
367 let values = re.exec(size);
368 if (values && values.length > 1) {
369 sizesType = SIZES_TELEMETRY_ENUM.DIMENSION;
370 width = parseInt(values[1]);
373 sizesType = SIZES_TELEMETRY_ENUM.INVALID;
379 sizesType = SIZES_TELEMETRY_ENUM.NO_SIZES;
382 // Telemetry probes for measuring the sizes attribute
383 // usage and available dimensions.
385 .getHistogramById("LINK_ICON_SIZES_ATTR_USAGE")
389 .getHistogramById("LINK_ICON_SIZES_ATTR_DIMENSION")
397 * Get link icon URI from a link dom node.
399 * @param {DOMNode} aLink A link dom node.
400 * @return {nsIURI} A uri of the icon.
402 function getLinkIconURI(aLink) {
403 let targetDoc = aLink.ownerDocument;
404 let uri = Services.io.newURI(aLink.href, targetDoc.characterSet);
406 uri = uri.mutate().setUserPass("").finalize();
408 // some URIs are immutable
414 * Guess a type for an icon based on its declared type or file extension.
416 function guessType(icon) {
417 // No type with no icon
422 // Use the file extension to guess at a type we're interested in
424 let extension = icon.iconUri.filePath.split(".").pop();
433 // Fuzzily prefer the type or fall back to the declared type
434 return icon.type == "image/vnd.microsoft.icon" ? TYPE_ICO : icon.type || "";
438 * Selects the best rich icon and tab icon from a list of IconInfo objects.
440 * @param {Array} iconInfos A list of IconInfo objects.
441 * @param {integer} preferredWidth The preferred width for tab icons.
443 function selectIcons(iconInfos, preferredWidth) {
444 if (!iconInfos.length) {
453 // Other links with the "icon" tag are the default icons
455 // Rich icons are either apple-touch or fluid icons, or the ones of the
456 // dimension 96x96 or greater
459 for (let icon of iconInfos) {
460 if (!icon.isRichIcon) {
461 // First check for svg. If it's not available check for an icon with a
462 // size adapt to the current resolution. If both are not available, prefer
463 // ico files. When multiple icons are in the same set, the latest wins.
464 if (guessType(icon) == TYPE_SVG) {
465 preferredIcon = icon;
467 icon.width == preferredWidth &&
468 guessType(preferredIcon) != TYPE_SVG
470 preferredIcon = icon;
472 guessType(icon) == TYPE_ICO &&
473 (!preferredIcon || guessType(preferredIcon) == TYPE_ICO)
475 preferredIcon = icon;
478 // Check for an icon larger yet closest to preferredWidth, that can be
479 // downscaled efficiently.
481 icon.width >= preferredWidth &&
482 (!bestSizedIcon || bestSizedIcon.width >= icon.width)
484 bestSizedIcon = icon;
488 // Note that some sites use hi-res icons without specifying them as
489 // apple-touch or fluid icons.
490 if (icon.isRichIcon || icon.width >= FAVICON_RICH_ICON_MIN_WIDTH) {
491 if (!largestRichIcon || largestRichIcon.width < icon.width) {
492 largestRichIcon = icon;
499 // Now set the favicons for the page in the following order:
500 // 1. Set the best rich icon if any.
501 // 2. Set the preferred one if any, otherwise check if there's a better
503 // This order allows smaller icon frames to eventually override rich icon
508 tabIcon = preferredIcon;
509 } else if (bestSizedIcon) {
510 tabIcon = bestSizedIcon;
511 } else if (defaultIcon) {
512 tabIcon = defaultIcon;
516 richIcon: largestRichIcon,
526 async load(iconInfo) {
528 this._loader.cancel();
531 if (LOCAL_FAVICON_SCHEMES.includes(iconInfo.iconUri.scheme)) {
532 // We need to do a manual security check because the channel won't do
535 Services.scriptSecurityManager.checkLoadURIWithPrincipal(
536 iconInfo.node.nodePrincipal,
538 Services.scriptSecurityManager.ALLOW_CHROME
543 this.actor.sendAsyncMessage("Link:SetIcon", {
544 pageURL: iconInfo.pageUri.spec,
545 originalURL: iconInfo.iconUri.spec,
546 canUseForTab: !iconInfo.isRichIcon,
547 expiration: undefined,
548 iconURL: iconInfo.iconUri.spec,
549 canStoreIcon: iconInfo.beforePageShow,
550 beforePageShow: iconInfo.beforePageShow,
555 // Let the main process that a tab icon is possibly coming.
556 this.actor.sendAsyncMessage("Link:LoadingIcon", {
557 originalURL: iconInfo.iconUri.spec,
558 canUseForTab: !iconInfo.isRichIcon,
562 this._loader = new FaviconLoad(iconInfo);
563 let { dataURL, expiration, canStoreIcon } = await this._loader.load();
565 this.actor.sendAsyncMessage("Link:SetIcon", {
566 pageURL: iconInfo.pageUri.spec,
567 originalURL: iconInfo.iconUri.spec,
568 canUseForTab: !iconInfo.isRichIcon,
572 beforePageShow: iconInfo.beforePageShow,
575 if (e.result != Cr.NS_BINDING_ABORTED) {
576 if (typeof e.data?.wrappedJSObject?.httpStatus !== "number") {
580 // Used mainly for tests currently.
581 this.actor.sendAsyncMessage("Link:SetFailedIcon", {
582 originalURL: iconInfo.iconUri.spec,
583 canUseForTab: !iconInfo.isRichIcon,
596 this._loader.cancel();
601 export class FaviconLoader {
606 // Icons added after onPageShow() are likely added by modifying <link> tags
607 // through javascript; we want to avoid storing those permanently because
608 // they are probably used to show badges, and many of them could be
609 // randomly generated. This boolean can be used to track that case.
610 this.beforePageShow = true;
612 // For every page we attempt to find a rich icon and a tab icon. These
613 // objects take care of the load process for each.
614 this.richIconLoader = new IconLoader(actor);
615 this.tabIconLoader = new IconLoader(actor);
617 this.iconTask = new lazy.DeferredTask(
618 () => this.loadIcons(),
619 FAVICON_PARSING_TIMEOUT
624 // If the page is unloaded immediately after the DeferredTask's timer fires
625 // we can still attempt to load icons, which will fail since the content
626 // window is no longer available. Checking if iconInfos has been cleared
627 // allows us to bail out early in this case.
628 if (!this.iconInfos.length) {
633 PREFERRED_WIDTH * Math.ceil(this.actor.contentWindow.devicePixelRatio);
634 let { richIcon, tabIcon } = selectIcons(this.iconInfos, preferredWidth);
638 this.richIconLoader.load(richIcon);
642 this.tabIconLoader.load(tabIcon);
646 addIconFromLink(aLink, aIsRichIcon) {
647 let iconInfo = makeFaviconFromLink(aLink, aIsRichIcon);
649 iconInfo.beforePageShow = this.beforePageShow;
650 this.iconInfos.push(iconInfo);
657 addDefaultIcon(pageUri) {
658 // Currently ImageDocuments will just load the default favicon, see bug
659 // 403651 for discussion.
660 this.iconInfos.push({
662 iconUri: pageUri.mutate().setPathQueryRef("/favicon.ico").finalize(),
666 node: this.actor.document,
667 beforePageShow: this.beforePageShow,
673 // We're likely done with icon parsing so load the pending icons now.
674 if (this.iconTask.isArmed) {
675 this.iconTask.disarm();
678 this.beforePageShow = false;
682 this.richIconLoader.cancel();
683 this.tabIconLoader.cancel();
685 this.iconTask.disarm();
690 function makeFaviconFromLink(aLink, aIsRichIcon) {
691 let iconUri = getLinkIconURI(aLink);
696 // Extract the size type and width.
697 let width = extractIconSize(aLink.sizes);
700 pageUri: aLink.ownerDocument.documentURIObject,
703 isRichIcon: aIsRichIcon,