Source code
Revision control
Copy as Markdown
Other Tools
/* Copyright 2026 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const PDF_EMBED_FALLBACK_WEB_PAGE = "resource://pdf.js/embedFallback.html";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
});
function getDOMWindow(channel, principal) {
const requestor =
channel.notificationCallbacks ?? channel.loadGroup.notificationCallbacks;
const window = requestor.getInterface(Ci.nsIDOMWindow);
// Ensure the window wasn't navigated to something that is not the fallback.
return window.document.nodePrincipal.equals(principal) ? window : null;
}
/**
* Asks the parent process to reopen the PDF. A cross-site fallback process
* cannot initiate a load with the embedding page's principal, so the parent
* derives the URI and principal from the browsing context. The original
* referrer is forwarded.
*
* The fallback retains no PDF bytes, so opening it starts a new request that may
* return different content. The retry is a frame load checked by frame-src
* rather than object-src; the fallback converter handles only object loads.
*
* @param {Window} domWindow the window displaying the fallback page.
* @param {nsIReferrerInfo|null} referrerInfo the original request's referrer.
*/
function openPdf(domWindow, referrerInfo) {
domWindow.windowGlobalChild
.getActor("PdfEmbedFallback")
.sendAsyncMessage("PdfEmbedFallback:OpenPdf", {
referrerInfo: lazy.E10SUtils.serializeReferrerInfo(referrerInfo),
});
}
/**
* Stream converter serving a page that lets the user open a PDF embedded with
* an object or embed element through the normal PDF handling flow, when the
* built-in viewer is disabled: such an element cannot invoke the configured PDF
* handler by itself. The page is built on the in-content design system, which
* is desktop-only, hence pdfjs.embedFallback defaults to false on Android.
*
* PdfStreamConverter handles the PDFs the viewer displays, and only one of the
* two converters is registered at a time (see StreamConverterFactory).
*/
export class PdfEmbedFallbackStreamConverter {
QueryInterface = ChromeUtils.generateQI([
"nsIStreamConverter",
"nsIStreamListener",
"nsIRequestObserver",
]);
#binaryStream = null;
#listener = null;
convert() {
throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
}
asyncConvertData(aFromType, aToType, aListener, aContext) {
if (aContext && aContext instanceof Ci.nsIChannel) {
aContext.QueryInterface(Ci.nsIChannel);
}
this.getConvertedType(aFromType, aContext);
this.#listener = aListener;
}
getConvertedType(aFromType, aChannel) {
if (!aChannel) {
Components.returnCode = Cr.NS_ERROR_INVALID_ARG;
return "";
}
if (aChannel instanceof Ci.nsIMultiPartChannel) {
throw Components.Exception(
"The embedded PDF fallback doesn't support multipart responses.",
Cr.NS_ERROR_NOT_IMPLEMENTED
);
}
if (
aFromType != "application/pdf" ||
aChannel.loadInfo?.externalContentPolicyType !=
Ci.nsIContentPolicy.TYPE_OBJECT
) {
// The open button retries as TYPE_SUBDOCUMENT. Decline quietly so normal
// handler selection does not produce a console error.
Components.returnCode = Cr.NS_ERROR_FAILURE;
return "";
}
return "text/html";
}
onStartRequest(aRequest) {
const isHttpRequest = aRequest instanceof Ci.nsIHttpChannel;
aRequest.QueryInterface(Ci.nsIChannel);
aRequest.QueryInterface(Ci.nsIWritablePropertyBag);
// Change the content type so we don't get stuck in a loop.
aRequest.setProperty("contentType", aRequest.contentType);
aRequest.contentType = "text/html";
if (isHttpRequest) {
// These headers describe the PDF, not the fallback page: it carries its
// own CSP and must not be redirected or made to preload anything.
aRequest.setResponseHeader("Content-Security-Policy", "", false);
aRequest.setResponseHeader(
"Content-Security-Policy-Report-Only",
"",
false
);
aRequest.setResponseHeader("Refresh", "", false);
aRequest.setResponseHeader("Link", "", false);
}
aRequest.contentDisposition = Ci.nsIChannel.DISPOSITION_FORCE_INLINE;
this.#binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
Ci.nsIBinaryInputStream
);
this.#serveFallback(aRequest, isHttpRequest ? aRequest.referrerInfo : null);
}
onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
if (!this.#binaryStream) {
return;
}
// Read the data even though the fallback page doesn't use it:
// nsInputStreamPump::OnStateTransfer fails the request when onDataAvailable
// consumes nothing, which it detects by comparing nsITellableStream::Tell
// before and after. nsIInputStream offers no scriptable way to skip data
// (read and readSegments are [noscript]), so it really has to be read.
this.#binaryStream.setInputStream(aInputStream);
this.#binaryStream.readArrayBuffer(aCount, new ArrayBuffer(aCount));
}
onStopRequest() {
// Neither argument is needed: nothing consumed the PDF data, so how the
// request ended doesn't matter. It usually ended because we cancelled it.
this.#binaryStream = null;
}
/*
* Serve a page that lets the user open an embedded PDF through the normal PDF
* handling flow when the built-in viewer is disabled.
*
* The page is streamed from its own resource:// channel, exactly like
* PdfStreamConverter does for the viewer: its data is handed to our listener
* as if it came from aRequest, so the docshell builds the fallback document
* from aRequest and keeps showing the PDF URI.
*
* aRequest is therefore the request the document loader tracks for this load,
* which is what dictates the order below. We have no use for the PDF it keeps
* downloading, since the page doesn't display it and its button starts a
* brand new load, but it can only be dropped once it is out of the load group
* and the fallback page has loaded or is being torn down.
*/
#serveFallback(aRequest, referrerInfo) {
const channel = lazy.NetUtil.newChannel({
uri: PDF_EMBED_FALLBACK_WEB_PAGE,
loadUsingSystemPrincipal: true,
});
const resourcePrincipal =
Services.scriptSecurityManager.createContentPrincipal(
lazy.NetUtil.newURI(PDF_EMBED_FALLBACK_WEB_PAGE),
aRequest.loadInfo.originAttributes
);
const listener = this.#listener;
const stopDownloadingPdf = () => {
if (aRequest.isPending()) {
aRequest.cancel(Cr.NS_BINDING_ABORTED);
}
};
// Like the viewer proxy in PdfStreamConverter, these deliberately forward
// aRequest and not the fallback request they are called with: the docshell
// must keep seeing the channel this load started with, so the document it
// builds still belongs to the PDF URI.
const proxy = {
onStartRequest() {
listener.onStartRequest(aRequest);
},
onDataAvailable(fallbackRequest, inputStream, offset, count) {
listener.onDataAvailable(aRequest, inputStream, offset, count);
},
onStopRequest(fallbackRequest, statusCode) {
// The fallback page has been streamed. Take aRequest out of the load
// group so it doesn't keep the page loading until the PDF finishes.
// removeRequest synchronously notifies nsDocLoader, but fallbackRequest
// remains in the same load group until this callback returns. It keeps
// the loader busy while the lifecycle listeners are installed and the
// downstream listener receives onStopRequest below, so the document
// cannot finish midway through this setup. If aRequest already
// completed, it removed itself and removeRequest throws
// NS_ERROR_FAILURE.
const { loadGroup } = aRequest;
if (loadGroup) {
try {
loadGroup.removeRequest(aRequest, null, Cr.NS_OK);
} catch (error) {
if (error.result !== Cr.NS_ERROR_FAILURE) {
throw error;
}
}
aRequest.loadGroup = null;
}
let domWindow = null;
try {
domWindow = getDOMWindow(channel, resourcePrincipal);
} catch {
// The frame may have gone away before the fallback page loaded.
}
const hasFallbackPage =
Components.isSuccessCode(statusCode) && !!domWindow;
if (hasFallbackPage) {
// Cancelling the document request before load aborts the fallback,
// but pagehide must also cancel it if the frame is torn down first.
const stopWhenFallbackIsDone = () => {
domWindow.removeEventListener("load", stopWhenFallbackIsDone);
domWindow.removeEventListener("pagehide", stopWhenFallbackIsDone);
Services.tm.dispatchToMainThread(stopDownloadingPdf);
};
domWindow.addEventListener("load", stopWhenFallbackIsDone);
domWindow.addEventListener("pagehide", stopWhenFallbackIsDone);
domWindow.document.addEventListener(
"DOMContentLoaded",
() => {
domWindow.document
.getElementById("fallbackOpenButton")
.addEventListener("click", event => {
if (event.isTrusted) {
openPdf(domWindow, referrerInfo);
}
});
},
{ once: true }
);
}
listener.onStopRequest(aRequest, statusCode);
if (!hasFallbackPage) {
// There is no page to wait for.
stopDownloadingPdf();
}
},
};
// Preserve the original PDF URI on the replacement channel.
channel.originalURI = aRequest.URI;
channel.loadGroup = aRequest.loadGroup;
channel.loadInfo.originAttributes = aRequest.loadInfo.originAttributes;
aRequest.owner = resourcePrincipal;
channel.asyncOpen(proxy);
}
}