Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9790aca
downloads: Add onDeterminingFilename event schema
ssm9 Apr 27, 2026
fc7a0fa
downloads: Add determineFilename hook to DownloadIntegration
ssm9 Apr 27, 2026
f9b371e
downloads: Call DownloadIntegration.determineFilename() in Download.s…
ssm9 Apr 27, 2026
35b9488
extensions: Implement downloads.onDeterminingFilename
ssm9 Apr 27, 2026
8321fee
extensions: Remove stale onDeterminingFilename comment from eventpage…
ssm9 Apr 27, 2026
0432fbb
extensions: Add tests for downloads.onDeterminingFilename
ssm9 Apr 27, 2026
22e075d
extensions: Register test_ext_downloads_determining_filename in manifest
ssm9 Apr 27, 2026
822e33b
extensions: Make downloads.onDeterminingFilename a persistent event
ssm9 Jun 26, 2026
67b0f80
downloads: Add logging and first-suggestion-wins to determineFilename()
ssm9 Jun 26, 2026
1faae3d
extensions: Add child-side downloads module to inject suggest callback
ssm9 Jun 26, 2026
eafc2ce
extensions: Register child-side downloads module in ext-toolkit.js
ssm9 Jun 26, 2026
fcfbaf1
extensions: Package child/ext-downloads.js in jar.mn
ssm9 Jun 26, 2026
65da327
downloads: Add determineFilenameBeforeDialog() and double-fire guard
ssm9 Jul 2, 2026
ee7578e
downloads: Prevent double-firing of onDeterminingFilename in legacy path
ssm9 Jul 2, 2026
064e3fd
extensions: Fire onDeterminingFilename before Save As dialog in downl…
ssm9 Jul 2, 2026
aeb696f
downloads: Fire onDeterminingFilename in HelperAppDlg before save pat…
ssm9 Jul 2, 2026
f74b49b
extensions: Compute unique filename without creating placeholder in a…
ssm9 Jul 2, 2026
38e2d0d
downloads: Remove debug logging added during development
ssm9 Jul 2, 2026
f65c33c
extensions: Add tests for double-fire guard and uniquify counter beha…
ssm9 Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion toolkit/components/downloads/DownloadCore.sys.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ Download.prototype = {
* Resolves when the download has finished successfully.
* @rejects JavaScript exception if the download failed.
*/
start: function D_start() {
start: async function D_start() {
// If the download succeeded, it's the final state, we have nothing to do.
if (this.succeeded) {
return Promise.resolve();
Expand Down Expand Up @@ -474,6 +474,8 @@ Download.prototype = {
}
}

await lazy.DownloadIntegration.determineFilename(this);

// Now that we stored the promise in the download object, we can start the
// task that will actually execute the download.
deferAttempt.resolve(
Expand Down
112 changes: 112 additions & 0 deletions toolkit/components/downloads/DownloadIntegration.sys.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,118 @@ export var DownloadIntegration = {
return directoryPath;
},

/**
* A set of callbacks registered by the WebExtensions downloads API to handle
* the onDeterminingFilename event. Each callback receives a Download object
* and may return a { filename, conflictAction } suggestion.
*/
_determineFilenameCallbacks: new Set(),

/**
* Downloads for which determineFilename() was already called early (before
* the Save As dialog) via the extension API. Download.start() checks this
* to avoid firing onDeterminingFilename a second time.
*/
_determinedDownloads: new WeakSet(),

markFilenameAlreadyDetermined(download) {
this._determinedDownloads.add(download);
},

/**
* Tracks nsIHelperAppLauncher objects for which determineFilenameBeforeDialog
* has already been called, so DownloadLegacy can avoid double-firing
* onDeterminingFilename in Download.start().
*/
_processedLaunchers: new WeakSet(),

markLauncherProcessed(launcher) {
this._processedLaunchers.add(launcher.QueryInterface(Ci.nsISupports));
},

wasLauncherProcessed(cancelable) {
try {
return this._processedLaunchers.has(
cancelable.QueryInterface(Ci.nsISupports)
);
} catch {
return false;
}
},

/**
* Called from the helper-app download dialog before either the auto-save
* path or the Save As file picker is chosen, giving onDeterminingFilename
* listeners a chance to redirect the download to a different filename before
* the user sees any dialog.
*
* @param {string} url Source URL of the download.
* @param {string} tentativePath Full tentative destination path (using the
* default download directory and suggested
* filename).
* @param {string|null} mimeType MIME type of the download, if known.
* @param {boolean} isPrivate Whether this is a private-browsing download.
* @returns {Promise<string>} Resolves to the (possibly updated) path.
*/
async determineFilenameBeforeDialog(url, tentativePath, mimeType, isPrivate) {
if (!this._determineFilenameCallbacks.size) {
return tentativePath;
}
const syntheticDownload = {
source: { url, isPrivate: !!isPrivate },
target: { path: tentativePath, partFilePath: `${tentativePath}.part` },
_syntheticSerialized: {
id: -1,
url,
referrer: null,
filename: PathUtils.filename(tentativePath),
incognito: !!isPrivate,
cookieStoreId: isPrivate ? "firefox-private" : "firefox-default",
danger: "safe",
mime: mimeType ?? null,
startTime: null,
endTime: null,
estimatedEndTime: null,
state: "in_progress",
paused: false,
canResume: false,
error: null,
bytesReceived: 0,
totalBytes: -1,
fileSize: -1,
exists: false,
byExtensionId: null,
byExtensionName: null,
},
};
await this.determineFilename(syntheticDownload);
return syntheticDownload.target.path;
},

/**
* Called by Download.start() before execution begins, to give registered
* callbacks (i.e. WebExtension onDeterminingFilename listeners) a chance to
* override the target filename.
*
* @param {Download} download
*/
async determineFilename(download) {
if (this._determinedDownloads.has(download)) {
return;
}
const originalPath = download.target.path;
for (let callback of this._determineFilenameCallbacks) {
try {
await callback(download);
} catch (ex) {
console.error(ex);
}
if (download.target.path !== originalPath) {
break;
}
}
},

/**
* Checks to determine whether to block downloads for parental controls.
*
Expand Down
11 changes: 11 additions & 0 deletions toolkit/components/downloads/DownloadLegacy.sys.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
DownloadError: "resource://gre/modules/DownloadCore.sys.mjs",
DownloadIntegration: "resource://gre/modules/DownloadIntegration.sys.mjs",
Downloads: "resource://gre/modules/Downloads.sys.mjs",
});

Expand Down Expand Up @@ -347,6 +348,8 @@ DownloadLegacyTransfer.prototype = {
aHttpChannel = null
) {
this._cancelable = aCancelable;
this._determineFilenameCalledBeforeDialog =
lazy.DownloadIntegration.wasLauncherProcessed(aCancelable);
let launchWhenSucceeded = false,
contentType = null,
launcherPath = null,
Expand Down Expand Up @@ -431,6 +434,14 @@ DownloadLegacyTransfer.prototype = {
aDownload.tryToKeepPartialData = true;
}

// If onDeterminingFilename was already fired by HelperAppDlg before
// the Save As dialog (save-to-disk path), prevent Download.start()
// from firing it again. Downloads that bypassed HelperAppDlg (e.g.
// SetDownloadToLaunch for helper-app opens) go through normally.
if (this._determineFilenameCalledBeforeDialog) {
lazy.DownloadIntegration.markFilenameAlreadyDetermined(aDownload);
}

// Start the download before allowing it to be controlled. Ignore errors.
aDownload.start().catch(() => {});

Expand Down
89 changes: 89 additions & 0 deletions toolkit/components/extensions/child/ext-downloads.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/* 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";

let nextListenerId = 0;

this.downloads = class extends ExtensionAPI {
getAPI(context) {
const childManager = context.childManager;
const path = "downloads.onDeterminingFilename";

return {
downloads: {
onDeterminingFilename: {
addListener(fn) {
const map = childManager.listeners.get(path);
if (map.listeners.has(fn)) {
return;
}

const id = ++nextListenerId;

const wrappedFn = item => {
let suggestCalled = false;
let suggestValue;
let resolveAsync;

const suggest = Cu.exportFunction(suggestion => {
suggestCalled = true;
suggestValue = suggestion;
if (resolveAsync) {
resolveAsync(suggestion);
}
}, context.cloneScope);

const result = context.applySafeWithoutClone(fn, [item, suggest]);

if (result === true) {
if (suggestCalled) {
return suggestValue;
}
return new Promise(resolve => {
resolveAsync = resolve;
});
}
if (suggestCalled) {
return suggestValue;
}
return result;
};

map.ids.set(id, wrappedFn);
map.listeners.set(fn, id);

childManager.conduit.sendAddListener({
childId: childManager.id,
listenerId: id,
path,
args: [],
});
},

removeListener(fn) {
const map = childManager.listeners.get(path);
if (!map.listeners.has(fn)) {
return;
}

const id = map.listeners.get(fn);
map.listeners.delete(fn);
map.ids.delete(id);
map.removedIds.add(id);

childManager.conduit.sendRemoveListener({
childId: childManager.id,
listenerId: id,
path,
});
},

hasListener(fn) {
return childManager.listeners.get(path).listeners.has(fn);
},
},
},
};
}
};
5 changes: 5 additions & 0 deletions toolkit/components/extensions/child/ext-toolkit.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
global.EventManager = ExtensionCommon.EventManager;

extensions.registerModules({
downloads: {
url: "chrome://extensions/content/child/ext-downloads.js",
scopes: ["addon_child"],
paths: [["downloads", "onDeterminingFilename"]],
},
backgroundPage: {
url: "chrome://extensions/content/child/ext-backgroundPage.js",
scopes: ["addon_child"],
Expand Down
1 change: 1 addition & 0 deletions toolkit/components/extensions/jar.mn
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ toolkit.jar:
content/extensions/parent/ext-webRequest.js (parent/ext-webRequest.js)
content/extensions/parent/ext-webNavigation.js (parent/ext-webNavigation.js)
content/extensions/child/ext-backgroundPage.js (child/ext-backgroundPage.js)
content/extensions/child/ext-downloads.js (child/ext-downloads.js)
content/extensions/child/ext-contentScripts.js (child/ext-contentScripts.js)
content/extensions/child/ext-declarativeNetRequest.js (child/ext-declarativeNetRequest.js)
content/extensions/child/ext-extension.js (child/ext-extension.js)
Expand Down
Loading
Loading