Add a 'supportsDownloading' browser option to gate saving/downloading

Introduces a 'supportsDownloading' browser option (defaulting to false)
that lets embedders disable the save/download paths entirely. When
disabled:
  - the toolbar and secondary-toolbar download buttons are hidden;
  - PDFViewerApplication.{download,save,downloadOrSave} and the
    "beforeunload" save prompt bail out early;
  - the BaseDownloadManager helpers (download, downloadData,
    openOrDownloadData) and the Firefox/generic _triggerDownload
    implementations no-op.
This commit is contained in:
Calixte Denizet 2026-05-20 19:52:50 +02:00
parent 2ed018ec2d
commit ece1e2ed0c
5 changed files with 111 additions and 3 deletions

View File

@ -1455,6 +1455,72 @@ describe("PDF viewer", () => {
});
});
describe("Save/download disabled when supportsDownloading is false", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"tracemonkey.pdf",
".textLayer .endOfContent",
null,
null,
{ supportsDownloading: false }
);
});
afterEach(async () => {
await closePages(pages);
});
it("must hide the download buttons and skip save/download", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await page.waitForSelector("#downloadButton", { hidden: true });
await waitAndClick(page, "#secondaryToolbarToggleButton");
await page.waitForSelector("#secondaryDownload", { hidden: true });
const triggered = await page.evaluate(async () => {
const app = window.PDFViewerApplication;
const calls = [];
const saveDocument = app.pdfDocument.saveDocument.bind(
app.pdfDocument
);
app.pdfDocument.saveDocument = (...args) => {
calls.push("saveDocument");
return saveDocument(...args);
};
// Each bail-out path dispatches a TESTING-only "downloadskipped"
// event, so we can deterministically wait for all four attempts to
// run to completion.
let skipped = 0;
const allSkipped = new Promise(resolve => {
app.eventBus.on("downloadskipped", function listener() {
if (++skipped === 4) {
app.eventBus.off("downloadskipped", listener);
resolve();
}
});
});
await app.download();
await app.save();
await app.downloadOrSave();
app.eventBus.dispatch("download", { source: null });
await allSkipped;
return { calls, skipped, downloadManager: app.downloadManager };
});
expect(triggered.downloadManager)
.withContext(`In ${browserName}`)
.toBeNull();
expect(triggered.calls).withContext(`In ${browserName}`).toEqual([]);
expect(triggered.skipped).withContext(`In ${browserName}`).toBe(4);
})
);
});
});
describe("Pinch-zoom", () => {
let pages;

View File

@ -385,6 +385,7 @@ const PDFViewerApplication = {
maxCanvasPixels: x => parseInt(x, 10),
spreadModeOnLoad: x => parseInt(x, 10),
supportsCaretBrowsingMode: x => x === "true",
supportsDownloading: x => x === "true",
viewerCssTheme: x => parseInt(x, 10),
forcePageColors: x => x === "true",
pageColorsBackground: x => x,
@ -434,7 +435,16 @@ const PDFViewerApplication = {
ignoreDestinationZoom: AppOptions.get("ignoreDestinationZoom"),
}));
const downloadManager = (this.downloadManager = new DownloadManager());
const supportsDownloading = AppOptions.get("supportsDownloading");
const downloadManager = (this.downloadManager = supportsDownloading
? new DownloadManager()
: null);
if (appConfig.secondaryToolbar?.downloadButton) {
appConfig.secondaryToolbar.downloadButton.hidden = !supportsDownloading;
}
if (appConfig.toolbar?.download) {
appConfig.toolbar.download.hidden = !supportsDownloading;
}
const findController = (this.findController = new PDFFindController({
linkService,
@ -1311,6 +1321,13 @@ const PDFViewerApplication = {
},
async download() {
if (!this.downloadManager) {
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) {
this.eventBus.dispatch("downloadskipped", { source: this });
}
return;
}
let data;
try {
data = await (this.pdfDocument
@ -1323,6 +1340,13 @@ const PDFViewerApplication = {
},
async save() {
if (!this.downloadManager) {
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) {
this.eventBus.dispatch("downloadskipped", { source: this });
}
return;
}
if (this._saveInProgress) {
return;
}
@ -1354,6 +1378,13 @@ const PDFViewerApplication = {
},
async downloadOrSave() {
if (!this.downloadManager) {
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) {
this.eventBus.dispatch("downloadskipped", { source: this });
}
return;
}
// In the Firefox case, this method MUST always trigger a download.
// When the user is closing a modified and unsaved document, we display a
// prompt asking for saving or not. In case they save, we must wait for
@ -2442,6 +2473,9 @@ const PDFViewerApplication = {
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) {
return;
}
if (!this.downloadManager) {
return;
}
if (!this.pdfDocument) {
return;
}

View File

@ -102,6 +102,11 @@ const defaultOptions = {
value: true,
kind: OptionKind.BROWSER,
},
supportsDownloading: {
/** @type {boolean} */
value: true,
kind: OptionKind.BROWSER,
},
supportsIntegratedFind: {
/** @type {boolean} */
value: false,

View File

@ -126,7 +126,7 @@ class PDFAttachmentViewer extends BaseTreeViewer {
: fallbackContent;
if (content) {
this.downloadManager.openOrDownloadData(content, filename);
this.downloadManager?.openOrDownloadData(content, filename);
}
};

View File

@ -155,7 +155,10 @@ class PDFOutlineViewer extends BaseTreeViewer {
const content = await linkService.getAttachmentContent(attachmentId);
if (content) {
this.downloadManager.openOrDownloadData(content, attachment.filename);
this.downloadManager?.openOrDownloadData(
content,
attachment.filename
);
}
};